From a8e4bfbdebca507af76a44b1f9206493301d4020 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 16 Jun 2026 17:38:49 +0000 Subject: [PATCH] Improve conference room history labels Co-Authored-By: Paperclip --- .../issue-selected-agent-chat-routes.test.ts | 51 +++++++++++++++++++ server/src/routes/issues.ts | 23 +++++++++ ui/src/components/SelectedAgentChat.tsx | 5 +- ui/src/pages/BoardChat.test.tsx | 25 +++++++++ ui/src/pages/BoardChat.tsx | 35 ++++++++++++- 5 files changed, 136 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/issue-selected-agent-chat-routes.test.ts b/server/src/__tests__/issue-selected-agent-chat-routes.test.ts index 2b8d0b7a2e9..4712c2eaf30 100644 --- a/server/src/__tests__/issue-selected-agent-chat-routes.test.ts +++ b/server/src/__tests__/issue-selected-agent-chat-routes.test.ts @@ -10,6 +10,7 @@ const ASSIGNEE_AGENT_ID = "33333333-3333-4333-8333-333333333333"; const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), + update: vi.fn(), addComment: vi.fn(), listComments: vi.fn(), getComment: vi.fn(), @@ -175,6 +176,7 @@ describe("selected-agent issue chat backend", () => { explanation: "Allowed by test grant.", }); mockIssueService.getById.mockResolvedValue(makeIssue()); + mockIssueService.update.mockResolvedValue(makeIssue()); mockIssueService.addComment.mockResolvedValue({ id: "comment-1", issueId: ISSUE_ID, @@ -240,6 +242,55 @@ describe("selected-agent issue chat backend", () => { ); }); + it("titles new board chat issues from the first selected-agent message and stores the target agent", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue({ + originKind: "board_chat", + originId: null, + title: "New chat", + })); + const db = createDbStub( + [makeAgent({ id: CEO_AGENT_ID, name: "CEO", role: "ceo" })], + [], + ); + + const res = await request(await createApp(db)) + .post(`/api/issues/${ISSUE_ID}/selected-agent-chat/comments`) + .send({ body: "Can you summarize the hiring plan before tomorrow?" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockIssueService.update).toHaveBeenCalledWith( + ISSUE_ID, + { + originId: CEO_AGENT_ID, + title: "Can you summarize the hiring plan before tomorrow?", + }, + ); + }); + + it("keeps a custom board chat title while refreshing the selected target agent", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue({ + originKind: "board_chat", + originId: CEO_AGENT_ID, + title: "Hiring plan", + })); + const db = createDbStub( + [makeAgent()], + [], + ); + + const res = await request(await createApp(db)) + .post(`/api/issues/${ISSUE_ID}/selected-agent-chat/comments`) + .send({ body: "switch this to the CTO", targetAgentId: TARGET_AGENT_ID }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockIssueService.update).toHaveBeenCalledWith( + ISSUE_ID, + { + originId: TARGET_AGENT_ID, + }, + ); + }); + it("rejects a targetAgentId outside the issue company", async () => { const db = createDbStub([makeAgent({ companyId: "other-company" })]); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 090b6c7ffbd..8fb21fd55eb 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -753,6 +753,16 @@ function selectedAgentChatTaskKey(issueId: string, targetAgentId: string) { return `selected-agent-chat:${issueId}:${targetAgentId}`; } +const BOARD_CHAT_ORIGIN_KIND = "board_chat"; +const BOARD_CHAT_GENERIC_TITLES = new Set(["New chat", "Board Operations"]); + +function deriveSelectedAgentBoardChatTitle(message: string): string { + const singleLine = message.replace(/\s+/g, " ").trim(); + if (!singleLine) return "New chat"; + if (singleLine.length <= 80) return singleLine; + return `${singleLine.slice(0, 77).trimEnd()}...`; +} + function shouldImplicitlyMoveCommentedIssueToTodo(input: { issueStatus: string | null | undefined; assigneeAgentId: string | null | undefined; @@ -6565,6 +6575,19 @@ export function issueRoutes( sourceTrust, }); + if (issue.originKind === BOARD_CHAT_ORIGIN_KIND) { + const boardChatPatch: Partial = {}; + if (issue.originId !== targetAgent.id) { + boardChatPatch.originId = targetAgent.id; + } + if (BOARD_CHAT_GENERIC_TITLES.has(issue.title)) { + boardChatPatch.title = deriveSelectedAgentBoardChatTitle(req.body.body); + } + if (Object.keys(boardChatPatch).length > 0) { + await svc.update(issue.id, boardChatPatch); + } + } + await issueReferencesSvc.syncComment(comment.id); await logActivity(db, { companyId: issue.companyId, diff --git a/ui/src/components/SelectedAgentChat.tsx b/ui/src/components/SelectedAgentChat.tsx index d93e0f8961e..998d79b9519 100644 --- a/ui/src/components/SelectedAgentChat.tsx +++ b/ui/src/components/SelectedAgentChat.tsx @@ -315,6 +315,7 @@ export interface SelectedAgentChatProps { showAgentSwitcher?: boolean; currentUserId?: string | null; emptyMessage?: string; + onMessageSent?: () => void | Promise; className?: string; } @@ -333,6 +334,7 @@ export function SelectedAgentChat({ showAgentSwitcher = true, currentUserId, emptyMessage, + onMessageSent, className, }: SelectedAgentChatProps) { const queryClient = useQueryClient(); @@ -409,6 +411,7 @@ export function SelectedAgentChat({ setErrorText(null); try { await issuesApi.addSelectedAgentChatComment(issueId, body, { targetAgentId }); + await onMessageSent?.(); invalidateRuns(); } catch (err) { setErrorText( @@ -420,7 +423,7 @@ export function SelectedAgentChat({ throw err; } }, - [issueId, targetAgentId, invalidateRuns], + [issueId, targetAgentId, onMessageSent, invalidateRuns], ); const handleStopRun = useCallback( diff --git a/ui/src/pages/BoardChat.test.tsx b/ui/src/pages/BoardChat.test.tsx index 5f85bb62aa9..f1407ea16b0 100644 --- a/ui/src/pages/BoardChat.test.tsx +++ b/ui/src/pages/BoardChat.test.tsx @@ -95,11 +95,19 @@ const CEO_AGENT = { status: "active", icon: null, }; +const CTO_AGENT = { + id: "agent-cto", + name: "Morgan", + role: "cto", + status: "active", + icon: null, +}; const BOARD_ISSUE = { id: "issue-board", identifier: "PAP-1", title: "How is hiring going?", originKind: "board_chat", + originId: "agent-ceo", status: "in_progress", createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z", @@ -109,6 +117,7 @@ const LEGACY_BOARD_ISSUE = { identifier: "PAP-0", title: "Board Operations", originKind: "board_chat", + originId: null, status: "todo", createdAt: "2026-06-09T00:00:00.000Z", updatedAt: "2026-06-09T00:00:00.000Z", @@ -251,6 +260,22 @@ describe("BoardChat Conference Room transport", () => { expect(container.textContent).not.toContain("PAP-0"); }); + it("shows the selected chat agent in history rows", async () => { + mockAgentsApi.list.mockResolvedValue([CEO_AGENT, CTO_AGENT]); + mockIssuesApi.list.mockResolvedValue([ + { + ...BOARD_ISSUE, + id: "issue-cto-chat", + originId: CTO_AGENT.id, + title: "Architecture review", + }, + ]); + await render(); + + expect(container.textContent).toContain("Architecture review"); + expect(container.textContent).toContain("Morgan · CTO"); + }); + it("reserves mobile viewport height and bottom-nav space for the agent feed", async () => { await render(); diff --git a/ui/src/pages/BoardChat.tsx b/ui/src/pages/BoardChat.tsx index af116192217..6bdbf9acf6a 100644 --- a/ui/src/pages/BoardChat.tsx +++ b/ui/src/pages/BoardChat.tsx @@ -24,7 +24,7 @@ import { Activity, AlertCircle, History, Loader2, MessageSquarePlus } from "luci import { ActivityFeed } from "../components/ActivityFeed"; import { SelectedAgentChat } from "../components/SelectedAgentChat"; import { cn, relativeTime } from "../lib/utils"; -import type { Issue } from "@paperclipai/shared"; +import { AGENT_ROLE_LABELS, type Agent, type Issue } from "@paperclipai/shared"; import { Sheet, SheetContent, @@ -51,6 +51,7 @@ const DEFAULT_CHAT_FRACTION = 2 / 3; const BOARD_CHAT_ISSUE_TITLE = "Board Operations"; const BOARD_CHAT_ORIGIN_KIND = "board_chat"; +const roleLabels = AGENT_ROLE_LABELS as Record; const boardChatHistoryDateFormatter = new Intl.DateTimeFormat(undefined, { month: "short", @@ -72,6 +73,21 @@ function boardChatHistoryLabel(issue: Pick, + agentsById: Map, + fallbackAgent: Agent | null, +): string | null { + const targetAgent = + issue.originKind === BOARD_CHAT_ORIGIN_KIND && issue.originId + ? agentsById.get(issue.originId) ?? null + : null; + const agent = targetAgent ?? fallbackAgent; + if (!agent) return null; + const role = roleLabels[agent.role] ?? agent.role; + return `${agent.name} · ${role}`; +} + export function BoardChat() { const { selectedCompanyId, selectedCompany } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -176,6 +192,10 @@ export function BoardChat() { () => agents?.find((a) => a.role === "ceo" && a.status !== "terminated"), [agents], ); + const agentsById = useMemo( + () => new Map((agents ?? []).map((agent) => [agent.id, agent] as const)), + [agents], + ); // Conference Room conversations are backed by ordinary company-scoped // `board_chat` issues so history survives reloads without a separate store. @@ -290,6 +310,11 @@ export function BoardChat() { setMintError(null); }, []); + const refreshBoardChatHistory = useCallback(async () => { + if (!historyQueryKey) return; + await queryClient.invalidateQueries({ queryKey: historyQueryKey }); + }, [historyQueryKey, queryClient]); + if (!selectedCompanyId) { return (
@@ -395,6 +420,11 @@ export function BoardChat() { {boardChatIssues.map((issue) => { const active = issue.id === boardIssueId; const label = boardChatHistoryLabel(issue); + const agentLabel = boardChatHistoryAgentLabel( + issue, + agentsById, + ceoAgent ?? null, + ); return ( @@ -436,6 +466,7 @@ export function BoardChat() { defaultTargetAgentId={ceoAgent?.id ?? null} currentUserId={currentUserId} emptyMessage={`Send ${ceoAgent?.name ?? "your CEO"} a message to start the conversation.`} + onMessageSent={refreshBoardChatHistory} className="min-h-0 flex-1" /> ) : mintError ? (