diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 2c873329eb1..9df32e41d66 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 60f66917e9e..76ba039ae66 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/server/src/__tests__/board-chat-route-feature-flag.test.ts b/server/src/__tests__/board-chat-route-feature-flag.test.ts index b9185a2c374..2a6c18c6bd8 100644 --- a/server/src/__tests__/board-chat-route-feature-flag.test.ts +++ b/server/src/__tests__/board-chat-route-feature-flag.test.ts @@ -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", () => { diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 3ef21b87c59..a39dba46c00 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -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); diff --git a/server/src/routes/board-chat.ts b/server/src/routes/board-chat.ts index 1ec96d267b4..87fe709f95b 100644 --- a/server/src/routes/board-chat.ts +++ b/server/src/routes/board-chat.ts @@ -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. diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 75da8f590b3..805818e8cb4 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index c61132e3174..8f8c1af0ebb 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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`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); diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index 91f736f6688..88d7c363834 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -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", diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 4fc2e7f58de..6ef21e25514 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -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) { diff --git a/ui/src/pages/BoardChat.test.tsx b/ui/src/pages/BoardChat.test.tsx index e6daa5b9555..1ae9ba316b7 100644 --- a/ui/src/pages/BoardChat.test.tsx +++ b/ui/src/pages/BoardChat.test.tsx @@ -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"); }); }); diff --git a/ui/src/pages/BoardChat.tsx b/ui/src/pages/BoardChat.tsx index 3059a443786..26f48e3a6ea 100644 --- a/ui/src/pages/BoardChat.tsx +++ b/ui/src/pages/BoardChat.tsx @@ -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): 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() {
{boardChatIssues.map((issue) => { const active = issue.id === boardIssueId && !isComposingNewConversation; + const label = boardChatHistoryLabel(issue); return (