Add CEO conference room chat

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-30 16:28:59 -05:00
1 parent a3de1d764d
commit 1b54120faf
18 files changed
+801 -5

No files matched your search

@@ -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:");
}
+2
View File
@@ -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;
+1
View File
@@ -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,
+3
View File
@@ -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(),
+44 -1
View File
@@ -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", () => {
@@ -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`");
});
});
+2
View File
@@ -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));
+204
View File
@@ -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;
}
+1
View File
@@ -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";
+3 -2
View File
@@ -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<number>`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
+12
View File
@@ -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,
});
+11 -1
View File
@@ -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) {
+9
View File
@@ -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() {
<Route path="inbox/requests" element={<JoinRequestQueue />} />
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
<Route path="u/:userSlug" element={<UserProfile />} />
<Route path="conference" element={<ConferenceRoom />} />
<Route path="conference/new" element={<ConferenceRoom />} />
<Route path="conference/history" element={<ConferenceRoom />} />
<Route path="conference/:chatRef" element={<ConferenceRoom />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="instance/settings/adapters" element={<AdapterManager />} />
<Route path=":pluginRoutePath" element={<PluginPage />} />
@@ -282,6 +287,10 @@ export function App() {
<Route path="companies" element={<UnprefixedBoardRedirect />} />
<Route path="issues" element={<UnprefixedBoardRedirect />} />
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
<Route path="conference" element={<UnprefixedBoardRedirect />} />
<Route path="conference/new" element={<UnprefixedBoardRedirect />} />
<Route path="conference/history" element={<UnprefixedBoardRedirect />} />
<Route path="conference/:chatRef" element={<UnprefixedBoardRedirect />} />
<Route path="routines" element={<UnprefixedBoardRedirect />} />
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
<Route path="u/:userSlug" element={<UnprefixedBoardRedirect />} />
+31
View File
@@ -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<ConferenceRoomTargetResponse>(`/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<ConferenceRoomChat[]>(`/companies/${companyId}/conference-room/chats${qs ? `?${qs}` : ""}`);
},
createChat: (companyId: string, data: { targetAgentId?: string | null; initialBody: string }) =>
api.post<CreateConferenceRoomChatResponse>(`/companies/${companyId}/conference-room/chats`, data),
};
+2
View File
@@ -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}
/>
<SidebarNavItem to="/conference" label="Conference Room" icon={MessageCircle} />
<PluginSlotOutlet
slotTypes={["sidebar"]}
context={pluginContext}
+7 -1
View File
@@ -186,7 +186,13 @@ function resolveVisibleIssueRouteContext(
const relativePath = toCompanyRelativePath(pathname);
const segments = relativePath.split("/").filter(Boolean);
if (segments[0] !== "issues" || !segments[1]) return null;
const isIssueRoute = segments[0] === "issues" && !!segments[1];
const isConferenceRoute =
segments[0] === "conference" &&
!!segments[1] &&
segments[1] !== "new" &&
segments[1] !== "history";
if (!isIssueRoute && !isConferenceRoute) return null;
const issueRef = decodeURIComponent(segments[1]);
const issue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueRef)) ?? null;
+5
View File
@@ -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,
+411
View File
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={active ? "secondary" : "ghost"}
size="icon-sm"
aria-label={label}
onClick={onClick}
className="h-8 w-8"
>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
}
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<IssueChatComposerHandle | null>(null);
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
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<IssueComment[]>(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 <div className="text-sm text-muted-foreground">Select a company.</div>;
}
if (targetLoading) {
return <div className="text-sm text-muted-foreground">Loading...</div>;
}
if (!target) {
return (
<div className="flex min-h-[45vh] items-center justify-center">
<div className="max-w-sm text-center">
<MessageCircle className="mx-auto h-8 w-8 text-muted-foreground" />
<h2 className="mt-3 text-lg font-semibold">No CEO Found</h2>
<p className="mt-1 text-sm text-muted-foreground">Add a CEO agent before opening the Conference Room.</p>
</div>
</div>
);
}
const showChat = !isHistoryRoute;
const chatIsMissing = !!chatRef && chatRef !== "new" && !issueLoading && !activeIssue;
return (
<TooltipProvider>
<div className="mx-auto flex h-[calc(100vh-8.5rem)] min-h-[560px] max-w-5xl flex-col overflow-hidden">
<div className="mb-3 flex h-11 shrink-0 items-center justify-between border-b border-border/80 bg-background">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-md border border-border bg-muted/60">
<MessageCircle className="h-4 w-4 text-muted-foreground" />
</span>
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{targetLabel}</div>
<div className="truncate text-xs text-muted-foreground">{target.name}</div>
</div>
</div>
<div className="flex items-center gap-1">
<ConferenceIconButton
label="Chat history"
onClick={openHistory}
active={isHistoryRoute}
icon={<History className="h-4 w-4" />}
/>
<ConferenceIconButton
label="New chat"
onClick={openNewChat}
active={isNewRoute && !isHistoryRoute}
icon={<Plus className="h-4 w-4" />}
/>
</div>
</div>
{isHistoryRoute ? (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="divide-y divide-border">
{chats.length === 0 ? (
<div className="flex min-h-[360px] items-center justify-center text-sm text-muted-foreground">
No chats yet.
</div>
) : (
chats.map((chat) => {
const excerpt = stripMarkdown(chat.latestComment?.body ?? "");
const to = `/conference/${chat.identifier ?? chat.id}`;
return (
<Link
key={chat.id}
to={to}
className="block px-1 py-3 transition-colors hover:bg-accent/50"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{chatDisplayTitle(chat)}</div>
{excerpt ? (
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{excerpt}</div>
) : null}
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{relativeTime(chat.lastActivityAt ?? chat.updatedAt)}
</div>
</div>
</Link>
);
})
)}
</div>
</div>
) : chatIsMissing ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
Chat not found.
</div>
) : showChat ? (
<div className={cn("min-h-0 flex-1 overflow-y-auto", issueLoading && "opacity-70")}>
<IssueChatThread
composerRef={composerRef}
comments={threadComments}
linkedRuns={linkedRuns.map((run) => ({
...run,
hasStoredOutput: (run.logBytes ?? 0) > 0,
}))}
liveRuns={liveRuns}
activeRun={activeRun}
companyId={selectedCompanyId}
projectId={activeIssue?.projectId ?? null}
issueStatus={activeIssue?.status ?? "todo"}
agentMap={agentMap as Map<string, Agent>}
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
/>
</div>
) : null}
</div>
</TooltipProvider>
);
}