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

@@ -50,6 +50,7 @@ Chat view provides project-scoped conversations with agents.
- Chat message lists now track near-bottom scroll state: while you are reading older messages, live streaming/new replies do not force-scroll; a **Latest** jump control appears until you return to the tail. - Chat message lists now track near-bottom scroll state: while you are reading older messages, live streaming/new replies do not force-scroll; a **Latest** jump control appears until you return to the tail.
- On mobile (`max-width: 768px`), chat bubbles are slightly wider in full Chat for improved readability while preserving header/composer gutters. - On mobile (`max-width: 768px`), chat bubbles are slightly wider in full Chat for improved readability while preserving header/composer gutters.
- Full Chat tool-call summaries now use a denser mobile layout: grouped and single-call collapsed rows keep icon + label + status on one line (Quick Chat-style scanability) while expanded details remain unchanged. - Full Chat tool-call summaries now use a denser mobile layout: grouped and single-call collapsed rows keep icon + label + status on one line (Quick Chat-style scanability) while expanded details remain unchanged.
- The desktop Chat view toggle and mobile Chat tab now show an unread-response indicator when a live assistant reply arrives for your active chat thread after you leave Chat; opening Chat clears it immediately.
![Chat view](./screenshots/chat-view.png) ![Chat view](./screenshots/chat-view.png)

View File

@@ -115,6 +115,7 @@ function prefetchLazyViews() {
} }
const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed"; 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 { function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string {
const url = new URL(serverUrl); const url = new URL(serverUrl);
@@ -345,8 +346,9 @@ function AppInner() {
// via useMobileScrollLock — the reference-counted hook handles overlap. // via useMobileScrollLock — the reference-counted hook handles overlap.
useMobileScrollLock(mobileKeyboardOpen); 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 [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
const [chatHasUnreadResponse, setChatHasUnreadResponse] = useState(false);
const refreshMailboxUnreadCount = useCallback(() => { const refreshMailboxUnreadCount = useCallback(() => {
fetchUnreadCount(currentProject?.id) fetchUnreadCount(currentProject?.id)
@@ -378,6 +380,39 @@ function AppInner() {
}); });
}, [currentProject?.id, refreshMailboxUnreadCount]); }, [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. // Nodes management is an overlay view (not a modal), so it stays local to App.
const [nodesOpen, setNodesOpen] = useState(false); const [nodesOpen, setNodesOpen] = useState(false);
const [retryingProjects, setRetryingProjects] = useState(false); const [retryingProjects, setRetryingProjects] = useState(false);
@@ -1193,6 +1228,7 @@ function AppInner() {
onOpenSystemStats={openSystemStatsWithNav} onOpenSystemStats={openSystemStatsWithNav}
onOpenMailbox={() => handleTaskViewChange("mailbox")} onOpenMailbox={() => handleTaskViewChange("mailbox")}
mailboxUnreadCount={mailboxUnreadCount} mailboxUnreadCount={mailboxUnreadCount}
chatHasUnreadResponse={chatHasUnreadResponse}
onOpenSchedules={openSchedulesWithNav} onOpenSchedules={openSchedulesWithNav}
onOpenGitManager={openGitManagerWithNav} onOpenGitManager={openGitManagerWithNav}
onOpenNodes={handleOpenNodesWithNav} onOpenNodes={handleOpenNodesWithNav}
@@ -1314,6 +1350,7 @@ function AppInner() {
onOpenMailbox={() => handleTaskViewChange("mailbox")} onOpenMailbox={() => handleTaskViewChange("mailbox")}
onOpenNodes={handleOpenNodesWithNav} onOpenNodes={handleOpenNodesWithNav}
mailboxUnreadCount={mailboxUnreadCount} mailboxUnreadCount={mailboxUnreadCount}
chatHasUnreadResponse={chatHasUnreadResponse}
onOpenGitManager={openGitManagerWithNav} onOpenGitManager={openGitManagerWithNav}
onOpenWorkflowSteps={openWorkflowStepsWithNav} onOpenWorkflowSteps={openWorkflowStepsWithNav}
onOpenSchedules={openSchedulesWithNav} onOpenSchedules={openSchedulesWithNav}

View File

@@ -624,6 +624,7 @@
justify-content: center; justify-content: center;
width: 28px; width: 28px;
height: 28px; height: 28px;
position: relative;
background: none; background: none;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -652,6 +653,12 @@
color: var(--bg); 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 Dropdown === */
.view-toggle-overflow-menu { .view-toggle-overflow-menu {
position: absolute; position: absolute;

View File

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

View File

@@ -83,6 +83,17 @@
height: 22px; 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 { .mobile-nav-tab-label {
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;

View File

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

View File

@@ -539,8 +539,9 @@ beforeEach(() => {
mockNodeContextValue.clearCurrentNode.mockClear(); mockNodeContextValue.clearCurrentNode.mockClear();
// Clear node selection from localStorage to avoid cross-test leakage // Clear node selection from localStorage to avoid cross-test leakage
localStorage.removeItem("fusion-dashboard-current-node"); localStorage.removeItem("fusion-dashboard-current-node");
// Clear onboarding state from localStorage // Clear onboarding/chat state from localStorage
localStorage.removeItem("kb-onboarding-state"); localStorage.removeItem("kb-onboarding-state");
localStorage.removeItem(scopedKey("kb-chat-active-session", "proj_123"));
// Reset onboarding state mocks // Reset onboarding state mocks
mockIsOnboardingResumable.mockReset(); mockIsOnboardingResumable.mockReset();
mockIsOnboardingResumable.mockReturnValue(false); 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", () => { describe("App deep link handling", () => {
const originalLocation = window.location; const originalLocation = window.location;
const originalReplaceState = window.history.replaceState; const originalReplaceState = window.history.replaceState;

View File

@@ -138,6 +138,16 @@ describe("Header", () => {
expect(onChangeView).toHaveBeenCalledWith("list"); 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", () => { it("has correct aria attributes for accessibility", () => {
renderHeader({ onChangeView: noop, view: "board" }); renderHeader({ onChangeView: noop, view: "board" });
const boardBtn = screen.getByTitle("Board view"); 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"); 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'", () => { it("skills tab calls onChangeView with 'skills'", () => {
const props = createDefaultProps(); const props = createDefaultProps();
render(<MobileNavBar {...props} view="board" showSkillsTab={true} />); render(<MobileNavBar {...props} view="board" showSkillsTab={true} />);