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

@@ -115,6 +115,7 @@ function prefetchLazyViews() {
}
const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed";
const ACTIVE_CHAT_SESSION_STORAGE_KEY = "kb-chat-active-session";
function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string {
const url = new URL(serverUrl);
@@ -345,8 +346,9 @@ function AppInner() {
// via useMobileScrollLock — the reference-counted hook handles overlap.
useMobileScrollLock(mobileKeyboardOpen);
// App-level mailbox unread count state (used for header/mobile nav badges)
// App-level mailbox/chat unread state (used for header/mobile nav badges)
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
const [chatHasUnreadResponse, setChatHasUnreadResponse] = useState(false);
const refreshMailboxUnreadCount = useCallback(() => {
fetchUnreadCount(currentProject?.id)
@@ -378,6 +380,39 @@ function AppInner() {
});
}, [currentProject?.id, refreshMailboxUnreadCount]);
useEffect(() => {
if (taskView === "chat") {
setChatHasUnreadResponse(false);
}
}, [taskView]);
useEffect(() => {
const params = new URLSearchParams();
if (currentProject?.id) {
params.set("projectId", currentProject.id);
}
const query = params.size > 0 ? `?${params.toString()}` : "";
return subscribeSse(`/api/events${query}`, {
events: {
"chat:message:added": (event: MessageEvent) => {
try {
const payload = JSON.parse(event.data) as { role?: string; sessionId?: string; projectId?: string | null };
const activeSessionId = getScopedItem(ACTIVE_CHAT_SESSION_STORAGE_KEY, currentProject?.id);
if (!activeSessionId) return;
if (payload.role !== "assistant") return;
if (taskView === "chat") return;
if (payload.sessionId !== activeSessionId) return;
if (payload.projectId && currentProject?.id && payload.projectId !== currentProject.id) return;
setChatHasUnreadResponse(true);
} catch {
// no-op
}
},
},
});
}, [currentProject?.id, taskView]);
// Nodes management is an overlay view (not a modal), so it stays local to App.
const [nodesOpen, setNodesOpen] = useState(false);
const [retryingProjects, setRetryingProjects] = useState(false);
@@ -1193,6 +1228,7 @@ function AppInner() {
onOpenSystemStats={openSystemStatsWithNav}
onOpenMailbox={() => handleTaskViewChange("mailbox")}
mailboxUnreadCount={mailboxUnreadCount}
chatHasUnreadResponse={chatHasUnreadResponse}
onOpenSchedules={openSchedulesWithNav}
onOpenGitManager={openGitManagerWithNav}
onOpenNodes={handleOpenNodesWithNav}
@@ -1314,6 +1350,7 @@ function AppInner() {
onOpenMailbox={() => handleTaskViewChange("mailbox")}
onOpenNodes={handleOpenNodesWithNav}
mailboxUnreadCount={mailboxUnreadCount}
chatHasUnreadResponse={chatHasUnreadResponse}
onOpenGitManager={openGitManagerWithNav}
onOpenWorkflowSteps={openWorkflowStepsWithNav}
onOpenSchedules={openSchedulesWithNav}

View File

@@ -624,6 +624,7 @@
justify-content: center;
width: 28px;
height: 28px;
position: relative;
background: none;
border: none;
border-radius: var(--radius-sm);
@@ -652,6 +653,12 @@
color: var(--bg);
}
.header-chat-unread-dot {
position: absolute;
top: calc(var(--space-xs) * -1);
right: calc(var(--space-xs) * -1);
}
/* === View Toggle Overflow Dropdown === */
.view-toggle-overflow-menu {
position: absolute;

View File

@@ -181,6 +181,8 @@ export interface HeaderProps {
onOpenMailbox?: () => void;
/** Unread message count for badge display */
mailboxUnreadCount?: number;
/** Whether chat has an unread assistant response */
chatHasUnreadResponse?: boolean;
onOpenSchedules?: () => void;
onOpenGitManager?: () => void;
onOpenNodes?: () => void;
@@ -242,6 +244,7 @@ export function Header({
onOpenSystemStats,
onOpenMailbox,
mailboxUnreadCount = 0,
chatHasUnreadResponse = false,
onOpenSchedules,
onOpenGitManager,
onOpenNodes,
@@ -1107,8 +1110,12 @@ export function Header({
title="Chat view"
aria-label="Chat view"
aria-pressed={view === "chat"}
data-testid="header-chat-view-btn"
>
<MessageSquare size={16} />
{chatHasUnreadResponse && view !== "chat" && (
<span className="status-dot status-dot--pending header-chat-unread-dot" aria-label="Unread chat response" />
)}
</button>
<button
className={`view-toggle-btn${view === "documents" ? " active" : ""}`}

View File

@@ -83,6 +83,17 @@
height: 22px;
}
.mobile-nav-tab-icon-wrapper {
position: relative;
display: inline-flex;
}
.mobile-nav-chat-unread-dot {
position: absolute;
top: calc(var(--space-xs) * -1);
right: calc(var(--space-xs) * -1);
}
.mobile-nav-tab-label {
max-width: 100%;
overflow: hidden;

View File

@@ -54,6 +54,7 @@ export interface MobileNavBarProps {
onOpenSystemStats?: () => void;
onOpenMailbox?: () => void;
mailboxUnreadCount?: number;
chatHasUnreadResponse?: boolean;
onOpenGitManager?: () => void;
onOpenWorkflowSteps?: () => void;
onOpenSchedules?: () => void;
@@ -116,6 +117,7 @@ export function MobileNavBar({
onOpenSystemStats,
onOpenMailbox,
mailboxUnreadCount = 0,
chatHasUnreadResponse = false,
onOpenGitManager,
onOpenWorkflowSteps,
onOpenSchedules,
@@ -303,7 +305,12 @@ export function MobileNavBar({
aria-selected={view === "chat"}
onClick={() => onChangeView("chat")}
>
<MessageSquare />
<span className="mobile-nav-tab-icon-wrapper">
<MessageSquare />
{chatHasUnreadResponse && view !== "chat" && (
<span className="status-dot status-dot--pending mobile-nav-chat-unread-dot" aria-label="Unread chat response" />
)}
</span>
<span className="mobile-nav-tab-label">Chat</span>
</button>

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;

View File

@@ -138,6 +138,16 @@ describe("Header", () => {
expect(onChangeView).toHaveBeenCalledWith("list");
});
it("shows chat unread indicator when chatHasUnreadResponse is true and chat is not active", () => {
renderHeader({ onChangeView: noop, view: "board", chatHasUnreadResponse: true });
expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
});
it("hides chat unread indicator when chat view is active", () => {
renderHeader({ onChangeView: noop, view: "chat", chatHasUnreadResponse: true });
expect(screen.queryByLabelText("Unread chat response")).toBeNull();
});
it("has correct aria attributes for accessibility", () => {
renderHeader({ onChangeView: noop, view: "board" });
const boardBtn = screen.getByTitle("Board view");

View File

@@ -315,6 +315,16 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-nav-tab-missions").className).not.toContain("mobile-nav-tab--active");
});
it("shows chat unread indicator when chatHasUnreadResponse is true and chat tab is inactive", () => {
render(<MobileNavBar {...createDefaultProps()} view="board" chatHasUnreadResponse={true} />);
expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
});
it("hides chat unread indicator when chat tab is active", () => {
render(<MobileNavBar {...createDefaultProps()} view="chat" chatHasUnreadResponse={true} />);
expect(screen.queryByLabelText("Unread chat response")).toBeNull();
});
it("skills tab calls onChangeView with 'skills'", () => {
const props = createDefaultProps();
render(<MobileNavBar {...props} view="board" showSkillsTab={true} />);