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:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" : ""}`}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
Reference in New Issue
Block a user