From b5b55fa75516024b82340669b88e585445cdd83b Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 3 Sep 2026 23:12:10 -0600 Subject: [PATCH] feat(chat): add child agent filters --- .../src/components/app/agent-pane.test.tsx | 34 ++++ apps/web/src/components/app/agent-pane.tsx | 154 ++++++++++++----- apps/web/src/components/app/agents-view.tsx | 10 ++ .../components/app/chat/chat-pane.test.tsx | 157 +++++++++++++++++- .../web/src/components/app/chat/chat-pane.tsx | 75 ++++++++- e2e/chat-surface.spec.ts | 9 +- 6 files changed, 388 insertions(+), 51 deletions(-) diff --git a/apps/web/src/components/app/agent-pane.test.tsx b/apps/web/src/components/app/agent-pane.test.tsx index 3242b505..67c3ef9a 100644 --- a/apps/web/src/components/app/agent-pane.test.tsx +++ b/apps/web/src/components/app/agent-pane.test.tsx @@ -89,6 +89,9 @@ function paneProps(overrides: Partial = {}): PaneProps { view: "chat", onViewChange: vi.fn(), chatUnreadCount: 0, + showChildAgents: true, + onShowChildAgentsChange: vi.fn(), + childAgentIds: [], terminalSlotRef: createRef(), header: true, openLightbox: vi.fn(), @@ -164,6 +167,37 @@ describe("AgentViewToggle", () => { ); expect(screen.queryByTestId("agent-view-chat-unread")).toBeNull(); }); + + it("opens chat filters and reports child-agent visibility changes", () => { + const onShowChildAgentsChange = vi.fn(); + const view = render( + + ); + + fireEvent.click(screen.getByTestId("chat-filters-trigger")); + expect(screen.getByTestId("chat-filters-popover")).toBeTruthy(); + const toggle = screen.getByTestId("show-child-agents-switch"); + expect(toggle.getAttribute("data-state")).toBe("checked"); + fireEvent.click(toggle); + expect(onShowChildAgentsChange).toHaveBeenCalledWith(false); + + view.rerender( + + ); + expect( + screen.getByTestId("chat-filters-trigger").getAttribute("aria-label") + ).toBe("Chat filters, child-agent messages hidden"); + }); }); describe("AgentPane", () => { diff --git a/apps/web/src/components/app/agent-pane.tsx b/apps/web/src/components/app/agent-pane.tsx index 941b1730..a178a5cc 100644 --- a/apps/web/src/components/app/agent-pane.tsx +++ b/apps/web/src/components/app/agent-pane.tsx @@ -1,8 +1,15 @@ import { type RefObject } from "react"; -import { Hash, MessageSquare, TerminalSquare } from "lucide-react"; +import { Hash, ListFilter, MessageSquare, TerminalSquare } from "lucide-react"; import { ChatPane } from "@/components/app/chat/chat-pane"; import { type Agent, type MediaFile } from "@/components/app/types"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Switch } from "@/components/ui/switch"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { formatBadgeCount } from "@/lib/format"; import { type AgentPaneView, isAgentPaneView } from "@/lib/store"; @@ -13,6 +20,8 @@ export type AgentViewToggleProps = { onViewChange: (view: AgentPaneView) => void; /** Unread chat replies; shown on the Chat segment while Console is up. */ chatUnreadCount?: number; + showChildAgents?: boolean; + onShowChildAgentsChange?: (show: boolean) => void; }; /** @@ -24,49 +33,105 @@ export function AgentViewToggle({ view, onViewChange, chatUnreadCount = 0, + showChildAgents = true, + onShowChildAgentsChange, }: AgentViewToggleProps): JSX.Element { const showUnread = view === "console" && chatUnreadCount > 0; + const filtersLabel = showChildAgents + ? "Chat filters" + : "Chat filters, child-agent messages hidden"; return ( - { - // Radix reports "" when the pressed segment is pressed again; the - // pane always shows one of the two, so that is a no-op. - if (isAgentPaneView(next) && next !== view) onViewChange(next); - }} - aria-label="Agent pane view" - data-testid="agent-view-toggle" - data-view={view} - > - + { + // Radix reports "" when the pressed segment is pressed again; the + // pane always shows one of the two, so that is a no-op. + if (isAgentPaneView(next) && next !== view) onViewChange(next); + }} + aria-label="Agent pane view" + data-testid="agent-view-toggle" + data-view={view} + className="rounded-full border-border/70 bg-muted p-0.5 shadow-inner" > - - Chat - {showUnread ? ( - + + Chat + {showUnread ? ( + + {formatBadgeCount(chatUnreadCount)} + + ) : null} + + + + Console + + + + + + + +
+ Chat filters +
+ +
+
+ ); } @@ -84,6 +149,9 @@ export type AgentPaneProps = { view: AgentPaneView; onViewChange: (view: AgentPaneView) => void; chatUnreadCount?: number; + showChildAgents: boolean; + onShowChildAgentsChange: (show: boolean) => void; + childAgentIds: readonly string[]; /** * Where the (portaled, long-lived) terminal DOM is parented. Owned by * `useCenterPaneLayout`, which moves the terminal between the single-pane @@ -119,6 +187,9 @@ export function AgentPane({ view, onViewChange, chatUnreadCount = 0, + showChildAgents, + onShowChildAgentsChange, + childAgentIds, terminalSlotRef, header, openLightbox, @@ -141,6 +212,8 @@ export function AgentPane({ view={view} onViewChange={onViewChange} chatUnreadCount={chatUnreadCount} + showChildAgents={showChildAgents} + onShowChildAgentsChange={onShowChildAgentsChange} /> ) : null} @@ -160,6 +233,9 @@ export function AgentPane({ agent={agent} terminalMode={terminalMode} active={active && chatShown} + showChildAgents={showChildAgents} + childAgentIds={childAgentIds} + onShowChildAgentsChange={onShowChildAgentsChange} openLightbox={openLightbox} isMobile={isMobile} /> diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index da3d35b8..155ed0cf 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -112,6 +112,7 @@ export function AgentsView({ >(null); const [sharedConnState, setSharedConnState] = useState("disconnected"); + const [showChildAgents, setShowChildAgents] = useState(true); const { agents, @@ -281,6 +282,10 @@ export function AgentsView({ : [], [agents, focusedAgentId] ); + const focusedSubAgentIds = useMemo( + () => focusedSubAgents.map((agent) => agent.id), + [focusedSubAgents] + ); const focusedSubAgentPins = useMemo( () => focusedSubAgents.map((agent) => ({ @@ -605,6 +610,9 @@ export function AgentsView({ view: agentView, onViewChange: setAgentView, chatUnreadCount, + showChildAgents, + onShowChildAgentsChange: setShowChildAgents, + childAgentIds: focusedSubAgentIds, openLightbox, isMobile, }; @@ -626,6 +634,8 @@ export function AgentsView({ view={agentView} onViewChange={setAgentView} chatUnreadCount={chatUnreadCount} + showChildAgents={showChildAgents} + onShowChildAgentsChange={setShowChildAgents} /> ) : null; diff --git a/apps/web/src/components/app/chat/chat-pane.test.tsx b/apps/web/src/components/app/chat/chat-pane.test.tsx index 76ddca4e..9beaf10b 100644 --- a/apps/web/src/components/app/chat/chat-pane.test.tsx +++ b/apps/web/src/components/app/chat/chat-pane.test.tsx @@ -14,7 +14,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Agent } from "@/components/app/types"; -import { ChatPane, questionExcerpt } from "./chat-pane"; +import { + ChatPane, + filterChildAgentMessages, + questionExcerpt, +} from "./chat-pane"; // The pane's data layer is exercised elsewhere; here it is replaced so the // pane's own decisions can be driven directly: what the composer does with a @@ -125,6 +129,9 @@ function renderPane(props: Partial[0]> = {}) { agent={agent} terminalMode="tmux" active={true} + showChildAgents={true} + childAgentIds={[]} + onShowChildAgentsChange={vi.fn()} openLightbox={vi.fn()} isMobile={false} {...props} @@ -163,7 +170,152 @@ describe("questionExcerpt", () => { }); }); +describe("filterChildAgentMessages", () => { + const childMessage = ( + id: string, + senderAgentId: string, + recipientAgentId: string + ): ChatFeedEntry => ({ + type: "agent_message", + id, + direction: senderAgentId === "agt_1" ? "out" : "in", + senderAgentId, + senderName: senderAgentId, + recipientAgentId, + recipientName: recipientAgentId, + content: id, + delivered: true, + at: "2026-09-02T10:00:00.000Z", + }); + + const entries = [ + childMessage("from-child", "agt_child", "agt_1"), + childMessage("to-child", "agt_1", "agt_child"), + childMessage("other-agent", "agt_other", "agt_1"), + chat(message({ id: "human-chat" })), + ]; + + it("keeps all entries while child agents are shown", () => { + expect( + filterChildAgentMessages(entries, new Set(["agt_child"]), true) + ).toHaveLength(4); + }); + + it("hides both directions of child-agent messages only", () => { + expect( + filterChildAgentMessages(entries, new Set(["agt_child"]), false).map( + (entry) => entry.id + ) + ).toEqual(["other-agent", "human-chat"]); + }); +}); + describe("ChatPane", () => { + it("removes child-agent messages from the rendered feed when filtered", () => { + H.entries = [ + { + type: "agent_message", + id: "from-child", + direction: "in", + senderAgentId: "agt_child", + senderName: "child", + recipientAgentId: "agt_1", + recipientName: "demo", + content: "child update", + delivered: true, + at: "2026-09-02T10:00:00.000Z", + }, + chat(message({ id: "human-chat", text: "visible reply" })), + ]; + + renderPane({ showChildAgents: false, childAgentIds: ["agt_child"] }); + + expect(screen.queryByText("child update")).toBeNull(); + expect(screen.getByText("visible reply")).toBeTruthy(); + }); + + it("does not treat filtering or hidden child messages as visible appends", () => { + const childEntry: ChatFeedEntry = { + type: "agent_message", + id: "from-child", + direction: "in", + senderAgentId: "agt_child", + senderName: "child", + recipientAgentId: "agt_1", + recipientName: "demo", + content: "child update", + delivered: true, + at: "2026-09-02T10:01:00.000Z", + }; + H.entries = [ + chat(message({ id: "human-chat", text: "visible reply" })), + childEntry, + ]; + const baseProps = { + agentId: "agt_1", + agent, + terminalMode: "tmux" as const, + active: true, + childAgentIds: ["agt_child"], + onShowChildAgentsChange: vi.fn(), + openLightbox: vi.fn(), + isMobile: false, + }; + const { rerender } = render( + , + { wrapper } + ); + const scroll = screen.getByTestId("chat-scroll"); + Object.defineProperties(scroll, { + scrollHeight: { configurable: true, value: 1_000 }, + clientHeight: { configurable: true, value: 200 }, + scrollTop: { configurable: true, value: 100, writable: true }, + }); + fireEvent.scroll(scroll); + + rerender(); + expect(screen.queryByText("New messages")).toBeNull(); + + H.entries = [ + ...H.entries, + { ...childEntry, id: "new-hidden-child", content: "still hidden" }, + ]; + rerender(); + expect(screen.queryByText("New messages")).toBeNull(); + expect(screen.queryByText("still hidden")).toBeNull(); + }); + + it("explains a filter-only empty feed and can show child messages again", () => { + const onShowChildAgentsChange = vi.fn(); + H.entries = [ + { + type: "agent_message", + id: "from-child", + direction: "in", + senderAgentId: "agt_child", + senderName: "child", + recipientAgentId: "agt_1", + recipientName: "demo", + content: "child update", + delivered: true, + at: "2026-09-02T10:00:00.000Z", + }, + ]; + + renderPane({ + showChildAgents: false, + childAgentIds: ["agt_child"], + onShowChildAgentsChange, + }); + + const empty = screen.getByTestId("chat-empty"); + expect(empty.classList.contains("h-full")).toBe(true); + expect(empty.textContent).toContain("Child-agent messages are hidden"); + expect(empty.textContent).not.toContain("No messages yet"); + fireEvent.click(screen.getByRole("button", { name: "Show child agents" })); + expect(onShowChildAgentsChange).toHaveBeenCalledWith(true); + }); + it("shows the empty state when there are no chat messages, keeping other entries", () => { H.entries = [ { @@ -329,6 +481,9 @@ describe("ChatPane", () => { agent={agent} terminalMode="tmux" active={true} + showChildAgents={true} + childAgentIds={[]} + onShowChildAgentsChange={vi.fn()} openLightbox={vi.fn()} isMobile={false} /> diff --git a/apps/web/src/components/app/chat/chat-pane.tsx b/apps/web/src/components/app/chat/chat-pane.tsx index c86afe2f..acc6817e 100644 --- a/apps/web/src/components/app/chat/chat-pane.tsx +++ b/apps/web/src/components/app/chat/chat-pane.tsx @@ -6,7 +6,7 @@ import { useRef, useState, } from "react"; -import type { ChatQuestionOption } from "@dispatch/shared"; +import type { ChatFeedEntry, ChatQuestionOption } from "@dispatch/shared"; import { useQuery } from "@tanstack/react-query"; import { ArrowDown, MessageSquare } from "lucide-react"; @@ -52,10 +52,28 @@ export type ChatPaneProps = { * not mark anything read or take focus. */ active: boolean; + showChildAgents: boolean; + childAgentIds: readonly string[]; + onShowChildAgentsChange: (show: boolean) => void; openLightbox: (file: MediaFile) => void; isMobile: boolean; }; +/** Remove both directions of the selected agent's child conversations. */ +export function filterChildAgentMessages( + entries: readonly ChatFeedEntry[], + childAgentIds: ReadonlySet, + showChildAgents: boolean +): ChatFeedEntry[] { + if (showChildAgents || childAgentIds.size === 0) return [...entries]; + return entries.filter( + (entry) => + entry.type !== "agent_message" || + (!childAgentIds.has(entry.senderAgentId) && + !childAgentIds.has(entry.recipientAgentId)) + ); +} + /** How close to the bottom (px) still counts as "following" the feed. */ const FOLLOW_THRESHOLD_PX = 48; @@ -96,6 +114,9 @@ export function ChatPane({ agent, terminalMode, active, + showChildAgents, + childAgentIds, + onShowChildAgentsChange, openLightbox, isMobile, }: ChatPaneProps): JSX.Element { @@ -106,6 +127,14 @@ export function ChatPane({ const holdState = useInjectionHoldState(agentId); const entries = feed.entries; + const childAgentIdSet = useMemo( + () => new Set(childAgentIds), + [childAgentIds] + ); + const visibleEntries = useMemo( + () => filterChildAgentMessages(entries, childAgentIdSet, showChildAgents), + [childAgentIdSet, entries, showChildAgents] + ); const heldMessageId = useMemo( () => (holdState?.held ? latestUserMessageId(entries) : null), [entries, holdState?.held] @@ -113,9 +142,10 @@ export function ChatPane({ // Status events alone are not a conversation: real agents always have // some, so the empty state must key off the entries a person wrote. const hasConversation = useMemo( - () => entries.some((entry) => entry.type !== "status"), - [entries] + () => visibleEntries.some((entry) => entry.type !== "status"), + [visibleEntries] ); + const hasHiddenChildMessages = visibleEntries.length < entries.length; // A typed reply answers the newest open free-text question unless the // user has opted out of that question with the chip's ×. @@ -136,6 +166,7 @@ export function ChatPane({ const [following, setFollowing] = useState(true); const [pendingBelow, setPendingBelow] = useState(false); const lastEntryIdRef = useRef(null); + const lastShowChildAgentsRef = useRef(showChildAgents); const olderLoadRef = useRef<{ height: number; top: number } | null>(null); const scrollToBottom = useCallback((behavior: ScrollBehavior = "auto") => { @@ -171,7 +202,17 @@ export function ChatPane({ olderLoadRef.current = null; return; } - const lastId = entries[entries.length - 1]?.id ?? null; + const lastId = visibleEntries[visibleEntries.length - 1]?.id ?? null; + const filterChanged = lastShowChildAgentsRef.current !== showChildAgents; + lastShowChildAgentsRef.current = showChildAgents; + // Changing the filter can expose an older tail or remove the current one. + // Adopt it before append detection so the filter itself does not + // manufacture a “New messages” prompt or move the scroll position. + if (filterChanged) { + lastEntryIdRef.current = lastId; + setPendingBelow(false); + return; + } const appended = lastId !== lastEntryIdRef.current; lastEntryIdRef.current = lastId; if (!appended) return; @@ -180,7 +221,7 @@ export function ChatPane({ } else { setPendingBelow(true); } - }, [entries, following, scrollToBottom]); + }, [following, scrollToBottom, showChildAgents, visibleEntries]); // Agent switch: start at the bottom again. useEffect(() => { @@ -334,6 +375,7 @@ export function ChatPane({
- {agent ? ( + {hasHiddenChildMessages ? ( + <> +
+ Child-agent messages are hidden. +
+ + + ) : agent ? ( <>
No messages yet. Send the first one below and the agent @@ -403,9 +460,9 @@ export function ChatPane({ )}
) : null} - {entries.length > 0 ? ( + {visibleEntries.length > 0 ? ( { await touchPage.evaluate(() => matchMedia("(pointer: coarse)").matches) ).toBe(true); - for (const id of ["agent-view-chat", "agent-view-console"]) { + for (const id of [ + "agent-view-chat", + "agent-view-console", + "chat-filters-trigger", + ]) { await expect .poll(() => touchPage @@ -604,7 +608,8 @@ test.describe("Chat surface", () => { .toBeGreaterThanOrEqual(44); } // The header grew to hold it rather than clipping it. - const header = toggle.locator("xpath=.."); + const controls = toggle.locator("xpath=.."); + const header = controls.locator("xpath=.."); const headerBox = (await header.boundingBox())!; const toggleBox = (await toggle.boundingBox())!; expect(toggleBox.y).toBeGreaterThanOrEqual(headerBox.y);