mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
Add CEO conference room chat
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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:");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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`");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user