Hide board chat issues from inbox

This commit is contained in:
Dotta committed 2026-06-16 10:02:04 +00:00
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);
+54 -14
View File
@@ -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.
+4
View File
@@ -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,
+21
View File
@@ -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);