diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 425a1f3f0fa..9e2b998e3e5 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -270,6 +270,8 @@ type PaperclipWakeIssue = { title: string | null; status: string | null; priority: string | null; + originKind: string | null; + originId: string | null; }; type PaperclipWakeExecutionPrincipal = { @@ -373,6 +375,8 @@ function normalizePaperclipWakeIssue(value: unknown): PaperclipWakeIssue | null const title = asString(issue.title, "").trim() || null; const status = asString(issue.status, "").trim() || null; const priority = asString(issue.priority, "").trim() || null; + const originKind = asString(issue.originKind, "").trim() || null; + const originId = asString(issue.originId, "").trim() || null; if (!id && !identifier && !title) return null; return { id, @@ -380,6 +384,8 @@ function normalizePaperclipWakeIssue(value: unknown): PaperclipWakeIssue | null title, status, priority, + originKind, + originId, }; } @@ -756,6 +762,20 @@ export function renderPaperclipWakePrompt( ); } + if (normalized.issue?.originKind === "conference_room") { + lines.push( + "", + "## Conference Room Mode", + "", + "This issue is a board chat with you, not a normal execution task.", + "Answer the board directly by posting an issue comment on this same issue.", + "Do not run normal inbox or assignment triage for this wake, and do not delegate unless the board explicitly asks you to create or assign work.", + "Keep the reply conversational, concise, and useful to the board.", + "If the issue title is still `New chat`, update it to a short descriptive conversation title before or alongside your first substantive reply.", + "", + ); + } + if (normalized.comments.length > 0) { lines.push("New comments in order:"); } diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 16211b38e4f..9931a1682da 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -181,11 +181,13 @@ export type IssueThreadInteractionContinuationPolicy = export const ISSUE_ORIGIN_KINDS = [ "manual", "routine_execution", + "conference_room", "stale_active_run_evaluation", "harness_liveness_escalation", "issue_productivity_review", "stranded_issue_recovery", ] as const; +export const CONFERENCE_ROOM_ORIGIN_KIND = "conference_room" as const; export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number]; export type PluginIssueOriginKind = `plugin:${string}`; export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a70ff848e1e..9f71525b014 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -19,6 +19,7 @@ export { INBOX_MINE_ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUS_FILTER, ISSUE_PRIORITIES, + CONFERENCE_ROOM_ORIGIN_KIND, MAX_ISSUE_REQUEST_DEPTH, clampIssueRequestDepth, ISSUE_THREAD_INTERACTION_KINDS, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 9533e839dca..e0f9baf9e3b 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -4,6 +4,7 @@ import { ISSUE_EXECUTION_POLICY_MODES, ISSUE_EXECUTION_STAGE_TYPES, ISSUE_EXECUTION_STATE_STATUSES, + ISSUE_ORIGIN_KINDS, ISSUE_PRIORITIES, clampIssueRequestDepth, ISSUE_STATUSES, @@ -147,6 +148,8 @@ export const createIssueSchema = z.object({ assigneeUserId: z.string().optional().nullable(), requestDepth: issueRequestDepthInputSchema.optional().default(0), billingCode: z.string().optional().nullable(), + originKind: z.enum(ISSUE_ORIGIN_KINDS).optional(), + originId: z.string().optional().nullable(), assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(), executionPolicy: issueExecutionPolicySchema.optional().nullable(), executionWorkspaceId: z.string().uuid().optional().nullable(), diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ad11eecdd7b..9badb766809 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -24,7 +24,7 @@ import { } from "./helpers/embedded-postgres.js"; import { instanceSettingsService } from "../services/instance-settings.ts"; import { clampIssueListLimit, ISSUE_LIST_MAX_LIMIT, issueService } from "../services/issues.ts"; -import { buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared"; +import { buildProjectMentionHref, CONFERENCE_ROOM_ORIGIN_KIND, MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -1075,6 +1075,49 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(result?.description).toHaveLength(1200); expect(result?.description?.endsWith("—")).toBe(true); }); + + it("excludes conference room issues from ordinary lists unless the origin is explicit", async () => { + const companyId = randomUUID(); + const manualIssueId = randomUUID(); + const conferenceIssueId = randomUUID(); + const ceoAgentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values([ + { + id: manualIssueId, + companyId, + title: "Ordinary task", + status: "todo", + priority: "medium", + originKind: "manual", + }, + { + id: conferenceIssueId, + companyId, + title: "New chat", + status: "todo", + priority: "medium", + originKind: CONFERENCE_ROOM_ORIGIN_KIND, + originId: ceoAgentId, + }, + ]); + + const ordinary = await svc.list(companyId); + expect(ordinary.map((issue) => issue.id)).toEqual([manualIssueId]); + + const conference = await svc.list(companyId, { + originKind: CONFERENCE_ROOM_ORIGIN_KIND, + originId: ceoAgentId, + }); + expect(conference.map((issue) => issue.id)).toEqual([conferenceIssueId]); + }); }); describeEmbeddedPostgres("issueService.create workspace inheritance", () => { diff --git a/server/src/__tests__/paperclip-conference-wake-prompt.test.ts b/server/src/__tests__/paperclip-conference-wake-prompt.test.ts new file mode 100644 index 00000000000..573fb234ae6 --- /dev/null +++ b/server/src/__tests__/paperclip-conference-wake-prompt.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils"; + +describe("conference room wake prompt", () => { + it("adds conference mode guidance for conference room issues", () => { + const prompt = renderPaperclipWakePrompt({ + reason: "issue_commented", + issue: { + id: "11111111-1111-4111-8111-111111111111", + identifier: "PAP-99", + title: "New chat", + status: "todo", + priority: "medium", + originKind: "conference_room", + originId: "22222222-2222-4222-8222-222222222222", + }, + comments: [ + { + id: "33333333-3333-4333-8333-333333333333", + issueId: "11111111-1111-4111-8111-111111111111", + body: "What should we work on next?", + createdAt: "2026-04-18T13:15:52.900Z", + author: { type: "user", id: "board" }, + }, + ], + commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 }, + }); + + expect(prompt).toContain("## Conference Room Mode"); + expect(prompt).toContain("This issue is a board chat with you"); + expect(prompt).toContain("If the issue title is still `New chat`"); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 6b27357301d..3cd356b9222 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -16,6 +16,7 @@ import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; import { issueRoutes } from "./routes/issues.js"; import { issueTreeControlRoutes } from "./routes/issue-tree-control.js"; +import { conferenceRoomRoutes } from "./routes/conference-room.js"; import { routineRoutes } from "./routes/routines.js"; import { environmentRoutes } from "./routes/environments.js"; import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js"; @@ -197,6 +198,7 @@ export async function createApp( pluginWorkerManager: workerManager, })); api.use(issueTreeControlRoutes(db)); + api.use(conferenceRoomRoutes(db)); api.use(routineRoutes(db, { pluginWorkerManager: workerManager })); api.use(environmentRoutes(db, { pluginWorkerManager: workerManager })); api.use(executionWorkspaceRoutes(db)); diff --git a/server/src/routes/conference-room.ts b/server/src/routes/conference-room.ts new file mode 100644 index 00000000000..d12a5c63265 --- /dev/null +++ b/server/src/routes/conference-room.ts @@ -0,0 +1,204 @@ +import { Router } from "express"; +import { z } from "zod"; +import { and, asc, eq, isNull, ne } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents } from "@paperclipai/db"; +import { CONFERENCE_ROOM_ORIGIN_KIND } from "@paperclipai/shared"; +import { forbidden, notFound, unprocessable } from "../errors.js"; +import { validate } from "../middleware/validate.js"; +import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { + heartbeatService, + issueService, + logActivity, +} from "../services/index.js"; +import { logger } from "../middleware/logger.js"; + +const createConferenceRoomChatSchema = z.object({ + targetAgentId: z.string().uuid().optional().nullable(), + initialBody: z.string().trim().min(1).optional(), +}); + +async function resolveConferenceRoomTarget(db: Db, companyId: string, targetAgentId?: string | null) { + if (targetAgentId) { + const target = await db + .select() + .from(agents) + .where(and(eq(agents.id, targetAgentId), eq(agents.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!target) throw notFound("Conference room target agent not found"); + if (target.role !== "ceo") throw unprocessable("Conference Room currently supports CEO chats only"); + if (target.status === "terminated") throw unprocessable("Conference Room target agent is terminated"); + return target; + } + + const rootCeo = await db + .select() + .from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.role, "ceo"), isNull(agents.reportsTo), ne(agents.status, "terminated"))) + .orderBy(asc(agents.createdAt), asc(agents.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + if (rootCeo) return rootCeo; + + return db + .select() + .from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.role, "ceo"), ne(agents.status, "terminated"))) + .orderBy(asc(agents.createdAt), asc(agents.id)) + .limit(1) + .then((rows) => rows[0] ?? null); +} + +export function conferenceRoomRoutes(db: Db) { + const router = Router(); + const issues = issueService(db); + const heartbeat = heartbeatService(db); + + router.get("/companies/:companyId/conference-room/target", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + + const target = await resolveConferenceRoomTarget(db, companyId); + res.json({ target: target ?? null, label: target ? "CEO" : null }); + }); + + router.get("/companies/:companyId/conference-room/chats", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + + const target = await resolveConferenceRoomTarget( + db, + companyId, + typeof req.query.targetAgentId === "string" ? req.query.targetAgentId : null, + ); + if (!target) { + res.json([]); + return; + } + + const chats = await issues.list(companyId, { + originKind: CONFERENCE_ROOM_ORIGIN_KIND, + originId: target.id, + limit: typeof req.query.limit === "string" ? Number(req.query.limit) : undefined, + }); + const rows = await Promise.all( + chats.map(async (chat) => { + const latestComment = await issues.listComments(chat.id, { order: "desc", limit: 1 }); + return { + ...chat, + latestComment: latestComment[0] ?? null, + }; + }), + ); + res.json(rows); + }); + + router.post( + "/companies/:companyId/conference-room/chats", + validate(createConferenceRoomChatSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + if (req.actor.type === "agent") { + throw forbidden("Conference Room chats must be created by the board"); + } + + const target = await resolveConferenceRoomTarget(db, companyId, req.body.targetAgentId); + if (!target) { + res.status(404).json({ error: "CEO agent not found" }); + return; + } + + const actor = getActorInfo(req); + const initialBody = typeof req.body.initialBody === "string" ? req.body.initialBody.trim() : ""; + const issue = await issues.create(companyId, { + title: "New chat", + description: null, + status: initialBody ? "todo" : "backlog", + priority: "medium", + assigneeAgentId: target.id, + originKind: CONFERENCE_ROOM_ORIGIN_KIND, + originId: target.id, + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + }); + + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.created", + entityType: "issue", + entityId: issue.id, + details: { + title: issue.title, + identifier: issue.identifier, + originKind: CONFERENCE_ROOM_ORIGIN_KIND, + originId: target.id, + }, + }); + + let comment = null; + if (initialBody) { + comment = await issues.addComment(issue.id, initialBody, { + userId: actor.actorType === "user" ? actor.actorId : undefined, + runId: actor.runId, + }); + + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.comment_added", + entityType: "issue", + entityId: issue.id, + details: { + commentId: comment.id, + bodySnippet: comment.body.slice(0, 120), + identifier: issue.identifier, + issueTitle: issue.title, + originKind: CONFERENCE_ROOM_ORIGIN_KIND, + }, + }); + + void heartbeat + .wakeup(target.id, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { + issueId: issue.id, + commentId: comment.id, + mutation: "conference_room_comment", + }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { + issueId: issue.id, + taskId: issue.id, + commentId: comment.id, + wakeCommentId: comment.id, + wakeReason: "issue_commented", + source: "conference_room.comment", + conferenceRoom: { + targetAgentId: target.id, + targetLabel: "CEO", + }, + }, + }) + .catch((err) => + logger.warn({ err, issueId: issue.id, agentId: target.id }, "failed to wake conference room target"), + ); + } + + res.status(201).json({ issue, comment, target, label: "CEO" }); + }, + ); + + return router; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 983562e265a..a6a6f276d35 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -5,6 +5,7 @@ export { agentRoutes } from "./agents.js"; export { projectRoutes } from "./projects.js"; export { issueRoutes } from "./issues.js"; export { issueTreeControlRoutes } from "./issue-tree-control.js"; +export { conferenceRoomRoutes } from "./conference-room.js"; export { routineRoutes } from "./routines.js"; export { goalRoutes } from "./goals.js"; export { approvalRoutes } from "./approvals.js"; diff --git a/server/src/services/dashboard.ts b/server/src/services/dashboard.ts index 5d60ced5648..d066932920a 100644 --- a/server/src/services/dashboard.ts +++ b/server/src/services/dashboard.ts @@ -1,6 +1,7 @@ -import { and, eq, gte, sql } from "drizzle-orm"; +import { and, eq, gte, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, approvals, companies, costEvents, heartbeatRuns, issues } from "@paperclipai/db"; +import { CONFERENCE_ROOM_ORIGIN_KIND } from "@paperclipai/shared"; import { notFound } from "../errors.js"; import { budgetService } from "./budgets.js"; @@ -43,7 +44,7 @@ export function dashboardService(db: Db) { const taskRows = await db .select({ status: issues.status, count: sql`count(*)` }) .from(issues) - .where(eq(issues.companyId, companyId)) + .where(and(eq(issues.companyId, companyId), ne(issues.originKind, CONFERENCE_ROOM_ORIGIN_KIND))) .groupBy(issues.status); const pendingApprovals = await db diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index a76b15a8145..eca074e2923 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1770,6 +1770,8 @@ async function buildPaperclipWakePayload(input: { title: string; status: string; priority: string; + originKind?: string | null; + originId?: string | null; } | null; }) { @@ -1787,6 +1789,8 @@ async function buildPaperclipWakePayload(input: { title: issues.title, status: issues.status, priority: issues.priority, + originKind: issues.originKind, + originId: issues.originId, }) .from(issues) .where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId))) @@ -1867,6 +1871,8 @@ async function buildPaperclipWakePayload(input: { title: issueSummary.title, status: issueSummary.status, priority: issueSummary.priority, + originKind: issueSummary.originKind ?? null, + originId: issueSummary.originId ?? null, } : null, childIssueSummaries: Array.isArray(input.contextSnapshot.childIssueSummaries) @@ -2260,6 +2266,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) description: issues.description, status: issues.status, priority: issues.priority, + originKind: issues.originKind, + originId: issues.originId, projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId, executionWorkspaceId: issues.executionWorkspaceId, @@ -5025,6 +5033,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) status: issueContext.status, priority: issueContext.priority, description: issueContext.description, + originKind: issueContext.originKind, + originId: issueContext.originId, projectId: issueContext.projectId, projectWorkspaceId: issueContext.projectWorkspaceId, executionWorkspaceId: issueContext.executionWorkspaceId, @@ -5056,6 +5066,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) title: issueRef.title, status: issueRef.status, priority: issueRef.priority, + originKind: issueRef.originKind, + originId: issueRef.originId, } : null, }); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index cce66d09c95..8a83bd142f7 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -33,7 +33,13 @@ import type { IssueProductivityReviewTrigger, IssueRelationIssueSummary, } from "@paperclipai/shared"; -import { clampIssueRequestDepth, extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared"; +import { + CONFERENCE_ROOM_ORIGIN_KIND, + clampIssueRequestDepth, + extractAgentMentionIds, + extractProjectMentionIds, + isUuidLike, +} from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { defaultIssueExecutionWorkspaceSettingsForProject, @@ -2199,6 +2205,9 @@ export function issueService(db: Db) { if (filters?.excludeRoutineExecutions && !filters?.originKind && !filters?.originId) { conditions.push(ne(issues.originKind, "routine_execution")); } + if (!filters?.originKind && !filters?.originId) { + conditions.push(ne(issues.originKind, CONFERENCE_ROOM_ORIGIN_KIND)); + } conditions.push(isNull(issues.hiddenAt)); const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`; @@ -2309,6 +2318,7 @@ export function issueService(db: Db) { const conditions = [ eq(issues.companyId, companyId), isNull(issues.hiddenAt), + ne(issues.originKind, CONFERENCE_ROOM_ORIGIN_KIND), unreadForUserCondition(companyId, userId), ]; if (status) { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8e6015f89f3..4670d626ee7 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -26,6 +26,7 @@ import { ApprovalDetail } from "./pages/ApprovalDetail"; import { Costs } from "./pages/Costs"; import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; +import { ConferenceRoom } from "./pages/ConferenceRoom"; import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; import { CompanyAccess } from "./pages/CompanyAccess"; @@ -126,6 +127,10 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -282,6 +287,10 @@ export function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/api/conferenceRoom.ts b/ui/src/api/conferenceRoom.ts new file mode 100644 index 00000000000..8a258affb39 --- /dev/null +++ b/ui/src/api/conferenceRoom.ts @@ -0,0 +1,31 @@ +import type { Agent, Issue, IssueComment } from "@paperclipai/shared"; +import { api } from "./client"; + +export type ConferenceRoomTargetResponse = { + target: Agent | null; + label: string | null; +}; + +export type ConferenceRoomChat = Issue & { + latestComment?: IssueComment | null; +}; + +export type CreateConferenceRoomChatResponse = { + issue: Issue; + comment: IssueComment | null; + target: Agent; + label: string; +}; + +export const conferenceRoomApi = { + target: (companyId: string) => + api.get(`/companies/${companyId}/conference-room/target`), + chats: (companyId: string, targetAgentId?: string | null) => { + const params = new URLSearchParams(); + if (targetAgentId) params.set("targetAgentId", targetAgentId); + const qs = params.toString(); + return api.get(`/companies/${companyId}/conference-room/chats${qs ? `?${qs}` : ""}`); + }, + createChat: (companyId: string, data: { targetAgentId?: string | null; initialBody: string }) => + api.post(`/companies/${companyId}/conference-room/chats`, data), +}; diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 6828014a726..058c2d3028e 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -12,6 +12,7 @@ import { Repeat, GitBranch, Settings, + MessageCircle, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { SidebarSection } from "./SidebarSection"; @@ -88,6 +89,7 @@ export function Sidebar() { badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"} alert={inboxBadge.failedRuns > 0} /> + (queryKeys.issues.detail(issueRef)) ?? null; diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 7290c28a9ea..453dea9507e 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -63,6 +63,11 @@ export const queryKeys = { activeRun: (issueId: string) => ["issues", "active-run", issueId] as const, workProducts: (issueId: string) => ["issues", "work-products", issueId] as const, }, + conferenceRoom: { + target: (companyId: string) => ["conference-room", companyId, "target"] as const, + chats: (companyId: string, targetAgentId?: string | null) => + ["conference-room", companyId, "chats", targetAgentId ?? "__default__"] as const, + }, routines: { list: (companyId: string) => ["routines", companyId] as const, detail: (id: string) => ["routines", "detail", id] as const, diff --git a/ui/src/pages/ConferenceRoom.tsx b/ui/src/pages/ConferenceRoom.tsx new file mode 100644 index 00000000000..435861c0df2 --- /dev/null +++ b/ui/src/pages/ConferenceRoom.tsx @@ -0,0 +1,411 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { History, MessageCircle, Plus } from "lucide-react"; +import type { Agent, Issue, IssueComment } from "@paperclipai/shared"; +import { CONFERENCE_ROOM_ORIGIN_KIND } from "@paperclipai/shared"; +import { useLocation, useNavigate, useParams, Link } from "@/lib/router"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useCompany } from "../context/CompanyContext"; +import { useToastActions } from "../context/ToastContext"; +import { conferenceRoomApi } from "../api/conferenceRoom"; +import { issuesApi } from "../api/issues"; +import { agentsApi } from "../api/agents"; +import { authApi } from "../api/auth"; +import { heartbeatsApi } from "../api/heartbeats"; +import { activityApi } from "../api/activity"; +import { queryKeys } from "../lib/queryKeys"; +import { + createOptimisticIssueComment, + mergeIssueComments, + type OptimisticIssueComment, +} from "../lib/optimistic-issue-comments"; +import { cn, relativeTime } from "../lib/utils"; + +const NEW_CHAT_TITLE = "New chat"; + +function stripMarkdown(value: string) { + return value + .replace(/```[\s\S]*?```/g, " ") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[[^\]]*]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)]\([^)]*\)/g, "$1") + .replace(/[#*_>~-]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function chatDisplayTitle(chat: Issue) { + if (chat.title && chat.title !== NEW_CHAT_TITLE) return chat.title; + return chat.identifier ?? NEW_CHAT_TITLE; +} + +function ConferenceIconButton({ + label, + onClick, + icon, + active = false, +}: { + label: string; + onClick: () => void; + icon: ReactNode; + active?: boolean; +}) { + return ( + + + + + {label} + + ); +} + +export function ConferenceRoom() { + const { chatRef } = useParams<{ chatRef?: string }>(); + const location = useLocation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { pushToast } = useToastActions(); + const composerRef = useRef(null); + const [optimisticComments, setOptimisticComments] = useState([]); + + const isHistoryRoute = location.pathname.endsWith("/conference/history"); + const isExplicitNewRoute = location.pathname.endsWith("/conference/new"); + const isBaseRoute = !chatRef && !isHistoryRoute && !isExplicitNewRoute; + const isNewRoute = isExplicitNewRoute || isBaseRoute; + + useEffect(() => { + setBreadcrumbs([{ label: "Conference Room" }]); + }, [setBreadcrumbs]); + + const { data: targetState, isLoading: targetLoading } = useQuery({ + queryKey: selectedCompanyId ? queryKeys.conferenceRoom.target(selectedCompanyId) : ["conference-room", "no-company"], + queryFn: () => conferenceRoomApi.target(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const target = targetState?.target ?? null; + const targetLabel = targetState?.label ?? "CEO"; + + const { data: chats = [] } = useQuery({ + queryKey: selectedCompanyId ? queryKeys.conferenceRoom.chats(selectedCompanyId, target?.id) : ["conference-room", "chats", "no-company"], + queryFn: () => conferenceRoomApi.chats(selectedCompanyId!, target?.id), + enabled: !!selectedCompanyId && !!target?.id, + }); + + useEffect(() => { + if (!selectedCompanyId || targetLoading || !isBaseRoute) return; + const latestChat = chats[0] ?? null; + if (latestChat?.identifier || latestChat?.id) { + navigate(`/conference/${latestChat.identifier ?? latestChat.id}`, { replace: true }); + } + }, [chats, isBaseRoute, navigate, selectedCompanyId, targetLoading]); + + const { data: issue, isLoading: issueLoading } = useQuery({ + queryKey: chatRef ? queryKeys.issues.detail(chatRef) : ["issues", "detail", "conference-room-new"], + queryFn: () => issuesApi.get(chatRef!), + enabled: !!chatRef && !isHistoryRoute && chatRef !== "new", + }); + const activeIssue = + issue?.originKind === CONFERENCE_ROOM_ORIGIN_KIND && + (!target?.id || issue.originId === target.id) + ? issue + : null; + const issueId = activeIssue?.id ?? null; + const issueQueryRef = chatRef && chatRef !== "new" && !isHistoryRoute ? chatRef : (issueId ?? "conference-room-new"); + + const { data: comments = [] } = useQuery({ + queryKey: issueId ? queryKeys.issues.comments(issueQueryRef) : ["issues", "comments", "conference-room-new"], + queryFn: () => issuesApi.listComments(issueId!, { order: "asc", limit: 200 }), + enabled: !!issueId, + }); + + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + }); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + + const { data: agents = [] } = useQuery({ + queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "no-company"], + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const agentMap = useMemo(() => new Map(agents.map((agent) => [agent.id, agent] as const)), [agents]); + + const { data: liveRuns = [] } = useQuery({ + queryKey: issueId ? queryKeys.issues.liveRuns(issueQueryRef) : ["issues", "live-runs", "conference-room-new"], + queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!), + enabled: !!issueId, + refetchInterval: issueId ? 3000 : false, + }); + const { data: activeRun = null } = useQuery({ + queryKey: issueId ? queryKeys.issues.activeRun(issueQueryRef) : ["issues", "active-run", "conference-room-new"], + queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), + enabled: !!issueId, + refetchInterval: issueId ? 3000 : false, + }); + const { data: linkedRuns = [] } = useQuery({ + queryKey: issueId ? queryKeys.issues.runs(issueQueryRef) : ["issues", "runs", "conference-room-new"], + queryFn: () => activityApi.runsForIssue(issueId!), + enabled: !!issueId, + refetchInterval: liveRuns.length > 0 || activeRun ? 5000 : false, + }); + + useEffect(() => { + if (!issueId) return; + issuesApi.markRead(issueId).catch(() => undefined); + }, [issueId]); + + const threadComments = useMemo( + () => mergeIssueComments(comments, optimisticComments), + [comments, optimisticComments], + ); + + const invalidateConferenceRoom = useCallback((nextIssueId?: string | null, nextIdentifier?: string | null) => { + if (selectedCompanyId && target?.id) { + queryClient.invalidateQueries({ queryKey: queryKeys.conferenceRoom.chats(selectedCompanyId, target.id) }); + } + const refs = new Set([nextIssueId, nextIdentifier, chatRef, issueId].filter(Boolean) as string[]); + for (const ref of refs) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) }); + } + }, [chatRef, issueId, queryClient, selectedCompanyId, target?.id]); + + const createChat = useMutation({ + mutationFn: (body: string) => + conferenceRoomApi.createChat(selectedCompanyId!, { + targetAgentId: target?.id ?? null, + initialBody: body, + }), + onSuccess: ({ issue: nextIssue, comment }) => { + queryClient.setQueryData(queryKeys.issues.detail(nextIssue.id), nextIssue); + if (nextIssue.identifier) { + queryClient.setQueryData(queryKeys.issues.detail(nextIssue.identifier), nextIssue); + } + if (comment) { + queryClient.setQueryData(queryKeys.issues.comments(nextIssue.id), [comment]); + if (nextIssue.identifier) { + queryClient.setQueryData(queryKeys.issues.comments(nextIssue.identifier), [comment]); + } + } + invalidateConferenceRoom(nextIssue.id, nextIssue.identifier); + navigate(`/conference/${nextIssue.identifier ?? nextIssue.id}`, { replace: true }); + }, + onError: (err) => { + pushToast({ + title: "Message failed", + body: err instanceof Error ? err.message : "Unable to start the chat", + tone: "error", + }); + }, + }); + + const addComment = useMutation({ + mutationFn: (body: string) => + issuesApi.addComment( + issueId!, + body, + activeIssue?.status === "done" || activeIssue?.status === "cancelled" ? true : undefined, + ), + onMutate: async (body) => { + if (!activeIssue) return { optimisticId: null }; + const optimistic = createOptimisticIssueComment({ + companyId: activeIssue.companyId, + issueId: activeIssue.id, + body, + authorUserId: currentUserId, + }); + setOptimisticComments((current) => [...current, optimistic]); + return { optimisticId: optimistic.clientId }; + }, + onSuccess: (comment, _body, context) => { + if (context?.optimisticId) { + setOptimisticComments((current) => current.filter((entry) => entry.clientId !== context.optimisticId)); + } + queryClient.setQueryData(queryKeys.issues.comments(issueQueryRef), (current) => { + const existing = current ?? []; + if (existing.some((entry) => entry.id === comment.id)) return existing; + return [...existing, comment].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + }); + invalidateConferenceRoom(issueId, activeIssue?.identifier ?? null); + }, + onError: (err, _body, context) => { + if (context?.optimisticId) { + setOptimisticComments((current) => current.filter((entry) => entry.clientId !== context.optimisticId)); + } + pushToast({ + title: "Message failed", + body: err instanceof Error ? err.message : "Unable to post the message", + tone: "error", + }); + }, + onSettled: () => { + invalidateConferenceRoom(issueId, activeIssue?.identifier ?? null); + }, + }); + + const stopRun = useMutation({ + mutationFn: (runId: string) => heartbeatsApi.cancel(runId), + onSuccess: () => invalidateConferenceRoom(issueId), + onError: (err) => { + pushToast({ + title: "Stop failed", + body: err instanceof Error ? err.message : "Unable to stop the current response", + tone: "error", + }); + }, + }); + + const handleAdd = useCallback(async (body: string) => { + if (!selectedCompanyId || !target?.id) throw new Error("Conference Room target is unavailable"); + if (!issueId) { + await createChat.mutateAsync(body); + return; + } + await addComment.mutateAsync(body); + }, [addComment, createChat, issueId, selectedCompanyId, target?.id]); + + const openHistory = useCallback(() => navigate("/conference/history"), [navigate]); + const openNewChat = useCallback(() => navigate("/conference/new"), [navigate]); + + if (!selectedCompanyId) { + return
Select a company.
; + } + + if (targetLoading) { + return
Loading...
; + } + + if (!target) { + return ( +
+
+ +

No CEO Found

+

Add a CEO agent before opening the Conference Room.

+
+
+ ); + } + + const showChat = !isHistoryRoute; + const chatIsMissing = !!chatRef && chatRef !== "new" && !issueLoading && !activeIssue; + + return ( + +
+
+
+ + + +
+
{targetLabel}
+
{target.name}
+
+
+
+ } + /> + } + /> +
+
+ + {isHistoryRoute ? ( +
+
+ {chats.length === 0 ? ( +
+ No chats yet. +
+ ) : ( + chats.map((chat) => { + const excerpt = stripMarkdown(chat.latestComment?.body ?? ""); + const to = `/conference/${chat.identifier ?? chat.id}`; + return ( + +
+
+
{chatDisplayTitle(chat)}
+ {excerpt ? ( +
{excerpt}
+ ) : null} +
+
+ {relativeTime(chat.lastActivityAt ?? chat.updatedAt)} +
+
+ + ); + }) + )} +
+
+ ) : chatIsMissing ? ( +
+ Chat not found. +
+ ) : showChat ? ( +
+ ({ + ...run, + hasStoredOutput: (run.logBytes ?? 0) > 0, + }))} + liveRuns={liveRuns} + activeRun={activeRun} + companyId={selectedCompanyId} + projectId={activeIssue?.projectId ?? null} + issueStatus={activeIssue?.status ?? "todo"} + agentMap={agentMap as Map} + currentUserId={currentUserId} + draftKey={`conference-room:${issueId ?? "new"}:${target.id}`} + enableReassign={false} + mentions={[{ id: target.id, name: target.name, kind: "agent", agentId: target.id, agentIcon: target.icon }]} + onAdd={handleAdd} + onStopRun={(runId) => stopRun.mutateAsync(runId)} + onCancelRun={activeRun?.id ? () => stopRun.mutateAsync(activeRun.id) : undefined} + emptyMessage={isNewRoute ? "" : "No messages yet."} + showJumpToLatest + /> +
+ ) : null} +
+
+ ); +}