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

+8
View File
@@ -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];
+3
View File
@@ -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);
+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);
+12
View File
@@ -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",
+2
View File
@@ -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) {
+28 -4
View File
@@ -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");
});
});
+27 -5
View File
@@ -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)}