diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 9e268299baa..32302512857 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -593,6 +593,13 @@ one sidebar entry, and choosing them again reopens the same conversation. The **Add and reopen** component story exercises that constraint. Agent selection and sending use local fixtures. The live `/chats` and `/chats/:agentRef` routes use the same sidebar, picker, landing page, and existing chat surface for everyone. +The **Switch agents · Unread chats** page stories cover per-agent unread badges +in the chat header picker in dark, light, and mobile views. The header and Add +chat pickers show numeric unread message counts from the shared account-scoped +chat-list cache, with indexed lookups and no per-agent read-state requests. +Chat endpoints return `unreadMessageCount` alongside `isUnreadForMe`; the count +uses the same batched query and excludes human messages, deleted messages, system +notices, and fast responses. Opening or selecting a chat does not mark unseen messages read. Use **Chat & Comments → Issue Thread Interactions → Composer Questions Auto Advance** to try the paged composer form. A single selection shows a brief checked-state animation before advancing to the diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index cb54060b60e..3d74f23e438 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -990,6 +990,7 @@ export interface Issue { lastExternalCommentAt?: Date | null; lastActivityAt?: Date | null; isUnreadForMe?: boolean; + unreadMessageCount?: number; archivedAt?: Date | null; archivedByActorType?: "user" | "agent" | null; archivedByAgentId?: string | null; diff --git a/server/src/__tests__/agent-chat-messages.test.ts b/server/src/__tests__/agent-chat-messages.test.ts index 8767cc2c9a1..12b37339659 100644 --- a/server/src/__tests__/agent-chat-messages.test.ts +++ b/server/src/__tests__/agent-chat-messages.test.ts @@ -386,20 +386,35 @@ import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres. await server.db.update(issueComments).set({ createdAt: sql`'2026-10-30 12:00:00.123456+00'::timestamptz` }).where(eq(issueComments.id, a.commentId)); await server.db.update(issueComments).set({ createdAt: sql`'2026-10-30 12:00:00.123789+00'::timestamptz` }).where(eq(issueComments.id, b.commentId)); const cookie = await server.humanSession(f.responsibleUserId!); + await server.db.insert(issueComments).values([ + { companyId: f.companyId, issueId: a.conversationId, authorUserId: f.responsibleUserId, authorType: "user", body: "Human message" }, + { companyId: f.companyId, issueId: a.conversationId, authorAgentId: f.agentId, authorType: "system", body: "System message" }, + { companyId: f.companyId, issueId: a.conversationId, authorAgentId: f.agentId, body: "Deleted message", deletedAt: new Date() }, + { companyId: f.companyId, issueId: a.conversationId, authorAgentId: f.agentId, body: "Receipt", origin: "fast_response" }, + { companyId: f.companyId, issueId: a.conversationId, authorAgentId: f.agentId, body: "Notice", presentation: { kind: "system_notice", tone: "neutral", detailsDefaultOpen: false } }, + ]); const mark = (id: string) => fetch(`${server.apiUrl}/api/issues/${a.conversationId}/read`, { method: "POST", headers: { Cookie: cookie, Origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify({ throughCommentId: id }) }); - const unread = async () => (await withConversationReadState(server.db, f.companyId, f.responsibleUserId!, [{ id: a.conversationId }]))[0].isUnreadForMe; + const unread = async () => (await withConversationReadState(server.db, f.companyId, f.responsibleUserId!, [{ id: a.conversationId }]))[0]; + expect(await unread()).toMatchObject({ isUnreadForMe: true, unreadMessageCount: 2 }); const response = await mark(a.commentId); expect(response.status, await response.text()).toBe(200); - expect(await unread()).toBe(true); + expect(await unread()).toMatchObject({ isUnreadForMe: true, unreadMessageCount: 1 }); expect((await mark(b.commentId)).status).toBe(200); - expect(await unread()).toBe(false); + expect(await unread()).toMatchObject({ isUnreadForMe: false, unreadMessageCount: 0 }); + expect(await withConversationReadState(server.db, f.companyId, randomUUID(), [{ id: a.conversationId }, { id: f.issueId }])) + .toMatchObject([{ unreadMessageCount: 2, isUnreadForMe: true }, { unreadMessageCount: 0, isUnreadForMe: false }]); + expect(await withConversationReadState(server.db, randomUUID(), f.responsibleUserId!, [{ id: a.conversationId }])) + .toMatchObject([{ unreadMessageCount: 0, isUnreadForMe: false }]); await mark(a.commentId); - expect(await unread()).toBe(false); + expect(await unread()).toMatchObject({ isUnreadForMe: false, unreadMessageCount: 0 }); const foreign = await server.fixture({ connectionScenario: "fresh" }); const foreignMessage = (await send(foreign))!; expect((await mark(foreignMessage.commentId)).status).toBe(404); const chats = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/chats`, { headers: { Cookie: cookie } }); expect(chats.status).toBe(200); - expect(await chats.json()).toMatchObject([{ id: a.conversationId, isUnreadForMe: false }]); + expect(await chats.json()).toMatchObject([{ id: a.conversationId, isUnreadForMe: false, unreadMessageCount: 0 }]); + const detail = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/chats/${f.agentId}`, { headers: { Cookie: cookie } }); + expect(detail.status).toBe(200); + expect(await detail.json()).toMatchObject({ id: a.conversationId, unreadMessageCount: 0 }); }); }); diff --git a/server/src/__tests__/slack-cloud-owner-work.test.ts b/server/src/__tests__/slack-cloud-owner-work.test.ts index fb66a4d278e..587b2ea822a 100644 --- a/server/src/__tests__/slack-cloud-owner-work.test.ts +++ b/server/src/__tests__/slack-cloud-owner-work.test.ts @@ -218,7 +218,8 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu await service.configure(b, { action: "pause" }, actorId); await holdCredentials(a); const start = cloud.calls.length; - const owner = githubConnectionEventService(db, { connector: cloud.connector }); + let pollTime = Date.now(); + const owner = githubConnectionEventService(db, { connector: cloud.connector, now: () => new Date(pollTime) }); await owner.pollOnce({ forDelivery: true }); await owner.pollOnce({ forDelivery: true }); expect((await setup(b)).slackCloudIntent).toBeUndefined(); @@ -227,10 +228,14 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu expect(retained).toMatchObject({ ...before, attempts: 1, failureCode: "credential_lease_busy", nextAttemptAt: expect.any(String) }); expect(Date.parse(retained.nextAttemptAt!)).toBeGreaterThan(Date.now()); expect(cloud.calls.slice(start).filter(call => call.enrollment?.endpointId === a)).toEqual([]); - expect(owner.nextPollAt()).not.toBeNull(); + const retryAt = Date.parse(retained.nextAttemptAt!); + expect(owner.nextPollAt()).toBe(retryAt); releases.splice(0).forEach(release => release()); await Promise.all(heldLeases); - const delay = Math.max(0, Date.parse(retained.nextAttemptAt!) - Date.now()); - await new Promise(resolve => setTimeout(resolve, delay)); + pollTime = retryAt - 1; + await owner.pollOnce(); + expect((await setup(a)).slackCloudIntent).toEqual(retained); + expect(cloud.calls.slice(start).filter(call => call.enrollment?.endpointId === a)).toEqual([]); + pollTime = retryAt; await owner.pollOnce(); expect((await setup(a)).slackCloudIntent).toBeUndefined(); expect((await setup(a)).slackCloudRetiredGeneration).toBeDefined(); diff --git a/server/src/services/agent-chat-read-state.ts b/server/src/services/agent-chat-read-state.ts index ee0e4ec29b7..5b93d1725f6 100644 --- a/server/src/services/agent-chat-read-state.ts +++ b/server/src/services/agent-chat-read-state.ts @@ -2,11 +2,11 @@ import { and, eq, inArray, sql } from "drizzle-orm"; import { issueComments, issueReadStates, type Db } from "@paperclipai/db"; export async function withConversationReadState(db: Db, companyId: string, userId: string, conversations: T[]) { - if (!conversations.length) return [] as Array; + if (!conversations.length) return [] as Array; const rows = await db.select({ issueId: issueComments.issueId, latest: sql`max(${issueComments.createdAt})`, - unread: sql`bool_or(${issueComments.createdAt} > coalesce(${issueReadStates.lastReadAt}, to_timestamp(0)))`, + unreadMessageCount: sql`count(*) filter (where ${issueComments.createdAt} > coalesce(${issueReadStates.lastReadAt}, to_timestamp(0)))`.mapWith(Number), }).from(issueComments).leftJoin(issueReadStates, and( eq(issueReadStates.companyId, companyId), eq(issueReadStates.issueId, issueComments.issueId), eq(issueReadStates.userId, userId), )).where(and(eq(issueComments.companyId, companyId), inArray(issueComments.issueId, conversations.map(chat => chat.id)), @@ -16,6 +16,7 @@ export async function withConversationReadState(db: Db eq(issueComments.origin, "comment"), )).groupBy(issueComments.issueId); const byId = new Map(rows.map(row => [row.issueId, row])); - return conversations.map(chat => ({ ...chat, isUnreadForMe: byId.get(chat.id)?.unread ?? false, + return conversations.map(chat => ({ ...chat, isUnreadForMe: (byId.get(chat.id)?.unreadMessageCount ?? 0) > 0, + unreadMessageCount: byId.get(chat.id)?.unreadMessageCount ?? 0, lastExternalCommentAt: byId.get(chat.id)?.latest ?? null })); } diff --git a/ui/src/components/AgentChatPicker.test.tsx b/ui/src/components/AgentChatPicker.test.tsx index 0b1cf102061..71d70dbcd6f 100644 --- a/ui/src/components/AgentChatPicker.test.tsx +++ b/ui/src/components/AgentChatPicker.test.tsx @@ -42,6 +42,40 @@ afterEach(async () => { }); describe("AgentChatPicker", () => { + it("shows unread per agent through search and clears it when read state updates", async () => { + await render({ existingChatAgentIds: ["first", "second"], unreadMessageCounts: new Map([["first", 0], ["second", 12]]) }); + expect(options()[0].querySelector('[aria-label*="unread message"]')).toBeNull(); + expect(options()[1].querySelector('[aria-label="12 unread messages from Alex"]')?.textContent).toBe("12"); + await search("designer"); + expect(options()).toHaveLength(1); + expect(options()[0].textContent).toContain("12"); + await render({ unreadMessageCounts: new Map([["second", 0]]) }); + expect(options()[0].textContent).not.toContain("12"); + expect(options()[0].textContent).toContain("Open chat"); + }); + + it("uses indexed counts and memoized chat membership for a large team", async () => { + const roster = Array.from({ length: 500 }, (_, index) => ({ ...agents[index % 2], id: `agent-${index}`, name: `Agent ${index}` })); + let visits = 0; + const ids = roster.map(agent => agent.id); + const boundedIds = new Proxy(ids, { get(target, key, receiver) { + if (typeof key === "string" && /^\d+$/.test(key) && ++visits > ids.length * 2) throw new Error("Repeated chat membership scan"); + return Reflect.get(target, key, receiver); + } }); + const counts = new Map(ids.map(id => [id, 7])); + const getCount = vi.spyOn(counts, "get"); + await render({ agents: roster, existingChatAgentIds: boundedIds, unreadMessageCounts: counts }); + expect(options()).toHaveLength(500); + expect(document.querySelectorAll('[aria-label^="7 unread messages from"]')).toHaveLength(500); + expect(visits).toBe(500); + expect(getCount).toHaveBeenCalledTimes(500); + await search("Agent 499"); + expect(options().length).toBeLessThan(500); + expect(options().some(option => option.textContent?.includes("Agent 499"))).toBe(true); + expect(visits).toBe(500); + expect(getCount).toHaveBeenCalledTimes(1000); + }); + it("shows saved custom avatars and default avatars in agent choices", async () => { await render({ agents: [ { ...agents[0], appearance: { ...appearanceForPalette("deep-tide"), customAvatarAssetId: "22222222-2222-4222-8222-222222222222" } }, diff --git a/ui/src/components/AgentChatPicker.tsx b/ui/src/components/AgentChatPicker.tsx index 6e3fc5a9334..b53f342203f 100644 --- a/ui/src/components/AgentChatPicker.tsx +++ b/ui/src/components/AgentChatPicker.tsx @@ -1,7 +1,8 @@ -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { Agent } from "@paperclipai/shared"; import { AgentAvatar } from "@/components/AgentAvatar"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"; @@ -14,6 +15,7 @@ export interface AgentChatPickerProps { error?: Error | null; onRetry?: () => void; existingChatAgentIds?: readonly string[]; + unreadMessageCounts?: ReadonlyMap; renderAgentIcon?: (agent: Agent) => ReactNode; trigger?: ReactNode; } @@ -34,7 +36,8 @@ export function AgentChatPicker({ open, onOpenChange, trigger, ...props }: Agent ); } -function AgentChatPickerResults({ agents, onSelect, onComplete, loading, error, onRetry, existingChatAgentIds, renderAgentIcon }: Omit & { onComplete: () => void }) { +function AgentChatPickerResults({ agents, onSelect, onComplete, loading, error, onRetry, existingChatAgentIds, unreadMessageCounts, renderAgentIcon }: Omit & { onComplete: () => void }) { + const existingIds = useMemo(() => new Set(existingChatAgentIds), [existingChatAgentIds]); const mounted = useRef(false); const selection = useRef(null); useEffect(() => { @@ -88,26 +91,30 @@ function AgentChatPickerResults({ agents, onSelect, onComplete, loading, error, - {agents.map((agent) => ( - { void selectAgent(agent); }} - disabled={openingId !== null} - className="gap-3 px-3 py-3" - > - {renderAgentIcon ? renderAgentIcon(agent) : } - - {agent.name} - {agent.title ?? agent.role} - - {existingChatAgentIds && {existingChatAgentIds.includes(agent.id) ? "Open chat" : "New chat"}} - {agent.status === "paused" && Paused} - {agent.status === "terminated" && Terminated} - {agent.status === "pending_approval" && Awaiting approval} - - ))} + {agents.map((agent) => { + const unreadCount = unreadMessageCounts?.get(agent.id) ?? 0; + return ( + { void selectAgent(agent); }} + disabled={openingId !== null} + className="gap-3 px-3 py-3" + > + {renderAgentIcon ? renderAgentIcon(agent) : } + + {agent.name} + {agent.title ?? agent.role} + + {unreadCount > 0 && {unreadCount}} + {existingChatAgentIds && {existingIds.has(agent.id) ? "Open chat" : "New chat"}} + {agent.status === "paused" && Paused} + {agent.status === "terminated" && Terminated} + {agent.status === "pending_approval" && Awaiting approval} + + ); + })} )} diff --git a/ui/src/components/AgentConversationSidebar.tsx b/ui/src/components/AgentConversationSidebar.tsx index f4f2ba6a7ea..74b68e26bef 100644 --- a/ui/src/components/AgentConversationSidebar.tsx +++ b/ui/src/components/AgentConversationSidebar.tsx @@ -1,4 +1,4 @@ -import { useId, useRef, useState } from "react"; +import { useId, useMemo, useRef, useState } from "react"; import { Plus, Search, Users, X } from "lucide-react"; import type { Agent } from "@paperclipai/shared"; import { AgentChatPicker } from "@/components/AgentChatPicker"; @@ -16,6 +16,7 @@ export interface AgentConversationSidebarProps { activeId?: string; previews?: Record; unreadAgentIds?: readonly string[]; + unreadMessageCounts?: ReadonlyMap; loading?: boolean; error?: Error | null; onRetry?: () => void; @@ -29,12 +30,13 @@ export interface AgentConversationSidebarProps { } /** Searchable navigation for one conversation per agent. */ -export function AgentConversationSidebar({ agents, existingChatAgentIds, availableAgents = agents, activeId, previews = {}, unreadAgentIds = [], loading = false, error, onRetry, initialSearch = "", onSelect, onBrowse, onAddChat, historyLoading, historyError, onRetryHistory }: AgentConversationSidebarProps) { +export function AgentConversationSidebar({ agents, existingChatAgentIds, availableAgents = agents, activeId, previews = {}, unreadAgentIds = [], unreadMessageCounts, loading = false, error, onRetry, initialSearch = "", onSelect, onBrowse, onAddChat, historyLoading, historyError, onRetryHistory }: AgentConversationSidebarProps) { const navigate = useNavigate(); const [pickerOpen, setPickerOpen] = useState(false); const [search, setSearch] = useState(initialSearch); const searchRef = useRef(null); const headingId = useId(); + const unreadIds = useMemo(() => new Set(unreadAgentIds), [unreadAgentIds]); const query = search.trim().toLocaleLowerCase(); const visible = agents.filter(agent => `${agent.name} ${agent.title ?? ""} ${agent.role}`.toLocaleLowerCase().includes(query)); function focusSearch() { @@ -83,7 +85,7 @@ export function AgentConversationSidebar({ agents, existingChatAgentIds, availab {agent.name} {agent.status === "terminated" ? "Terminated" : agent.status === "paused" ? "Paused" : previews[agent.id] ?? agent.title ?? "Start a conversation"} - {unreadAgentIds.includes(agent.id) && } + {unreadIds.has(agent.id) && } )} : !agents.length && (historyLoading || historyError) ? null :

{agents.length ? "No agents found" : "No chats yet"}

@@ -98,6 +100,7 @@ export function AgentConversationSidebar({ agents, existingChatAgentIds, availab
agent.id)} loading={loading} error={error} onRetry={onRetry} renderAgentIcon={agent => } onSelect={async (agent, signal) => { diff --git a/ui/src/components/AgentConversationsSidebar.test.tsx b/ui/src/components/AgentConversationsSidebar.test.tsx index 650a09a3e3d..d9e7bbe47ed 100644 --- a/ui/src/components/AgentConversationsSidebar.test.tsx +++ b/ui/src/components/AgentConversationsSidebar.test.tsx @@ -5,8 +5,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { Agent } from "@paperclipai/shared"; import { AgentConversationsSidebar } from "./AgentConversationsSidebar"; +import type { AgentConversationSidebarProps } from "./AgentConversationSidebar"; import { queryKeys } from "@/lib/queryKeys"; const state = vi.hoisted(() => ({ companyId: "company-a", navigate: vi.fn(), closeSidebar: vi.fn(), ensure: vi.fn(), getAgent: vi.fn() })); +const sidebarInputs = vi.hoisted(() => [] as Pick[]); +vi.mock("./AgentConversationSidebar", async importOriginal => { + const original = await importOriginal(); + return { AgentConversationSidebar: (props: AgentConversationSidebarProps) => { + sidebarInputs.push({ existingChatAgentIds: props.existingChatAgentIds, unreadAgentIds: props.unreadAgentIds, unreadMessageCounts: props.unreadMessageCounts }); + return ; + } }; +}); vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.companyId }) })); vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: true, setSidebarOpen: state.closeSidebar }) })); vi.mock("@/hooks/useAgentChatEnabled", () => ({ useAgentChatEnabled: () => ({ enabled: true, loaded: true }) })); @@ -38,6 +47,7 @@ beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} }); state.companyId = "company-a"; + sidebarInputs.length = 0; state.navigate.mockReset(); state.closeSidebar.mockReset(); state.ensure.mockReset(); state.getAgent.mockReset(); state.ensure.mockImplementation(async (_company, id) => chat(id)); memberships.agentMemberships = {}; @@ -103,6 +113,48 @@ it("shows accessible unread indicators only for the current company and human", expect(container.querySelector('[aria-label^="Unread messages"]')).toBeNull(); }); +it("passes numeric unread counts to Add chat and updates them from the account cache", async () => { + const key = queryKeys.agentChats.list("company-a", "user-a"); + client.setQueryData(key, [{ ...chat("alice"), isUnreadForMe: true, unreadMessageCount: 8 }, chat("bob")]); + await render(); await addChat(); + expect(document.querySelector('[aria-label="8 unread messages from alice"]')?.textContent).toBe("8"); + expect(document.querySelector('[aria-label*="unread message from bob"]')).toBeNull(); + await act(async () => { + client.setQueryData(key, [{ ...chat("alice"), isUnreadForMe: true, unreadMessageCount: 1 }]); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(document.querySelector('[aria-label="1 unread message from alice"]')?.textContent).toBe("1"); + await act(async () => { + client.setQueryData(key, [{ ...chat("alice"), isUnreadForMe: false, unreadMessageCount: 0 }]); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(document.querySelector('[aria-label*="unread message"]')).toBeNull(); +}); + +it("reuses chat indexes for 500 agents across unchanged parent renders and refreshes changed counts", async () => { + const agents = Array.from({ length: 500 }, (_, index) => ({ ...roster[0], id: `agent-${index}`, name: `Agent ${index}`, urlKey: `agent-${index}` })); + const chats = agents.map(agent => ({ ...chat(agent.id), isUnreadForMe: true, unreadMessageCount: 7 })); + const key = queryKeys.agentChats.list("company-a", "user-a"); + client.setQueryData(queryKeys.agents.list("company-a"), agents); + client.setQueryData(key, chats); + await render(); await addChat(); + const first = sidebarInputs.at(-1)!; + expect(first.existingChatAgentIds).toHaveLength(500); + expect(first.unreadAgentIds).toHaveLength(500); + expect(document.querySelectorAll('[aria-label^="7 unread messages from"]')).toHaveLength(500); + await render(); + const unchanged = sidebarInputs.at(-1)!; + expect(unchanged.existingChatAgentIds).toBe(first.existingChatAgentIds); + expect(unchanged.unreadAgentIds).toBe(first.unreadAgentIds); + expect(unchanged.unreadMessageCounts).toBe(first.unreadMessageCounts); + await act(async () => { + client.setQueryData(key, [{ ...chats[0], isUnreadForMe: false, unreadMessageCount: 0 }, ...chats.slice(1)]); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(document.querySelector('[aria-label="7 unread messages from Agent 0"]')).toBeNull(); + expect(document.querySelectorAll('[aria-label^="7 unread messages from"]')).toHaveLength(499); +}); + it("retains terminated agents' history by id without offering them for new chats", async () => { const retired = { ...roster[0], id: "retired-id", name: "Retired", status: "terminated", urlKey: "alice" }; client.setQueryData(queryKeys.agents.detail(retired.id), retired); diff --git a/ui/src/components/AgentConversationsSidebar.tsx b/ui/src/components/AgentConversationsSidebar.tsx index cb6e5156d83..18e065f416e 100644 --- a/ui/src/components/AgentConversationsSidebar.tsx +++ b/ui/src/components/AgentConversationsSidebar.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { AgentConversationSidebar } from "./AgentConversationSidebar"; import { queryViewKind } from "./QueryView"; import { useAgentChatNavigation, useOpenAgentChat } from "@/hooks/useAgentChatNavigation"; @@ -12,6 +13,9 @@ import { resourceMembershipState, useResourceMemberships } from "@/hooks/useReso /** Account- and company-scoped data for the secondary chat navigation. */ export function AgentConversationsSidebar() { const { companyId, userId, agents, chats, session } = useAgentChatNavigation(); + const unreadMessageCounts = useMemo(() => new Map((chats.data ?? []) + .flatMap(chat => chat.conversationAgentId && chat.unreadMessageCount + ? [[chat.conversationAgentId, chat.unreadMessageCount] as const] : [])), [chats.data]); const openChat = useOpenAgentChat(companyId, userId); const navigate = useNavigate(); const memberships = useResourceMemberships(companyId); @@ -28,6 +32,14 @@ export function AgentConversationsSidebar() { const byId = new Map(roster.map(agent => [agent.id, agent])); for (const result of historyAgents) if (result.data) byId.set(result.data.id, result.data); const active = [...byId.values()].find(agent => agent.id === activeRef || (agent.status !== "terminated" && encodeURIComponent(agentRouteRef(agent)) === activeRef)); + const activeId = active?.id; + const existingChatAgentIds = useMemo(() => { + const ids = (chats.data ?? []).flatMap(chat => chat.conversationAgentId ? [chat.conversationAgentId] : []); + if (activeId && !ids.includes(activeId)) ids.unshift(activeId); + return ids; + }, [chats.data, activeId]); + const unreadAgentIds = useMemo(() => (chats.data ?? []) + .flatMap(chat => chat.isUnreadForMe && chat.conversationAgentId ? [chat.conversationAgentId] : []), [chats.data]); const conversations = (chats.data ?? []).flatMap(chat => { const agent = chat.conversationAgentId ? byId.get(chat.conversationAgentId) : undefined; return agent ? [agent] : []; @@ -45,9 +57,10 @@ export function AgentConversationsSidebar() { const previews = Object.fromEntries((chats.data ?? []).filter(chat => chat.conversationState === "active") .map(chat => [chat.conversationAgentId!, "Working…"])); return agent.id)} + existingChatAgentIds={existingChatAgentIds} activeId={active?.id} previews={previews} - unreadAgentIds={(chats.data ?? []).filter(chat => chat.isUnreadForMe).flatMap(chat => chat.conversationAgentId ? [chat.conversationAgentId] : [])} + unreadAgentIds={unreadAgentIds} + unreadMessageCounts={unreadMessageCounts} // Loaded chats stay listed through an outage (the banner covers it); an // outage before the first load is a skeleton, and only a real failure is an error. loading={[agents, chats, session].some(query => { const kind = queryViewKind(query); return kind === "loading" || kind === "reconnecting"; })} diff --git a/ui/src/components/CoordinatorAgentSwitcher.test.tsx b/ui/src/components/CoordinatorAgentSwitcher.test.tsx new file mode 100644 index 00000000000..b7c61d075ef --- /dev/null +++ b/ui/src/components/CoordinatorAgentSwitcher.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { Agent, Issue } from "@paperclipai/shared"; +import { queryKeys } from "@/lib/queryKeys"; +import { CoordinatorAgentSwitcher } from "./CoordinatorAgentSwitcher"; + +const state = vi.hoisted(() => ({ companyId: "company-a", navigate: vi.fn(), listChats: vi.fn(), listAgents: vi.fn() })); +vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.companyId }) })); +vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: false, setSidebarOpen: vi.fn() }) })); +vi.mock("@/lib/router", () => ({ useNavigate: () => state.navigate })); +vi.mock("@/api/agents", () => ({ agentsApi: { list: state.listAgents } })); +vi.mock("@/api/agentChats", () => ({ agentChatsApi: { list: state.listChats } })); +vi.mock("@/api/auth", () => ({ authApi: { getSession: () => ({ user: { id: "user-a" } }) } })); + +const agents = ["Mira", "Alex", "Sam"].map(name => ({ id: name, name, urlKey: name.toLowerCase(), companyId: "company-a", role: "engineer", status: "idle" } as Agent)); +const chat = (agentId: string, unreadMessageCount: number) => ({ id: `chat-${agentId}`, conversationAgentId: agentId, isUnreadForMe: unreadMessageCount > 0, unreadMessageCount } as Issue); +let client: QueryClient; +let root: Root; +let container: HTMLDivElement; +async function render() { + await act(async () => root.render()); +} +async function open() { + await act(async () => container.querySelector('button[aria-label^="Switch agent"]')!.click()); +} +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} }); + state.companyId = "company-a"; + state.navigate.mockReset(); state.listChats.mockReset(); state.listAgents.mockReset(); + client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + client.setQueryData(queryKeys.auth.session, { user: { id: "user-a" } }); + client.setQueryData(queryKeys.agents.list("company-a"), agents); + client.setQueryData(queryKeys.agentChats.list("company-a", "user-a"), [chat("Alex", 12), chat("Sam", 0)]); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); +}); +afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); }); + +it("opens cached badges without requests and follows read-state cache updates", async () => { + await render(); await open(); + expect(document.querySelector('[aria-label="12 unread messages from Alex"]')?.textContent).toBe("12"); + expect(document.querySelector('[aria-label="1 unread message from Sam"]')).toBeNull(); + await act(async () => { + client.setQueryData(queryKeys.agentChats.list("company-a", "user-a"), [chat("Alex", 0), chat("Sam", 1)]); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(document.querySelector('[aria-label="12 unread messages from Alex"]')).toBeNull(); + expect(document.querySelector('[aria-label="1 unread message from Sam"]')).not.toBeNull(); + await act(async () => [...document.querySelectorAll('[role="option"]')].find(option => option.textContent?.includes("Sam"))!.click()); + expect(state.navigate).toHaveBeenCalledWith("/chats/sam"); + expect(client.getQueryData(queryKeys.agentChats.list("company-a", "user-a"))![1].isUnreadForMe).toBe(true); + await open(); + expect(state.listChats).not.toHaveBeenCalled(); + expect(state.listAgents).not.toHaveBeenCalled(); +}); + +it("keeps switching available while unread history is still loading", async () => { + client.removeQueries({ queryKey: queryKeys.agentChats.list("company-a", "user-a") }); + state.listChats.mockReturnValue(new Promise(() => {})); + await render(); await open(); + expect(document.querySelectorAll('[role="option"]')).toHaveLength(3); + expect(document.querySelector('[role="status"]')).toBeNull(); + await act(async () => [...document.querySelectorAll('[role="option"]')][1].click()); + expect(state.navigate).toHaveBeenCalledWith("/chats/alex"); + expect(state.listChats).toHaveBeenCalledTimes(1); +}); + +it("does not carry unread badges into a different account", async () => { + await render(); await open(); + await act(async () => { + client.setQueryData(queryKeys.agentChats.list("company-a", "user-b"), [chat("Sam", 1)]); + client.setQueryData(queryKeys.auth.session, { user: { id: "user-b" } }); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(document.querySelector('[aria-label="12 unread messages from Alex"]')).toBeNull(); + expect(document.querySelector('[aria-label="1 unread message from Sam"]')).not.toBeNull(); +}); diff --git a/ui/src/components/CoordinatorAgentSwitcher.tsx b/ui/src/components/CoordinatorAgentSwitcher.tsx index a6460aa41c8..a780b2927d2 100644 --- a/ui/src/components/CoordinatorAgentSwitcher.tsx +++ b/ui/src/components/CoordinatorAgentSwitcher.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { ChevronDown } from "lucide-react"; import type { Agent } from "@paperclipai/shared"; import { AgentChatPicker } from "./AgentChatPicker"; @@ -14,6 +14,16 @@ export function CoordinatorAgentSwitcher({ agent }: { agent: Agent }) { const [open, setOpen] = useState(false); const { agents, chats } = useAgentChatNavigation(); const navigate = useNavigate(); + const chatAgentIds = useMemo(() => { + const existing: string[] = []; + const unread = new Map(); + for (const chat of chats.data ?? []) { + if (!chat.conversationAgentId) continue; + existing.push(chat.conversationAgentId); + if (chat.unreadMessageCount) unread.set(chat.conversationAgentId, chat.unreadMessageCount); + } + return { existing, unread }; + }, [chats.data]); return ( { void agents.refetch(); }} - existingChatAgentIds={(chats.data ?? []).flatMap(chat => chat.conversationAgentId ? [chat.conversationAgentId] : [])} + existingChatAgentIds={chatAgentIds.existing} + unreadMessageCounts={chatAgentIds.unread} onSelect={next => { navigate(`/chats/${encodeURIComponent(agentRouteRef(next))}`); }} /> ); diff --git a/ui/storybook/prototypes/agent-chat/AgentChatPrototype.tsx b/ui/storybook/prototypes/agent-chat/AgentChatPrototype.tsx index 8f5bbd1ade1..ee40481e34f 100644 --- a/ui/storybook/prototypes/agent-chat/AgentChatPrototype.tsx +++ b/ui/storybook/prototypes/agent-chat/AgentChatPrototype.tsx @@ -216,6 +216,7 @@ export interface AgentChatPrototypeProps { entryScenario?: EntryScenario; sidebarScenario?: SidebarScenario; coordinator?: boolean; + unreadMessageCounts?: Record; } /** Production pages with an in-memory API. No alternate chat controller. */ @@ -226,6 +227,7 @@ export function AgentChatPrototype({ entryScenario, sidebarScenario, coordinator = false, + unreadMessageCounts, }: AgentChatPrototypeProps) { const [ready, setReady] = useState(false); const navigate = useNavigate(); @@ -251,6 +253,8 @@ export function AgentChatPrototype({ conversationAgentId?: string | null; conversationUserId?: string | null; conversationState?: "waiting"; + isUnreadForMe?: boolean; + unreadMessageCount?: number; } >(); const messages = new Map(); @@ -284,6 +288,8 @@ export function AgentChatPrototype({ conversationAgentId: taskComparison ? null : a.id, conversationUserId: taskComparison ? null : "user-board", conversationState: "waiting" as const, + isUnreadForMe: (unreadMessageCounts?.[a.id] ?? 0) > 0, + unreadMessageCount: unreadMessageCounts?.[a.id] ?? 0, }; const hasExistingChat = sidebarScenario ? (sidebarScenario === "large-team" || sidebarAgents.some(initial => initial.id === a.id)) && (scenario !== "empty" || a.id !== agent.id) @@ -429,7 +435,11 @@ export function AgentChatPrototype({ messages.set(task.id, [...(messages.get(task.id) ?? []), row]); return Response.json(row); } - if (method === "POST" && path.endsWith("/read")) return Response.json({}); + if (method === "POST" && path.endsWith("/read")) { + const conversation = [...chats.values()].find(chat => chat.id === task.id); + if (conversation) { conversation.isUnreadForMe = false; conversation.unreadMessageCount = 0; } + return Response.json({}); + } if (method === "PATCH" && /\/issues\/[^/]+$/.test(path)) { Object.assign(task, body); return Response.json(task); @@ -613,7 +623,7 @@ export function AgentChatPrototype({ } queryClient.clear(); }; - }, [scenario, taskComparison, entryScenario, sidebarScenario, coordinator, contextInitiallyOpen, queryClient]); + }, [scenario, taskComparison, entryScenario, sidebarScenario, coordinator, unreadMessageCounts, contextInitiallyOpen, queryClient]); useLayoutEffect(() => { if (ready && !initialRouteSet.current) { initialRouteSet.current = true; diff --git a/ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx b/ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx index 349753adbcc..833587c0b74 100644 --- a/ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx +++ b/ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx @@ -1,4 +1,4 @@ -import { userEvent, within } from "storybook/test"; +import { expect, userEvent, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { AgentChatPrototype } from "../../prototypes/agent-chat/AgentChatPrototype"; @@ -40,3 +40,31 @@ export const AddChat: Story = { await userEvent.click(await within(canvasElement).findByRole("button", { name: "Add chat" })); }, }; + +export const UnreadSwitcher: Story = { + name: "10 · Switch agents · Unread chats", + args: { coordinator: true, unreadMessageCounts: { "chat-analyst": 3, "chat-writer": 12 } }, + parameters: { paperclip: { capabilities: ["agent-chat/discovery", "agent-chat/history"], status: "current", implementation: "production", boundary: "fixtures", notes: "Production header switcher and chat page with per-agent numeric unread counts from the shared chat-list fixture. Does not prove backend read-state persistence or live delivery." } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await canvas.findByRole("button", { name: /Switch agent\. Currently/ })); + await expect(await page.findByLabelText("3 unread messages from Analyst")).toHaveTextContent("3"); + await expect(await page.findByLabelText("12 unread messages from Writer")).toHaveTextContent("12"); + await expect(page.queryByLabelText("1 unread message from Researcher")).toBeNull(); + }, +}; +export const UnreadSwitcherLight: Story = { ...UnreadSwitcher, name: "11 · Switch agents · Unread · Light", globals: { theme: "light" } }; +export const UnreadSwitcherMobile: Story = { ...UnreadSwitcher, name: "12 · Switch agents · Unread · Mobile", globals: { viewport: { value: "mobile", isRotated: false } } }; + +export const UnreadAddChat: Story = { + name: "13 · Add chat · Unread counts", + args: { unreadMessageCounts: { "chat-analyst": 3, "chat-writer": 12 } }, + parameters: { paperclip: { capabilities: ["agent-chat/discovery", "agent-chat/history"], implementation: "production", boundary: "fixtures", notes: "Production secondary sidebar Add chat picker with numeric unread counts. API data is simulated." } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await within(canvasElement).findByRole("button", { name: "Add chat" })); + await expect(await page.findByLabelText("3 unread messages from Analyst")).toHaveTextContent("3"); + await expect(await page.findByLabelText("12 unread messages from Writer")).toHaveTextContent("12"); + }, +}; diff --git a/ui/storybook/support/agent-chat/proactive-messages.tsx b/ui/storybook/support/agent-chat/proactive-messages.tsx index a1466f02127..9a81d48dfd8 100644 --- a/ui/storybook/support/agent-chat/proactive-messages.tsx +++ b/ui/storybook/support/agent-chat/proactive-messages.tsx @@ -53,6 +53,7 @@ export function prepareProactiveMessageFixtures(readFailure = false, transientRe executionRunId: null, updatedAt: new Date(index === 0 ? "2026-10-30T07:00:00Z" : "2026-10-30T06:45:00Z"), isUnreadForMe: unread.has(agent.id), + unreadMessageCount: unread.has(agent.id) ? 1 : 0, })); return prepareFixtureApi([ { path: "/api/instance/settings/experimental", read: () => instanceExperimentalSettingsSchema.parse({ enableAgentChat: true }) },