From 634cd43b3032e46e3245f0e3721f3307ca9bf991 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Tue, 9 Jun 2026 22:29:06 +0000 Subject: [PATCH 1/2] feat(chat): add pure chat-message visibility predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hides a message only when authorType is agent AND its authorId matches a real session agent — nil-author system notices and human messages stay visible. --- .../chat/message-visibility.test.ts | 78 +++++++++++++++++++ src/components/chat/message-visibility.ts | 25 ++++++ 2 files changed, 103 insertions(+) create mode 100644 src/components/chat/message-visibility.test.ts create mode 100644 src/components/chat/message-visibility.ts 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)); +} From c9b4ac8b0136e78d5b3bffdf62e3fa1baf978136 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Tue, 9 Jun 2026 22:30:02 +0000 Subject: [PATCH 2/2] feat(chat): hide agent replies from the main chat list Agent replies now surface only on the super-thread surfaces (inline AgentTaskCard and the agent thread drawer). Header grouping and the empty state derive from the visible sequence; auto-scroll stays keyed on the raw message count so an arriving reply still scrolls its card into view. --- src/components/chat/ChatView.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 ||