diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx
index 63f5e05..b1bd393 100644
--- a/src/components/chat/ChatView.tsx
+++ b/src/components/chat/ChatView.tsx
@@ -17,6 +17,7 @@ import { isSessionMember } from "@/lib/membership";
import { useSessionStore } from "@/stores/session-store";
import { tasksByAnchor, queuePositions } from "@/stores/agent-runs";
import { AgentTaskCard } from "@/components/super-threads/AgentTaskCard";
+import { visibleChatMessages } from "@/components/chat/message-visibility";
import type { Agent, Message, User, Session, WorkspaceStatus } from "@/types";
function isWorkspaceLive(status: WorkspaceStatus | undefined): boolean {
@@ -379,6 +380,12 @@ export function ChatView() {
? [...session.agents, ...session.members]
: [];
+ // Agent task replies render on the super-thread surfaces (inline card +
+ // drawer), not as chat bubbles. System notices (agent-typed, nil author)
+ // are not in agentIds and stay visible.
+ const agentIds = new Set(session?.agents.map((a) => a.id) ?? []);
+ const visibleMessages = visibleChatMessages(sessionMessages, agentIds);
+
// Super Threads: inline task cards anchored to the message that spawned them.
const sessionRuns = activeSessionId ? agentRuns[activeSessionId] : undefined;
const cardsByAnchor = tasksByAnchor(sessionRuns);
@@ -460,7 +467,7 @@ export function ChatView() {
onScroll={handleScroll}
>
- {sessionMessages.length === 0 && (
+ {visibleMessages.length === 0 && (
@@ -482,8 +489,8 @@ export function ChatView() {
)}
- {sessionMessages.map((msg, i) => {
- const prevMsg = sessionMessages[i - 1];
+ {visibleMessages.map((msg, i) => {
+ const prevMsg = visibleMessages[i - 1];
const showHeader =
!prevMsg ||
prevMsg.authorId !== msg.authorId ||
diff --git a/src/components/chat/message-visibility.test.ts b/src/components/chat/message-visibility.test.ts
new file mode 100644
index 0000000..abda97c
--- /dev/null
+++ b/src/components/chat/message-visibility.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import { isVisibleInChat, visibleChatMessages } from "./message-visibility";
+import type { Message } from "@/types";
+
+// NOTE: The frontend has no test runner wired up yet (see CLAUDE.md). These
+// Vitest-style specs capture the intended behavior and run as soon as a runner
+// is added. Until then, `npx tsc --noEmit` keeps them type-checked.
+
+const NIL_UUID = "00000000-0000-0000-0000-000000000000";
+const AGENT_ID = "a1111111-1111-1111-1111-111111111111";
+const USER_ID = "u1111111-1111-1111-1111-111111111111";
+
+function msg(overrides: Partial
): Message {
+ return {
+ id: "m1",
+ sessionId: "s1",
+ authorId: USER_ID,
+ authorType: "human",
+ content: "hello",
+ mentions: [],
+ createdAt: "2026-06-08T12:00:00Z",
+ status: "sent",
+ ...overrides,
+ };
+}
+
+describe("isVisibleInChat", () => {
+ it("keeps human messages visible regardless of agentIds", () => {
+ const m = msg({ authorType: "human", authorId: USER_ID });
+ expect(isVisibleInChat(m, new Set())).toBe(true);
+ expect(isVisibleInChat(m, new Set([AGENT_ID, USER_ID]))).toBe(true);
+ });
+
+ it("hides an agent reply whose author is a session agent", () => {
+ const m = msg({ authorType: "agent", authorId: AGENT_ID });
+ expect(isVisibleInChat(m, new Set([AGENT_ID]))).toBe(false);
+ });
+
+ it("keeps system notices visible (agent-typed, nil author ID)", () => {
+ const m = msg({ authorType: "agent", authorId: NIL_UUID });
+ expect(isVisibleInChat(m, new Set([AGENT_ID]))).toBe(true);
+ });
+
+ it("keeps agent-typed messages visible when the author is not a session agent", () => {
+ // Defensive: e.g. the agent was removed from the session.
+ const m = msg({ authorType: "agent", authorId: "gone-agent" });
+ expect(isVisibleInChat(m, new Set([AGENT_ID]))).toBe(true);
+ });
+
+ it("hides nothing when agentIds is empty", () => {
+ const m = msg({ authorType: "agent", authorId: AGENT_ID });
+ expect(isVisibleInChat(m, new Set())).toBe(true);
+ });
+});
+
+describe("visibleChatMessages", () => {
+ it("returns only visible messages in original order without mutating input", () => {
+ const human1 = msg({ id: "m1", authorType: "human", authorId: USER_ID });
+ const reply = msg({ id: "m2", authorType: "agent", authorId: AGENT_ID });
+ const notice = msg({ id: "m3", authorType: "agent", authorId: NIL_UUID });
+ const human2 = msg({ id: "m4", authorType: "human", authorId: USER_ID });
+ const input = [human1, reply, notice, human2];
+
+ const out = visibleChatMessages(input, new Set([AGENT_ID]));
+
+ expect(out).toEqual([human1, notice, human2]);
+ expect(input).toHaveLength(4);
+ expect(input[1]).toBe(reply);
+ });
+
+ it("returns everything when agentIds is empty", () => {
+ const input = [
+ msg({ id: "m1", authorType: "agent", authorId: AGENT_ID }),
+ msg({ id: "m2", authorType: "human", authorId: USER_ID }),
+ ];
+ expect(visibleChatMessages(input, new Set())).toEqual(input);
+ });
+});
diff --git a/src/components/chat/message-visibility.ts b/src/components/chat/message-visibility.ts
new file mode 100644
index 0000000..fb994a7
--- /dev/null
+++ b/src/components/chat/message-visibility.ts
@@ -0,0 +1,25 @@
+import type { Message } from "@/types";
+
+// Agent task replies are surfaced on the super-thread surfaces (the inline
+// AgentTaskCard and the agent thread drawer), so their chat-bubble copy is
+// hidden from the main chat list. System/operational notices are posted with
+// authorType "agent" but a nil author ID that matches no session agent
+// (postSystemMessage in server/internal/handler/messages.go) — they fall
+// through the agentIds check and stay visible, as do all human messages.
+export function isVisibleInChat(
+ message: Message,
+ agentIds: Set,
+): boolean {
+ return !(message.authorType === "agent" && agentIds.has(message.authorId));
+}
+
+// visibleChatMessages filters a message list for the main chat surface,
+// preserving order and never mutating the input. agentIds is the set of the
+// session's agent IDs (derived from session.agents by the caller — this
+// module stays free of store/session imports).
+export function visibleChatMessages(
+ messages: Message[],
+ agentIds: Set,
+): Message[] {
+ return messages.filter((m) => isVisibleInChat(m, agentIds));
+}