fix(ui): show unread message counts in agent chat pickers (#19627)

## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - People switch between agent chats to read replies.
> - The agent picker does not show how many messages are unread.
> - People need that count before they select a chat.
> - The chat list already has the viewer's read state.
> - This pull request adds numeric badges from that shared list.
> - People can find unread replies without extra requests for each
agent.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The chat header agent picker and the secondary sidebar's Add chat
picker.

**Subsystem affected**

Agent Chat UI, shared task types, and the chat read-state service.

**Current behavior**

The pickers do not show the unread message count for each agent.

**Proposed behavior**

Show the unread count beside each agent. For example, show 3 for three
unread agent messages. Hide the badge when the count is zero. Keep
switching available while chat data loads.

**Reason and benefit**

People can see which chats need attention before they switch. The count
uses the existing shared request and cache.

**Breaking changes**

The chat response gains an optional `unreadMessageCount` field. Existing
fields and read receipts keep their behavior. There is no database
migration.

**Additional context**

Related: #19425 changes chat structure. This change adds counts to the
current agent pickers. The duplicate search found no PR for these count
badges.

## What Changed

- Count unread agent comments in the existing grouped read-state query.
Keep company and viewer scope.
- Exclude human messages, deleted comments, system notices, and
fast-response receipts.
- Pass memoized count maps to both agent pickers. Use a direct lookup
for each row.
- Show numeric badges and accessible singular or plural labels.
- Test count changes, account isolation, cached opens, search, and large
agent lists.
- Update the chat stories, fixture data, and development guide.
- Make the Slack owner retry-deadline test deterministic after
reproducing its CI failure.

## Verification

- On current head `05f5fa6201`, the [main CI
workflow](https://github.com/paperclipai/paperclip/actions/runs/42305845930)
passed, including build, typecheck, all applicable test shards, browser
shards, and final verification. The prior server shard 2, 6, and 7
failures do not recur.
- [Greptile's current-head
review](https://github.com/paperclipai/paperclip/pull/19627#issuecomment-6696161631)
is 5/5, its check succeeded, and both previous review threads are
resolved. Current head check inventory is complete: 55 successful
checks, three intentional skips, and no pending or failed checks. The
Paperclip application review summary reports paused; no new completed
Paperclip review is claimed. Its existing findings are resolved, and the
independent current-head Greptile review is 5/5.

- Current head `05f5fa6201` merges master `8621bcf53e` to resolve an
import conflict. Master now includes the same model-default test repair.
This branch keeps its upstream import, so that file no longer differs
from master.
- After this merge: all 21 server tests in the Slack owner and
model-default suites pass, all 22 tests in the three picker/sidebar
suites pass, and token gates pass. The standard preflight still cannot
build Rust because `cargo` is absent. Fresh CI must supply the broad
checks.

- Head `7d125fb8c7` replaces the Slack owner test's real timer wait with
the worker's existing clock input. It checks that the intent stays
pending one millisecond before the retry deadline and clears at the
deadline. All 15 Slack owner integration tests pass locally. Runtime
retry behavior is unchanged.
- On `4cfb1e9d55`, both review checks gave 5/5. CI [server shard
2](https://github.com/paperclipai/paperclip/actions/runs/42302458830/job/128183671349)
failed the Slack credential-lease retry test. The same assertion failed
in an unchanged local run. A diagnostic run with added logging passed;
the exact timer deviation was not captured. The new test removes the
real-time wait assumption and checks the clock boundary directly. The
earlier model-default and cleanup failures did not recur in that CI run.

- On `a3378dfc40`, both Greptile and Paperclip Review gave 5/5 with no
open findings. Typecheck, build, all eight browser shards, and most test
shards passed.
- That CI attempt failed two server shards. The model-default test still
expected `gpt-5.6-sol`, while the current default is `gpt-6.1-sol`. I
reproduced both failures locally. Commit `4cfb1e9d55` uses the exported
default-model constant; all six tests in that suite now pass. Production
model selection does not change.
- The retained-cleanup backlog test exceeded its 15-second limit in
[server shard
6](https://github.com/paperclipai/paperclip/actions/runs/42299858777/job/128175193320).
The unchanged test passes locally with the same limit. Its CI timeout
cause is not established. No assertion or timeout was changed. The fresh
CI run must recheck it.

- Review repair: commit `0db631b215` keeps the existing-chat and
unread-agent arrays stable when chat data and the active agent do not
change. A new 500-agent test fails before the repair and passes after
it. The test also checks count refreshes.
- The first CI typecheck failed on duplicate `IMAGE_ONLY` and
`TESTOPS_REQUESTED` keys in `tests/runner-e2e/workflow-target.test.ts`.
This was present in upstream code. Master commit `e482f0f632` removes
the duplicate keys. This branch now includes that commit. All nine
workflow-target tests pass locally.
- After the latest master merge: all 22 tests in the three affected
picker/sidebar suites pass. Before that merge, all 38 focused UI tests
passed.
- The current head is `05f5fa620134ad199a3d85b66fb2eb93d3bb6077`. All
current status checks are complete, the aggregate verify gate succeeds,
mergeability is CLEAN, and no unresolved review threads remain. The
maintainer has instructed normal merge after checks pass.

- After merging current master: 37 focused UI tests pass across
AgentChatPicker, CoordinatorAgentSwitcher, AgentConversationsSidebar,
useAgentChatRead, and SidebarAgentChats.
- After merging current master: all 38 proactive-chat integration tests
pass with a real disposable PostgreSQL database. They check counts of 2,
1, and 0 at exact read cutoffs.
- The prior revision passed UI typecheck and build, shared build,
isolated low-memory server typecheck, token gates, and 30 map/catalog
tests.
- The 500-agent test bounds index work and count lookups. Search does
not rebuild the existing-chat index.
- Browser checks used production components with simulated API data.
Dark, light, mobile, and Add chat cases passed. Counts of 3 and 12
stayed visible through search. Read agents had no badge. Cached opens
made no chat API requests.
- Twenty local cached opens took 34.5–57.6 ms, with a 38.4 ms median.
This is an unthrottled fixture measurement.
- Earlier full local checks could not complete because Rust cargo is
absent and the workspace has limited memory. The standard worktree
preflight also stops at missing cargo. Required CI must supply broad
checks for this PR head.
- The first server typecheck ran out of memory while Storybook was
active. Its isolated low-memory retry passed. No visual baseline
comparison or live provider delivery is claimed.

## Risks

- Low risk. The count replaces a boolean aggregate in the same grouped
query. Very long chat histories still need that existing scan.
- A count can be stale until the existing chat-list cache refreshes.
Read receipts still control the unread cutoff.
- The change adds no polling, per-agent history request, database
migration, or permission change.

## Model Used

OpenAI GPT-6 through Codex, with code editing, terminal tools, and
browser verification. The runtime did not expose a more specific served
model ID or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-06 17:10:56 -06:00
1 parent 7941f6b051
commit 2e340d6235
15 files changed
+312 -44

No files matched your search

+7
View File
@@ -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
+1
View File
@@ -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;
@@ -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 });
});
});
@@ -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();
+4 -3
View File
@@ -2,11 +2,11 @@ import { and, eq, inArray, sql } from "drizzle-orm";
import { issueComments, issueReadStates, type Db } from "@paperclipai/db";
export async function withConversationReadState<T extends { id: string }>(db: Db, companyId: string, userId: string, conversations: T[]) {
if (!conversations.length) return [] as Array<T & { isUnreadForMe: boolean; lastExternalCommentAt: Date | null }>;
if (!conversations.length) return [] as Array<T & { isUnreadForMe: boolean; unreadMessageCount: number; lastExternalCommentAt: Date | null }>;
const rows = await db.select({
issueId: issueComments.issueId,
latest: sql<Date>`max(${issueComments.createdAt})`,
unread: sql<boolean>`bool_or(${issueComments.createdAt} > coalesce(${issueReadStates.lastReadAt}, to_timestamp(0)))`,
unreadMessageCount: sql<number>`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<T extends { id: string }>(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 }));
}
@@ -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" } },
+29 -22
View File
@@ -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<string, number>;
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<AgentChatPickerProps, "open" | "onOpenChange"> & { onComplete: () => void }) {
function AgentChatPickerResults({ agents, onSelect, onComplete, loading, error, onRetry, existingChatAgentIds, unreadMessageCounts, renderAgentIcon }: Omit<AgentChatPickerProps, "open" | "onOpenChange"> & { onComplete: () => void }) {
const existingIds = useMemo(() => new Set(existingChatAgentIds), [existingChatAgentIds]);
const mounted = useRef(false);
const selection = useRef<AbortController | null>(null);
useEffect(() => {
@@ -88,26 +91,30 @@ function AgentChatPickerResults({ agents, onSelect, onComplete, loading, error,
</div>
</CommandEmpty>
<CommandGroup>
{agents.map((agent) => (
<CommandItem
key={agent.id}
value={agent.id}
keywords={[agent.name, agent.title ?? "", agent.role]}
onSelect={() => { void selectAgent(agent); }}
disabled={openingId !== null}
className="gap-3 px-3 py-3"
>
{renderAgentIcon ? renderAgentIcon(agent) : <AgentAvatar agent={agent} size={32} />}
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-medium">{agent.name}</span>
<span className="truncate text-xs text-muted-foreground">{agent.title ?? agent.role}</span>
</span>
{existingChatAgentIds && <span className="shrink-0 text-xs text-muted-foreground">{existingChatAgentIds.includes(agent.id) ? "Open chat" : "New chat"}</span>}
{agent.status === "paused" && <span className="text-xs text-(--status-agent-paused)">Paused</span>}
{agent.status === "terminated" && <span className="text-xs text-muted-foreground">Terminated</span>}
{agent.status === "pending_approval" && <span className="text-xs text-muted-foreground">Awaiting approval</span>}
</CommandItem>
))}
{agents.map((agent) => {
const unreadCount = unreadMessageCounts?.get(agent.id) ?? 0;
return (
<CommandItem
key={agent.id}
value={agent.id}
keywords={[agent.name, agent.title ?? "", agent.role]}
onSelect={() => { void selectAgent(agent); }}
disabled={openingId !== null}
className="gap-3 px-3 py-3"
>
{renderAgentIcon ? renderAgentIcon(agent) : <AgentAvatar agent={agent} size={32} />}
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-medium">{agent.name}</span>
<span className="truncate text-xs text-muted-foreground">{agent.title ?? agent.role}</span>
</span>
{unreadCount > 0 && <Badge variant="ghost" aria-label={`${unreadCount} unread ${unreadCount === 1 ? "message" : "messages"} from ${agent.name}`} className="shrink-0 bg-primary font-mono tabular-nums text-primary-foreground">{unreadCount}</Badge>}
{existingChatAgentIds && <span className="shrink-0 text-xs text-muted-foreground">{existingIds.has(agent.id) ? "Open chat" : "New chat"}</span>}
{agent.status === "paused" && <span className="text-xs text-(--status-agent-paused)">Paused</span>}
{agent.status === "terminated" && <span className="text-xs text-muted-foreground">Terminated</span>}
{agent.status === "pending_approval" && <span className="text-xs text-muted-foreground">Awaiting approval</span>}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
)}
@@ -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<string, string>;
unreadAgentIds?: readonly string[];
unreadMessageCounts?: ReadonlyMap<string, number>;
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<HTMLInputElement>(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
<span className="truncate text-sm font-medium">{agent.name}</span>
<span className="truncate text-xs text-muted-foreground">{agent.status === "terminated" ? "Terminated" : agent.status === "paused" ? "Paused" : previews[agent.id] ?? agent.title ?? "Start a conversation"}</span>
</span>
{unreadAgentIds.includes(agent.id) && <span role="img" aria-label={`Unread messages from ${agent.name}`} className="size-2 shrink-0 rounded-full bg-primary" />}
{unreadIds.has(agent.id) && <span role="img" aria-label={`Unread messages from ${agent.name}`} className="size-2 shrink-0 rounded-full bg-primary" />}
</Link>)}
</nav> : !agents.length && (historyLoading || historyError) ? null : <div className="flex flex-col items-start gap-2 px-2 py-6">
<p className="text-sm font-medium">{agents.length ? "No agents found" : "No chats yet"}</p>
@@ -98,6 +100,7 @@ export function AgentConversationSidebar({ agents, existingChatAgentIds, availab
</Button>
</div>
<AgentChatPicker agents={availableAgents} open={pickerOpen} onOpenChange={setPickerOpen}
unreadMessageCounts={unreadMessageCounts}
existingChatAgentIds={existingChatAgentIds ?? agents.map(agent => agent.id)} loading={loading} error={error} onRetry={onRetry}
renderAgentIcon={agent => <AgentAvatar agent={agent} size={32} />}
onSelect={async (agent, signal) => {
@@ -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<AgentConversationSidebarProps, "existingChatAgentIds" | "unreadAgentIds" | "unreadMessageCounts">[]);
vi.mock("./AgentConversationSidebar", async importOriginal => {
const original = await importOriginal<typeof import("./AgentConversationSidebar")>();
return { AgentConversationSidebar: (props: AgentConversationSidebarProps) => {
sidebarInputs.push({ existingChatAgentIds: props.existingChatAgentIds, unreadAgentIds: props.unreadAgentIds, unreadMessageCounts: props.unreadMessageCounts });
return <original.AgentConversationSidebar {...props} />;
} };
});
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);
@@ -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 <AgentConversationSidebar key={`${companyId}:${userId}`} agents={[...conversations, ...teammates]} availableAgents={roster}
existingChatAgentIds={conversations.map(agent => 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"; })}
@@ -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(<QueryClientProvider client={client}><CoordinatorAgentSwitcher agent={agents[0]} /></QueryClientProvider>));
}
async function open() {
await act(async () => container.querySelector<HTMLButtonElement>('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<HTMLElement>('[role="option"]')].find(option => option.textContent?.includes("Sam"))!.click());
expect(state.navigate).toHaveBeenCalledWith("/chats/sam");
expect(client.getQueryData<Issue[]>(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<HTMLElement>('[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();
});
+13 -2
View File
@@ -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<string, number>();
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 (
<AgentChatPicker
open={open}
@@ -29,7 +39,8 @@ export function CoordinatorAgentSwitcher({ agent }: { agent: Agent }) {
loading={agents.isPending}
error={agents.error}
onRetry={() => { 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))}`); }}
/>
);
@@ -216,6 +216,7 @@ export interface AgentChatPrototypeProps {
entryScenario?: EntryScenario;
sidebarScenario?: SidebarScenario;
coordinator?: boolean;
unreadMessageCounts?: Record<string, number>;
}
/** 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<string, IssueChatComment[]>();
@@ -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;
@@ -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");
},
};
@@ -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 }) },