FN-7392: suppress hidden planner chat unread badges

Suppress hidden task-planner chat replies from triggering the global unread badge while preserving opt-in common-feed behavior.

- Enrich chat message SSE payloads with session agent metadata and task chat common-feed visibility.
- Ignore hidden `task-planner:` assistant events in the chat unread badge hook.
- Cover hidden, visible, and legacy payload behavior with dashboard SSE and hook tests.
- Document the unread badge scope and add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-7392-planner-chat-unread.md          |   7 +
 docs/dashboard-guide.md                            |   2 +-
 .../app/components/__tests__/App.test.tsx          |  55 ++++++++
 .../app/hooks/__tests__/useChatUnreadBadge.test.ts | 110 ++++++++++++++++
 packages/dashboard/app/hooks/useChatUnreadBadge.ts |  32 ++++-
 packages/dashboard/src/__tests__/sse.test.ts       | 141 ++++++++++++++++++++-
 packages/dashboard/src/sse.ts                      |  36 +++++-
 7 files changed, 378 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-7392

Fusion-Task-Lineage: 13fd5c51-eefa-4088-979b-005c8b217f89

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 12:25:48 -07:00
parent bfe5ceddc8
commit 4beae7130d
7 changed files with 378 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep hidden task-planner Chat replies from lighting the global Chat unread badge.
category: fix
dev: Enriches direct chat SSE payloads with session agent metadata plus common-feed visibility, then suppresses `task-planner:` unread badges only while hidden.

View File

@@ -414,7 +414,7 @@ Chat view provides project-scoped conversations with agents.
- 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.
<!-- FNXC:ChatAskQuestion 2026-06-17-16:35: Dashboard chat agents have a Fusion-native `fn_ask_question` tool, so the documented question-card behavior must cover both provider-native question tools and Fusion's first-party tool. -->
- Assistant question tool calls now render as a shared in-chat response card instead of a generic tool-call disclosure. The card recognizes provider-native question tools and Fusion's `fn_ask_question`, supports select, multi-select, text, and yes/no prompts, sends the formatted answer back into the same direct or room thread, and renders historical answered questions read-only.
- 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.
- The desktop Chat view toggle and mobile Chat tab now show an unread-response indicator when a live assistant reply arrives for a visible direct or room chat after you leave Chat; opening Chat clears it immediately. Task-detail planner Chat replies stay task-local and do not light up the global Chat unread indicator while those sessions are hidden from the common Chat feed.
- Agent-backed chat sessions now expose the same mailbox messaging tools (`fn_send_message`, `fn_read_messages`) used by runtime execution/heartbeat flows whenever the engine `MessageStore` is available; model-only chats continue to run without mailbox tools.
- Chat attachments are included in agent-visible prompts for both direct sessions and rooms: supported text attachments are appended under an `Attachments` prompt section, and supported images (`png`, `jpeg`, `gif`, `webp`) are passed as image inputs to the model.
- Chat attachments can be sent without accompanying text in both Quick Chat and Main Chat; fully empty sends with no text and no attachments are still blocked.

View File

@@ -1329,6 +1329,61 @@ describe("App chat unread response indicator", () => {
});
});
it("does not show unread indicator for hidden planner assistant messages", async () => {
const events = await getChatEvents();
await act(async () => {
events["chat:message:added"](
new MessageEvent("chat:message:added", {
data: JSON.stringify({ role: "assistant", sessionId: "sess-planner", agentId: "task-planner:FN-7392" }),
}),
);
});
const chatNav = screen.getByTestId("sidebar-nav-chat");
expect(chatNav).toBeInTheDocument();
expect(chatUnreadDot()).toBeNull();
expect(chatNav.querySelector(".left-sidebar-nav__dot")).toBeNull();
});
it("does not show mobile unread indicator for hidden planner assistant messages", async () => {
mockUseViewportMode.mockReturnValue("mobile");
const events = await getChatEvents();
await act(async () => {
events["chat:message:added"](
new MessageEvent("chat:message:added", {
data: JSON.stringify({ role: "assistant", sessionId: "sess-planner", agentId: "task-planner:FN-7392" }),
}),
);
});
const mobileChatNav = screen.getByTestId("mobile-nav-tab-chat");
expect(mobileChatNav).toBeInTheDocument();
expect(mobileChatNav.querySelector(".mobile-nav-chat-unread-dot")).toBeNull();
});
it("shows unread indicator for planner assistant messages visible in the common Chat feed", async () => {
const events = await getChatEvents();
await act(async () => {
events["chat:message:added"](
new MessageEvent("chat:message:added", {
data: JSON.stringify({
role: "assistant",
sessionId: "sess-planner",
agentId: "task-planner:FN-7392",
taskChatVisibleInCommonFeed: true,
}),
}),
);
});
await waitFor(() => {
expect(chatUnreadDot()).not.toBeNull();
});
});
it("does not show unread indicator for individual user messages", async () => {
const events = await getChatEvents();

View File

@@ -38,6 +38,67 @@ describe("useChatUnreadBadge", () => {
expect(result.current.chatHasUnreadResponse).toBe(true);
});
it("ignores planner assistant messages hidden from the global Chat feed", () => {
const { result } = renderHook(() =>
useChatUnreadBadge(undefined, { taskView: "board", quickChatOpen: false }),
);
act(() => {
handlers["chat:message:added"]?.(
message({ role: "assistant", sessionId: "sess-planner", agentId: "task-planner:FN-7392" }),
);
});
expect(result.current.chatHasUnreadResponse).toBe(false);
});
it("ignores planner assistant messages when session metadata carries the synthetic agent id", () => {
const { result } = renderHook(() =>
useChatUnreadBadge(undefined, { taskView: "board", quickChatOpen: false }),
);
act(() => {
handlers["chat:message:added"]?.(
message({ role: "assistant", sessionId: "sess-planner", session: { agentId: "task-planner:FN-7392" } }),
);
});
expect(result.current.chatHasUnreadResponse).toBe(false);
});
it("marks unread for planner assistant messages visible in the common Chat feed", () => {
const { result } = renderHook(() =>
useChatUnreadBadge(undefined, { taskView: "board", quickChatOpen: false }),
);
act(() => {
handlers["chat:message:added"]?.(
message({
role: "assistant",
sessionId: "sess-planner",
agentId: "task-planner:FN-7392",
taskChatVisibleInCommonFeed: true,
}),
);
});
expect(result.current.chatHasUnreadResponse).toBe(true);
});
it("ignores planner user messages", () => {
const { result } = renderHook(() =>
useChatUnreadBadge(undefined, { taskView: "board", quickChatOpen: false }),
);
act(() => {
handlers["chat:message:added"]?.(
message({ role: "user", sessionId: "sess-planner", agentId: "task-planner:FN-7392" }),
);
});
expect(result.current.chatHasUnreadResponse).toBe(false);
});
it("ignores user-role messages", () => {
const { result } = renderHook(() =>
useChatUnreadBadge(undefined, { taskView: "board", quickChatOpen: false }),
@@ -62,6 +123,18 @@ describe("useChatUnreadBadge", () => {
expect(result.current.chatHasUnreadResponse).toBe(false);
});
it("ignores assistant messages while quick chat is open", () => {
const { result } = renderHook(() =>
useChatUnreadBadge(undefined, { taskView: "board", quickChatOpen: true }),
);
act(() => {
handlers["chat:message:added"]?.(message({ role: "assistant" }));
});
expect(result.current.chatHasUnreadResponse).toBe(false);
});
it("clears the unread flag once the chat view opens", () => {
const { result, rerender } = renderHook(
({ taskView }: { taskView: TaskView }) =>
@@ -101,6 +174,18 @@ describe("useChatUnreadBadge", () => {
expect(result.current.chatHasUnreadResponse).toBe(false);
});
it("marks unread for assistant messages scoped to the current project", () => {
const { result } = renderHook(() =>
useChatUnreadBadge("p1", { taskView: "board", quickChatOpen: false }),
);
act(() => {
handlers["chat:message:added"]?.(message({ role: "assistant", projectId: "p1" }));
});
expect(result.current.chatHasUnreadResponse).toBe(true);
});
it("ignores assistant messages scoped to a different project", () => {
const { result } = renderHook(() =>
useChatUnreadBadge("p1", { taskView: "board", quickChatOpen: false }),
@@ -112,6 +197,18 @@ describe("useChatUnreadBadge", () => {
expect(result.current.chatHasUnreadResponse).toBe(false);
});
it("marks unread for assistant chat:room:message:added events scoped to the current project", () => {
const { result } = renderHook(() =>
useChatUnreadBadge("p1", { taskView: "board", quickChatOpen: false }),
);
act(() => {
handlers["chat:room:message:added"]?.(message({ role: "assistant", projectId: "p1" }));
});
expect(result.current.chatHasUnreadResponse).toBe(true);
});
it("ignores assistant chat:room:message:added events scoped to a different project", () => {
const { result } = renderHook(() =>
useChatUnreadBadge("p1", { taskView: "board", quickChatOpen: false }),
@@ -123,4 +220,17 @@ describe("useChatUnreadBadge", () => {
expect(result.current.chatHasUnreadResponse).toBe(false);
});
it("ignores malformed unread event payloads", () => {
const { result } = renderHook(() =>
useChatUnreadBadge(undefined, { taskView: "board", quickChatOpen: false }),
);
act(() => {
handlers["chat:message:added"]?.({ data: "{" } as MessageEvent);
handlers["chat:room:message:added"]?.({ data: "not-json" } as MessageEvent);
});
expect(result.current.chatHasUnreadResponse).toBe(false);
});
});

View File

@@ -1,6 +1,9 @@
/*
FNXC:ChatBadge 2026-06-24-00:00:
Header/mobile-nav unread indicator for assistant chat responses. Set when an assistant message arrives over SSE while the user is not viewing chat, and cleared when the chat view (or quick-chat window) opens. Extracted verbatim from AppInner.
FNXC:ChatBadge 2026-07-01-00:00:
Task-detail planner chats use synthetic `task-planner:<taskId>` direct sessions that are hidden from the common Chat feed unless the project explicitly opts them back in. Ignore planner assistant events only while the SSE visibility metadata says that planner session is absent from global Chat, so opt-in shared-feed projects still get normal unread badges.
*/
import { useEffect, useState } from "react";
@@ -8,6 +11,32 @@ import type { ChatRoomMessage } from "@fusion/core";
import { subscribeSse } from "../sse-bus";
import type { TaskView } from "./useViewState";
const TASK_PLANNER_CHAT_AGENT_ID_PREFIX = "task-planner:";
type ChatMessageAddedPayload = {
role?: string;
projectId?: string | null;
agentId?: string | null;
session?: { agentId?: string | null } | null;
chatSession?: { agentId?: string | null } | null;
taskChatVisibleInCommonFeed?: boolean | null;
};
function isTaskPlannerChatMessage(payload: ChatMessageAddedPayload): boolean {
const candidateAgentIds = [
payload.agentId,
payload.session?.agentId,
payload.chatSession?.agentId,
];
return candidateAgentIds.some(
(agentId) => typeof agentId === "string" && agentId.startsWith(TASK_PLANNER_CHAT_AGENT_ID_PREFIX),
);
}
function isHiddenTaskPlannerChatMessage(payload: ChatMessageAddedPayload): boolean {
return isTaskPlannerChatMessage(payload) && payload.taskChatVisibleInCommonFeed !== true;
}
export interface UseChatUnreadBadgeOptions {
taskView: TaskView;
quickChatOpen: boolean;
@@ -40,8 +69,9 @@ export function useChatUnreadBadge(
events: {
"chat:message:added": (event: MessageEvent) => {
try {
const payload = JSON.parse(event.data) as { role?: string; projectId?: string | null };
const payload = JSON.parse(event.data) as ChatMessageAddedPayload;
if (payload.role !== "assistant") return;
if (isHiddenTaskPlannerChatMessage(payload)) return;
if (taskView === "chat" || quickChatOpen) return;
if (payload.projectId && currentProjectId && payload.projectId !== currentProjectId) return;
setChatHasUnreadResponse(true);

View File

@@ -1,7 +1,7 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Request, Response } from "express";
import type { TaskStore, AutomationStore } from "@fusion/core";
import type { TaskStore, AutomationStore, ChatStore } from "@fusion/core";
import {
createSSE,
disconnectSSEClient,
@@ -42,7 +42,7 @@ class MockResponse extends EventEmitter {
}
}
function createMockStore(): TaskStore {
function createMockStore(settings: Record<string, unknown> = {}): TaskStore {
const researchStore = {
on: vi.fn(),
off: vi.fn(),
@@ -51,6 +51,7 @@ function createMockStore(): TaskStore {
on: vi.fn(),
off: vi.fn(),
getResearchStore: vi.fn(() => researchStore),
getSettings: vi.fn(async () => settings),
} as unknown as TaskStore;
}
@@ -104,6 +105,47 @@ function openSseConnectionWithAutomation(clientId: string, projectId?: string) {
return { req, res, socket, store, automationStore };
}
function createMockChatStore(sessions: Record<string, { id: string; agentId: string; projectId?: string | null }>): ChatStore {
const emitter = new EventEmitter();
return Object.assign(emitter, {
getSession: vi.fn((id: string) => sessions[id]),
}) as unknown as ChatStore;
}
function openSseConnectionWithChatStore(
clientId: string,
chatStore: ChatStore,
projectId?: string,
settings: Record<string, unknown> = {},
) {
const store = createMockStore(settings);
const socket = new MockSocket();
const req = new EventEmitter() as Request & { query: Record<string, string>; socket: MockSocket };
req.query = projectId ? { clientId, projectId } : { clientId };
req.socket = socket;
const res = new MockResponse(socket);
createSSE(
store,
undefined,
undefined,
undefined,
projectId ? { projectId } : undefined,
undefined,
undefined,
chatStore,
)(req, res as unknown as Response);
return { req, res, socket, store, chatStore };
}
function parseSsePayload(writeCall: unknown[]): Record<string, unknown> {
const frame = String(writeCall[0]);
const dataLine = frame.split("\n").find((line) => line.startsWith("data: "));
if (!dataLine) throw new Error(`No SSE data line in frame: ${frame}`);
return JSON.parse(dataLine.slice("data: ".length)) as Record<string, unknown>;
}
afterEach(() => {
vi.useRealTimers();
});
@@ -186,6 +228,101 @@ describe("plugin custom SSE events", () => {
});
});
describe("chat store SSE events", () => {
it("enriches direct message payloads with hidden planner session metadata", async () => {
const chatStore = createMockChatStore({
"sess-planner": { id: "sess-planner", agentId: "task-planner:FN-7392", projectId: "project-a" },
});
const connection = openSseConnectionWithChatStore("chat-planner-metadata", chatStore, "project-a");
chatStore.emit("chat:message:added", {
id: "msg-1",
sessionId: "sess-planner",
role: "assistant",
content: "Done",
});
await vi.waitFor(() => {
expect(connection.res.write).toHaveBeenCalledWith(expect.stringContaining("event: chat:message:added"));
});
const payload = parseSsePayload(vi.mocked(connection.res.write).mock.calls.at(-1) ?? []);
expect(payload).toMatchObject({
id: "msg-1",
sessionId: "sess-planner",
role: "assistant",
content: "Done",
agentId: "task-planner:FN-7392",
projectId: "project-a",
taskChatVisibleInCommonFeed: false,
});
connection.req.emit("close");
});
it("enriches planner session visibility when project opts into the common feed", async () => {
const chatStore = createMockChatStore({
"sess-planner": { id: "sess-planner", agentId: "task-planner:FN-7392", projectId: "project-a" },
});
const connection = openSseConnectionWithChatStore("chat-planner-visible", chatStore, "project-a", {
showTaskChatsInCommonFeed: true,
});
chatStore.emit("chat:message:added", {
id: "msg-visible",
sessionId: "sess-planner",
role: "assistant",
content: "Visible",
});
await vi.waitFor(() => {
expect(connection.res.write).toHaveBeenCalledWith(expect.stringContaining("event: chat:message:added"));
});
const payload = parseSsePayload(vi.mocked(connection.res.write).mock.calls.at(-1) ?? []);
expect(payload).toMatchObject({
id: "msg-visible",
sessionId: "sess-planner",
role: "assistant",
content: "Visible",
agentId: "task-planner:FN-7392",
projectId: "project-a",
taskChatVisibleInCommonFeed: true,
});
connection.req.emit("close");
});
it("keeps normal direct message payload fields compatible while adding session metadata", async () => {
const chatStore = createMockChatStore({
"sess-direct": { id: "sess-direct", agentId: "agent-123", projectId: null },
});
const connection = openSseConnectionWithChatStore("chat-direct-metadata", chatStore);
chatStore.emit("chat:message:added", {
id: "msg-2",
sessionId: "sess-direct",
role: "assistant",
content: "Hello",
projectId: "event-project",
});
await vi.waitFor(() => {
expect(connection.res.write).toHaveBeenCalledWith(expect.stringContaining("event: chat:message:added"));
});
const payload = parseSsePayload(vi.mocked(connection.res.write).mock.calls.at(-1) ?? []);
expect(payload).toMatchObject({
id: "msg-2",
sessionId: "sess-direct",
role: "assistant",
content: "Hello",
projectId: "event-project",
agentId: "agent-123",
});
expect(payload).not.toHaveProperty("taskChatVisibleInCommonFeed");
connection.req.emit("close");
});
});
describe("automation store SSE events", () => {
it("subscribes to all automation store events", () => {
const connection = openSseConnectionWithAutomation("automation-subscribe");

View File

@@ -207,6 +207,33 @@ function stripTaskEventHeavyFields<T>(payload: T): T {
return stripTaskListHeavyFields(payload);
}
async function enrichChatMessageSsePayload<T>(message: T, store: TaskStore, chatStore?: ChatStore): Promise<T> {
if (!message || typeof message !== "object" || Array.isArray(message) || !chatStore) {
return message;
}
const payload = message as Record<string, unknown>;
const sessionId = typeof payload.sessionId === "string" ? payload.sessionId : undefined;
if (!sessionId) return message;
const session = chatStore.getSession(sessionId);
if (!session) return message;
const agentId = typeof payload.agentId === "string" ? payload.agentId : session.agentId;
const enrichedPayload: Record<string, unknown> = {
...payload,
agentId,
projectId: payload.projectId ?? session.projectId ?? null,
};
if (typeof agentId === "string" && agentId.startsWith("task-planner:")) {
const settings = await store.getSettings().catch(() => undefined);
enrichedPayload.taskChatVisibleInCommonFeed = settings?.showTaskChatsInCommonFeed === true;
}
return enrichedPayload as T;
}
/**
* Normalized plugin lifecycle transition types.
* These are the unified set of transitions that the SSE stream emits.
@@ -754,7 +781,14 @@ export function createSSE(
};
const onChatMessageAdded = (message: unknown) => {
send(`event: chat:message:added\ndata: ${JSON.stringify(message)}\n\n`);
void (async () => {
/*
* FNXC:ChatBadge 2026-07-01-00:00:
* Task-detail planner Chat sessions are hidden from the global Chat feed unless `showTaskChatsInCommonFeed` is enabled, so direct-message SSE payloads must carry both the source session agent id and effective feed visibility. The App unread badge uses this metadata to suppress hidden task-local planner replies without regressing opt-in shared-feed planner chats or normal direct-message payload fields.
*/
const payload = await enrichChatMessageSsePayload(message, store, chatStore);
send(`event: chat:message:added\ndata: ${JSON.stringify(payload)}\n\n`);
})();
};
const onChatMessageDeleted = (messageId: string) => {