mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +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
@@ -256,6 +256,7 @@ export type IssueThreadInteractionContinuationPolicy =
|
||||
|
||||
export const ISSUE_ORIGIN_KINDS = [
|
||||
"manual",
|
||||
"board_chat",
|
||||
"routine_execution",
|
||||
"stale_active_run_evaluation",
|
||||
"harness_liveness_escalation",
|
||||
@@ -265,6 +266,7 @@ export const ISSUE_ORIGIN_KINDS = [
|
||||
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
|
||||
export type PluginIssueOriginKind = `plugin:${string}`;
|
||||
export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind;
|
||||
export const DEFAULT_HIDDEN_ISSUE_ORIGIN_KINDS = ["board_chat"] as const satisfies readonly BuiltInIssueOriginKind[];
|
||||
export const ISSUE_SURFACE_VISIBILITIES = ["default", "plugin_operation"] as const;
|
||||
export type IssueSurfaceVisibility = (typeof ISSUE_SURFACE_VISIBILITIES)[number];
|
||||
|
||||
@@ -311,6 +313,12 @@ export function isPluginOperationIssueOriginKind(originKind: string | null | und
|
||||
return typeof originKind === "string" && /^plugin:[^:]+:operation(?::|$)/.test(originKind);
|
||||
}
|
||||
|
||||
export function isDefaultHiddenIssueOriginKind(originKind: string | null | undefined): boolean {
|
||||
return (DEFAULT_HIDDEN_ISSUE_ORIGIN_KINDS as readonly BuiltInIssueOriginKind[]).includes(
|
||||
originKind as BuiltInIssueOriginKind,
|
||||
);
|
||||
}
|
||||
|
||||
export const ISSUE_RELATION_TYPES = ["blocks"] as const;
|
||||
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number];
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export {
|
||||
ISSUE_THREAD_INTERACTION_STATUSES,
|
||||
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
|
||||
ISSUE_ORIGIN_KINDS,
|
||||
DEFAULT_HIDDEN_ISSUE_ORIGIN_KINDS,
|
||||
ISSUE_SURFACE_VISIBILITIES,
|
||||
ISSUE_RECOVERY_ACTION_KINDS,
|
||||
ISSUE_RECOVERY_ACTION_STATUSES,
|
||||
@@ -79,6 +80,7 @@ export {
|
||||
ISSUE_RECOVERY_ACTION_OUTCOMES,
|
||||
pluginOperationIssueOriginKind,
|
||||
isPluginOperationIssueOriginKind,
|
||||
isDefaultHiddenIssueOriginKind,
|
||||
ISSUE_RELATION_TYPES,
|
||||
ISSUE_TREE_CONTROL_MODES,
|
||||
ISSUE_TREE_HOLD_RELEASE_POLICY_STRATEGIES,
|
||||
@@ -1016,6 +1018,7 @@ export {
|
||||
issueCommentMetadataSectionSchema,
|
||||
issueCommentMetadataSchema,
|
||||
addIssueCommentSchema,
|
||||
selectedAgentChatCommentSchema,
|
||||
issueThreadInteractionStatusSchema,
|
||||
issueThreadInteractionKindSchema,
|
||||
issueThreadInteractionContinuationPolicySchema,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -71,6 +71,18 @@ describe("issuesApi.list", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes special origin opt-in filters through to the company issues endpoint", async () => {
|
||||
await issuesApi.list("company-1", {
|
||||
originKind: "board_chat",
|
||||
includeSpecialOrigins: true,
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/companies/company-1/issues?originKind=board_chat&includeSpecialOrigins=true&limit=25",
|
||||
);
|
||||
});
|
||||
|
||||
it("posts recovery action resolution to the source issue endpoint", async () => {
|
||||
await issuesApi.resolveRecoveryAction("issue-1", {
|
||||
actionId: "00000000-0000-0000-0000-0000000000aa",
|
||||
|
||||
@@ -56,6 +56,7 @@ export const issuesApi = {
|
||||
originId?: string;
|
||||
descendantOf?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
includeSpecialOrigins?: boolean;
|
||||
includeBlockedBy?: boolean;
|
||||
includeBlockedInboxAttention?: boolean;
|
||||
hasPlanDocument?: boolean;
|
||||
@@ -85,6 +86,7 @@ export const issuesApi = {
|
||||
if (filters?.originId) params.set("originId", filters.originId);
|
||||
if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf);
|
||||
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||
if (filters?.includeSpecialOrigins) params.set("includeSpecialOrigins", "true");
|
||||
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
|
||||
if (filters?.includeBlockedInboxAttention) params.set("includeBlockedInboxAttention", "true");
|
||||
if (filters?.hasPlanDocument !== undefined) {
|
||||
|
||||
@@ -112,6 +112,7 @@ const BOARD_ISSUE = {
|
||||
id: "issue-board",
|
||||
identifier: "PAP-1",
|
||||
title: "Board Operations",
|
||||
originKind: "board_chat",
|
||||
status: "in_progress",
|
||||
createdAt: "2026-06-10T00:00:00.000Z",
|
||||
updatedAt: "2026-06-10T00:00:00.000Z",
|
||||
@@ -120,6 +121,7 @@ const OLDER_BOARD_ISSUE = {
|
||||
id: "issue-board-old",
|
||||
identifier: "PAP-0",
|
||||
title: "Board Operations",
|
||||
originKind: "board_chat",
|
||||
status: "todo",
|
||||
createdAt: "2026-06-09T00:00:00.000Z",
|
||||
updatedAt: "2026-06-09T00:00:00.000Z",
|
||||
@@ -336,6 +338,24 @@ describe("BoardChat staged typing intro", () => {
|
||||
expect(mockChatComposerProps.at(-1)?.submitKey).toBe("mod-enter");
|
||||
});
|
||||
|
||||
it("loads Conference Room history by board_chat origin", async () => {
|
||||
await render();
|
||||
|
||||
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
originKind: "board_chat",
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
}));
|
||||
});
|
||||
|
||||
it("uses a friendly date label for legacy Board Operations history rows", async () => {
|
||||
mockIssuesApi.list.mockResolvedValue([OLDER_BOARD_ISSUE]);
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toMatch(/Chat from .*2026/);
|
||||
expect(container.textContent).not.toContain("PAP-0");
|
||||
});
|
||||
|
||||
it("starts a fresh server-side conversation after New chat is clicked", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
@@ -383,9 +403,13 @@ describe("BoardChat staged typing intro", () => {
|
||||
});
|
||||
|
||||
it("switches comment history when a prior chat is selected", async () => {
|
||||
mockIssuesApi.list.mockResolvedValue([BOARD_ISSUE, OLDER_BOARD_ISSUE]);
|
||||
const olderPlanningIssue = {
|
||||
...OLDER_BOARD_ISSUE,
|
||||
title: "Older planning chat",
|
||||
};
|
||||
mockIssuesApi.list.mockResolvedValue([BOARD_ISSUE, olderPlanningIssue]);
|
||||
mockIssuesApi.listComments.mockImplementation(async (issueId: string) =>
|
||||
issueId === OLDER_BOARD_ISSUE.id
|
||||
issueId === olderPlanningIssue.id
|
||||
? [{ ...USER_COMMENT, id: "comment-old", body: "Older chat" }]
|
||||
: [],
|
||||
);
|
||||
@@ -402,7 +426,7 @@ describe("BoardChat staged typing intro", () => {
|
||||
});
|
||||
|
||||
const olderButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes(OLDER_BOARD_ISSUE.identifier),
|
||||
(button) => button.textContent?.includes(olderPlanningIssue.title),
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(olderButton).toBeDefined();
|
||||
await act(async () => {
|
||||
@@ -412,7 +436,7 @@ describe("BoardChat staged typing intro", () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
expect(mockIssuesApi.listComments).toHaveBeenCalledWith(OLDER_BOARD_ISSUE.id);
|
||||
expect(mockIssuesApi.listComments).toHaveBeenCalledWith(olderPlanningIssue.id);
|
||||
expect(container.textContent).toContain("Older chat");
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from "../components/AgentBubbleActionRow";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import { cn, formatDateTime, relativeTime } from "../lib/utils";
|
||||
import type { FeedbackVoteValue } from "@paperclipai/shared";
|
||||
import type { FeedbackVoteValue, Issue } from "@paperclipai/shared";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -57,6 +57,7 @@ const DEFAULT_CHAT_FRACTION = 2 / 3;
|
||||
const BOARD_CHAT_MARKDOWN_CLASS =
|
||||
"max-w-full overflow-visible [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_table]:block [&_table]:max-w-full [&_table]:overflow-x-auto";
|
||||
const BOARD_CHAT_ISSUE_TITLE = "Board Operations";
|
||||
const BOARD_CHAT_ORIGIN_KIND = "board_chat";
|
||||
|
||||
const boardChatBubbleShell =
|
||||
"min-w-0 max-w-[85%] break-words px-3 py-2 text-sm overflow-x-auto overflow-y-visible";
|
||||
@@ -67,6 +68,26 @@ function agentInitials(name: string): string {
|
||||
return (((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase()) || "A";
|
||||
}
|
||||
|
||||
const boardChatHistoryDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
function formatBoardChatHistoryDate(value: Date | string | null | undefined): string {
|
||||
if (!value) return "recent chat";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "recent chat";
|
||||
return boardChatHistoryDateFormatter.format(date);
|
||||
}
|
||||
|
||||
function boardChatHistoryLabel(issue: Pick<Issue, "title" | "createdAt" | "updatedAt">): string {
|
||||
if (!issue.title || issue.title === BOARD_CHAT_ISSUE_TITLE) {
|
||||
return `Chat from ${formatBoardChatHistoryDate(issue.createdAt ?? issue.updatedAt)}`;
|
||||
}
|
||||
return issue.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon-adjacent-to-name header rendered directly above an agent bubble —
|
||||
* the shared `[agent icon][agent name]` convention (PAP-105 / PAP-97).
|
||||
@@ -314,10 +335,10 @@ export function BoardChat() {
|
||||
// company-scoped issues so history survives reloads without a separate chat
|
||||
// store.
|
||||
const { data: issues } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "board-chat-history"],
|
||||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
q: BOARD_CHAT_ISSUE_TITLE,
|
||||
originKind: BOARD_CHAT_ORIGIN_KIND,
|
||||
limit: 50,
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
@@ -330,7 +351,7 @@ export function BoardChat() {
|
||||
(issues ?? [])
|
||||
.filter(
|
||||
(i) =>
|
||||
i.title === BOARD_CHAT_ISSUE_TITLE &&
|
||||
i.originKind === BOARD_CHAT_ORIGIN_KIND &&
|
||||
i.status !== "cancelled",
|
||||
)
|
||||
.sort(
|
||||
@@ -884,6 +905,7 @@ export function BoardChat() {
|
||||
<div className="divide-y divide-border">
|
||||
{boardChatIssues.map((issue) => {
|
||||
const active = issue.id === boardIssueId && !isComposingNewConversation;
|
||||
const label = boardChatHistoryLabel(issue);
|
||||
return (
|
||||
<button
|
||||
key={issue.id}
|
||||
@@ -898,7 +920,7 @@ export function BoardChat() {
|
||||
<History className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
{issue.identifier ?? issue.title}
|
||||
{label}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
Updated {relativeTime(issue.updatedAt)}
|
||||
|
||||
Reference in new issue
Block a user