mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01:00
Hide board chat issues from inbox
This commit is contained in:
1 parent
ee7cb3141e
commit
2d51920655
11 files changed
+248
-26
No files matched your search
@@ -7,6 +7,7 @@ const mockGetExperimental = vi.hoisted(() => vi.fn());
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
listComments: vi.fn(),
|
||||
}));
|
||||
@@ -116,7 +117,7 @@ describe("board-chat client disconnect", () => {
|
||||
it("kills the spawned subprocess when the client disconnects mid-stream", async () => {
|
||||
mockGetExperimental.mockResolvedValue({ enableConferenceRoomChat: true });
|
||||
mockIssueService.list.mockResolvedValue([
|
||||
{ id: "issue-1", title: "Board Operations", status: "todo" },
|
||||
{ id: "issue-1", title: "Quarterly hiring plan", originKind: "board_chat", status: "todo" },
|
||||
]);
|
||||
mockIssueService.addComment.mockResolvedValue({ id: "comment-1" });
|
||||
mockIssueService.listComments.mockResolvedValue([]);
|
||||
@@ -146,7 +147,7 @@ describe("board-chat client disconnect", () => {
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("tags newly created Board Operations issues as board chat conversations", async () => {
|
||||
it("tags newly created issues as board chat conversations with a first-message title", async () => {
|
||||
mockGetExperimental.mockResolvedValue({ enableConferenceRoomChat: true });
|
||||
mockIssueService.list.mockResolvedValue([]);
|
||||
mockIssueService.create.mockResolvedValue({ id: "issue-new" });
|
||||
@@ -170,7 +171,7 @@ describe("board-chat client disconnect", () => {
|
||||
fakeProc.emit("close", 0);
|
||||
|
||||
expect(mockIssueService.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
title: "Board Operations",
|
||||
title: "hello",
|
||||
originKind: "board_chat",
|
||||
}));
|
||||
expect(mockIssueService.addComment).toHaveBeenCalledWith(
|
||||
@@ -181,6 +182,56 @@ describe("board-chat client disconnect", () => {
|
||||
req.abort();
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("reuses board chat issues by origin before falling back to legacy route-owned Board Operations issues", async () => {
|
||||
mockGetExperimental.mockResolvedValue({ enableConferenceRoomChat: true });
|
||||
mockIssueService.list
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "issue-legacy",
|
||||
title: "Board Operations",
|
||||
description: "Standing issue for board concierge conversations and decision log",
|
||||
originKind: "manual",
|
||||
status: "todo",
|
||||
},
|
||||
]);
|
||||
mockIssueService.update.mockResolvedValue({ id: "issue-legacy", originKind: "board_chat" });
|
||||
mockIssueService.addComment.mockResolvedValue({ id: "comment-1" });
|
||||
mockIssueService.listComments.mockResolvedValue([]);
|
||||
const fakeProc = makeFakeProc();
|
||||
mockSpawn.mockReturnValue(fakeProc);
|
||||
const app = await createApp();
|
||||
|
||||
const req = request(app)
|
||||
.post("/api/board/chat/stream")
|
||||
.set("Content-Type", "application/json")
|
||||
.send(JSON.stringify({ companyId: "company-1", message: "hello" }));
|
||||
const pending = req.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
|
||||
fakeProc.exitCode = 0;
|
||||
fakeProc.emit("close", 0);
|
||||
|
||||
expect(mockIssueService.list).toHaveBeenNthCalledWith(1, "company-1", expect.objectContaining({
|
||||
originKind: "board_chat",
|
||||
}));
|
||||
expect(mockIssueService.list).toHaveBeenNthCalledWith(2, "company-1", expect.objectContaining({
|
||||
q: "Board Operations",
|
||||
}));
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith("issue-legacy", { originKind: "board_chat" });
|
||||
expect(mockIssueService.create).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.addComment).toHaveBeenCalledWith(
|
||||
"issue-legacy",
|
||||
"hello",
|
||||
expect.any(Object),
|
||||
);
|
||||
req.abort();
|
||||
await pending;
|
||||
});
|
||||
});
|
||||
|
||||
describe("board-chat history role classification", () => {
|
||||
|
||||
@@ -1312,6 +1312,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
const operationIssueId = randomUUID();
|
||||
const typedOperationIssueId = randomUUID();
|
||||
const legacyContentMachineOperationIssueId = randomUUID();
|
||||
const boardChatIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
@@ -1387,6 +1388,15 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
originKind: "plugin:paperclipai.content-machine:evaluation",
|
||||
originId: "content-machine-operation-1",
|
||||
},
|
||||
{
|
||||
id: boardChatIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Chat about hiring",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
originKind: "board_chat",
|
||||
},
|
||||
]);
|
||||
|
||||
const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id);
|
||||
@@ -1395,6 +1405,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
expect(defaultIssueIds).not.toContain(operationIssueId);
|
||||
expect(defaultIssueIds).not.toContain(typedOperationIssueId);
|
||||
expect(defaultIssueIds).not.toContain(legacyContentMachineOperationIssueId);
|
||||
expect(defaultIssueIds).not.toContain(boardChatIssueId);
|
||||
|
||||
const inboxIssueIds = (await svc.list(companyId, {
|
||||
assigneeAgentId: agentId,
|
||||
@@ -1405,6 +1416,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
expect(inboxIssueIds).not.toContain(operationIssueId);
|
||||
expect(inboxIssueIds).not.toContain(typedOperationIssueId);
|
||||
expect(inboxIssueIds).not.toContain(legacyContentMachineOperationIssueId);
|
||||
expect(inboxIssueIds).not.toContain(boardChatIssueId);
|
||||
|
||||
await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" }))
|
||||
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
|
||||
@@ -1415,16 +1427,23 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
expect.objectContaining({ id: operationIssueId }),
|
||||
expect.objectContaining({ id: typedOperationIssueId }),
|
||||
]));
|
||||
await expect(svc.list(companyId, { originKind: "board_chat" }))
|
||||
.resolves.toEqual([expect.objectContaining({ id: boardChatIssueId })]);
|
||||
|
||||
const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id);
|
||||
expect(projectIssueIds).toContain(operationIssueId);
|
||||
expect(projectIssueIds).toContain(typedOperationIssueId);
|
||||
expect(projectIssueIds).toContain(legacyContentMachineOperationIssueId);
|
||||
expect(projectIssueIds).not.toContain(boardChatIssueId);
|
||||
|
||||
const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id);
|
||||
expect(advancedIssueIds).toContain(operationIssueId);
|
||||
expect(advancedIssueIds).toContain(typedOperationIssueId);
|
||||
expect(advancedIssueIds).toContain(legacyContentMachineOperationIssueId);
|
||||
expect(advancedIssueIds).not.toContain(boardChatIssueId);
|
||||
|
||||
const specialIssueIds = (await svc.list(companyId, { includeSpecialOrigins: true })).map((issue) => issue.id);
|
||||
expect(specialIssueIds).toContain(boardChatIssueId);
|
||||
});
|
||||
|
||||
it("excludes plugin operation issues from unread inbox counts", async () => {
|
||||
@@ -1433,6 +1452,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
const otherUserId = "other-user";
|
||||
const normalIssueId = randomUUID();
|
||||
const operationIssueId = randomUUID();
|
||||
const boardChatIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
@@ -1458,6 +1478,15 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
createdByUserId: userId,
|
||||
originKind: "plugin:paperclip.missions:operation",
|
||||
},
|
||||
{
|
||||
id: boardChatIssueId,
|
||||
companyId,
|
||||
title: "Board chat touched issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: userId,
|
||||
originKind: "board_chat",
|
||||
},
|
||||
]);
|
||||
await db.insert(issueComments).values([
|
||||
{
|
||||
@@ -1472,6 +1501,12 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
authorUserId: otherUserId,
|
||||
body: "Unread operation update.",
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
issueId: boardChatIssueId,
|
||||
authorUserId: otherUserId,
|
||||
body: "Unread board chat update.",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(svc.countUnreadTouchedByUser(companyId, userId, "todo")).resolves.toBe(1);
|
||||
|
||||
@@ -61,6 +61,34 @@ export function isConciergeReply(comment: {
|
||||
|
||||
/** Max simultaneous `claude` subprocesses across all board-chat requests. */
|
||||
const MAX_CONCURRENT_BOARD_CHATS = 3;
|
||||
const BOARD_CHAT_ORIGIN_KIND = "board_chat";
|
||||
const LEGACY_BOARD_CHAT_TITLE = "Board Operations";
|
||||
const BOARD_CHAT_DESCRIPTION = "Standing issue for board concierge conversations and decision log";
|
||||
|
||||
function deriveBoardChatIssueTitle(message: string): string {
|
||||
const singleLine = message.replace(/\s+/g, " ").trim();
|
||||
if (!singleLine) return "New chat";
|
||||
if (singleLine.length <= 80) return singleLine;
|
||||
return `${singleLine.slice(0, 77).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function isOpenBoardChatIssue(issue: { status?: string | null }) {
|
||||
return issue.status !== "done" && issue.status !== "cancelled";
|
||||
}
|
||||
|
||||
function isLegacyBoardChatIssue(issue: {
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
originKind?: string | null;
|
||||
status?: string | null;
|
||||
}) {
|
||||
return (
|
||||
issue.title === LEGACY_BOARD_CHAT_TITLE &&
|
||||
issue.description === BOARD_CHAT_DESCRIPTION &&
|
||||
(issue.originKind === undefined || issue.originKind === null || issue.originKind === "manual") &&
|
||||
isOpenBoardChatIssue(issue)
|
||||
);
|
||||
}
|
||||
|
||||
export function boardChatRoutes(
|
||||
db: Db,
|
||||
@@ -154,31 +182,43 @@ export function boardChatRoutes(
|
||||
const issueSvc = issueService(db);
|
||||
let issueId = wantsNewConversation ? undefined : taskId;
|
||||
|
||||
// Find or create the standing "Board Operations" issue that anchors the
|
||||
// board conversation + decision log.
|
||||
// Find or create the standing issue that anchors the board conversation +
|
||||
// decision log. New records use the special origin; the title fallback
|
||||
// below is intentionally narrow so unrelated "Board Operations" tasks are
|
||||
// not silently pulled into Conference Room history.
|
||||
if (!issueId) {
|
||||
if (!wantsNewConversation) {
|
||||
const companyIssues = await issueSvc.list(companyId, {
|
||||
q: "Board Operations",
|
||||
const boardChatIssues = await issueSvc.list(companyId, {
|
||||
originKind: BOARD_CHAT_ORIGIN_KIND,
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
});
|
||||
const boardIssue = companyIssues.find(
|
||||
(i) =>
|
||||
i.title === "Board Operations" &&
|
||||
i.status !== "done" &&
|
||||
i.status !== "cancelled",
|
||||
);
|
||||
const boardIssue = boardChatIssues.find(isOpenBoardChatIssue);
|
||||
if (boardIssue) {
|
||||
issueId = boardIssue.id;
|
||||
}
|
||||
if (!issueId) {
|
||||
const legacyIssues = await issueSvc.list(companyId, {
|
||||
q: LEGACY_BOARD_CHAT_TITLE,
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
});
|
||||
const legacyIssue = legacyIssues.find(isLegacyBoardChatIssue);
|
||||
if (legacyIssue) {
|
||||
issueId = legacyIssue.id;
|
||||
try {
|
||||
await issueSvc.update(legacyIssue.id, { originKind: BOARD_CHAT_ORIGIN_KIND });
|
||||
} catch {
|
||||
/* best-effort legacy repair; the selected issue still anchors this request */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!issueId) {
|
||||
const created = await issueSvc.create(companyId, {
|
||||
title: "Board Operations",
|
||||
description:
|
||||
"Standing issue for board concierge conversations and decision log",
|
||||
originKind: "board_chat",
|
||||
title: deriveBoardChatIssueTitle(message),
|
||||
description: BOARD_CHAT_DESCRIPTION,
|
||||
originKind: BOARD_CHAT_ORIGIN_KIND,
|
||||
// `todo` rather than `in_progress`: this is an unassigned standing
|
||||
// issue, and the service rejects in_progress issues without an
|
||||
// assignee.
|
||||
|
||||
@@ -2534,6 +2534,8 @@ export function issueRoutes(
|
||||
req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1",
|
||||
includePluginOperations:
|
||||
req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1",
|
||||
includeSpecialOrigins:
|
||||
req.query.includeSpecialOrigins === "true" || req.query.includeSpecialOrigins === "1",
|
||||
includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1",
|
||||
includeBlockedInboxAttention:
|
||||
req.query.includeBlockedInboxAttention === "true" || req.query.includeBlockedInboxAttention === "1",
|
||||
@@ -2611,6 +2613,8 @@ export function issueRoutes(
|
||||
req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1",
|
||||
includePluginOperations:
|
||||
req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1",
|
||||
includeSpecialOrigins:
|
||||
req.query.includeSpecialOrigins === "true" || req.query.includeSpecialOrigins === "1",
|
||||
includeBlockedBy: true,
|
||||
includeBlockedInboxAttention: true,
|
||||
hasPlanDocument,
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
SuccessfulRunHandoffState,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
DEFAULT_HIDDEN_ISSUE_ORIGIN_KINDS,
|
||||
clampIssueRequestDepth,
|
||||
extractAgentMentionIds,
|
||||
extractProjectMentionIds,
|
||||
@@ -265,6 +266,7 @@ export interface IssueFilters {
|
||||
includeRoutineExecutions?: boolean;
|
||||
excludeRoutineExecutions?: boolean;
|
||||
includePluginOperations?: boolean;
|
||||
includeSpecialOrigins?: boolean;
|
||||
includeBlockedBy?: boolean;
|
||||
includeBlockedInboxAttention?: boolean;
|
||||
hasPlanDocument?: boolean;
|
||||
@@ -1119,6 +1121,19 @@ function shouldIncludePluginOperationIssues(filters: IssueFilters | undefined) {
|
||||
);
|
||||
}
|
||||
|
||||
function nonDefaultHiddenIssueOriginCondition() {
|
||||
return notInArray(issues.originKind, [...DEFAULT_HIDDEN_ISSUE_ORIGIN_KINDS]);
|
||||
}
|
||||
|
||||
function shouldIncludeDefaultHiddenIssueOrigins(filters: IssueFilters | undefined) {
|
||||
return Boolean(
|
||||
filters?.includeSpecialOrigins ||
|
||||
filters?.originKind ||
|
||||
filters?.originKindPrefix ||
|
||||
filters?.originId,
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveIssueUserContext(
|
||||
issue: IssueUserContextInput,
|
||||
userId: string,
|
||||
@@ -3037,6 +3052,7 @@ async function blockedInboxIssueConditions(
|
||||
conditions.push(hasPlanDocumentCondition(companyId, filters.hasPlanDocument));
|
||||
}
|
||||
if (!shouldIncludePluginOperationIssues(filters)) conditions.push(nonPluginOperationIssueCondition());
|
||||
if (!shouldIncludeDefaultHiddenIssueOrigins(filters)) conditions.push(nonDefaultHiddenIssueOriginCondition());
|
||||
if (filters?.labelId) {
|
||||
const labeledIssueIds = await dbOrTx
|
||||
.select({ issueId: issueLabels.issueId })
|
||||
@@ -4145,6 +4161,9 @@ export function issueService(db: Db) {
|
||||
if (!shouldIncludePluginOperationIssues(filters)) {
|
||||
conditions.push(nonPluginOperationIssueCondition());
|
||||
}
|
||||
if (!shouldIncludeDefaultHiddenIssueOrigins(filters)) {
|
||||
conditions.push(nonDefaultHiddenIssueOriginCondition());
|
||||
}
|
||||
if (filters?.labelId) {
|
||||
const labeledIssueIds = await db
|
||||
.select({ issueId: issueLabels.issueId })
|
||||
@@ -4313,6 +4332,7 @@ export function issueService(db: Db) {
|
||||
conditions.push(hasPlanDocumentCondition(companyId, filters.hasPlanDocument));
|
||||
}
|
||||
if (!shouldIncludePluginOperationIssues(filters)) conditions.push(nonPluginOperationIssueCondition());
|
||||
if (!shouldIncludeDefaultHiddenIssueOrigins(filters)) conditions.push(nonDefaultHiddenIssueOriginCondition());
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(issues)
|
||||
@@ -4329,6 +4349,7 @@ export function issueService(db: Db) {
|
||||
eq(issues.companyId, companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
nonPluginOperationIssueCondition(),
|
||||
nonDefaultHiddenIssueOriginCondition(),
|
||||
unreadForUserCondition(companyId, userId),
|
||||
];
|
||||
const statuses = parseStatusFilter(status);
|
||||
|
||||
Reference in new issue
Block a user