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:
@@ -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.
|
||||||
|
|
||||||

|

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