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 23840148146..b9185a2c374 100644
--- a/server/src/__tests__/board-chat-route-feature-flag.test.ts
+++ b/server/src/__tests__/board-chat-route-feature-flag.test.ts
@@ -96,6 +96,10 @@ describe("POST /api/board/chat/stream feature flag guard (PAP-137)", () => {
});
describe("board-chat client disconnect", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
function makeFakeProc() {
const proc = new EventEmitter() as any;
proc.stdout = new EventEmitter();
@@ -141,6 +145,42 @@ describe("board-chat client disconnect", () => {
fakeProc.emit("close", 143);
await pending;
});
+
+ it("tags newly created Board Operations issues as board chat conversations", async () => {
+ mockGetExperimental.mockResolvedValue({ enableConferenceRoomChat: true });
+ mockIssueService.list.mockResolvedValue([]);
+ mockIssueService.create.mockResolvedValue({ id: "issue-new" });
+ 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.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
+ title: "Board Operations",
+ originKind: "board_chat",
+ }));
+ expect(mockIssueService.addComment).toHaveBeenCalledWith(
+ "issue-new",
+ "hello",
+ expect.any(Object),
+ );
+ req.abort();
+ await pending;
+ });
});
describe("board-chat history role classification", () => {
diff --git a/server/src/routes/board-chat.ts b/server/src/routes/board-chat.ts
index 14f1fbf8fdc..1ec96d267b4 100644
--- a/server/src/routes/board-chat.ts
+++ b/server/src/routes/board-chat.ts
@@ -123,11 +123,13 @@ export function boardChatRoutes(
return;
}
- const { companyId, message, taskId } = req.body as {
+ const { companyId, message, taskId, newConversation } = req.body as {
companyId?: string;
message?: string;
taskId?: string;
+ newConversation?: boolean | string;
};
+ const wantsNewConversation = newConversation === true || newConversation === "true";
if (!companyId || !message) {
res.status(400).json({ error: "companyId and message are required" });
@@ -150,25 +152,33 @@ export function boardChatRoutes(
}
const issueSvc = issueService(db);
- let issueId = taskId;
+ let issueId = wantsNewConversation ? undefined : taskId;
// Find or create the standing "Board Operations" issue that anchors the
// board conversation + decision log.
if (!issueId) {
- const companyIssues = await issueSvc.list(companyId, { q: "Board Operations" });
- const boardIssue = companyIssues.find(
- (i) =>
- i.title === "Board Operations" &&
- i.status !== "done" &&
- i.status !== "cancelled",
- );
- if (boardIssue) {
- issueId = boardIssue.id;
- } else {
+ if (!wantsNewConversation) {
+ const companyIssues = await issueSvc.list(companyId, {
+ q: "Board Operations",
+ sortField: "updated",
+ sortDir: "desc",
+ });
+ const boardIssue = companyIssues.find(
+ (i) =>
+ i.title === "Board Operations" &&
+ i.status !== "done" &&
+ i.status !== "cancelled",
+ );
+ if (boardIssue) {
+ issueId = boardIssue.id;
+ }
+ }
+ if (!issueId) {
const created = await issueSvc.create(companyId, {
title: "Board Operations",
description:
"Standing issue for board concierge conversations and decision log",
+ originKind: "board_chat",
// `todo` rather than `in_progress`: this is an unassigned standing
// issue, and the service rejects in_progress issues without an
// assignee.
diff --git a/ui/src/pages/BoardChat.test.tsx b/ui/src/pages/BoardChat.test.tsx
index 22ff19e8006..e6daa5b9555 100644
--- a/ui/src/pages/BoardChat.test.tsx
+++ b/ui/src/pages/BoardChat.test.tsx
@@ -1,7 +1,8 @@
// @vitest-environment jsdom
-import { act, forwardRef, useImperativeHandle } from "react";
+import { forwardRef, useImperativeHandle } from "react";
import type { ReactNode } from "react";
+import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -73,12 +74,27 @@ vi.mock("@/components/ui/tooltip", () => ({
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ children }: { children: ReactNode }) =>
{children}
,
SheetTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
- SheetContent: () => null,
+ SheetContent: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ SheetHeader: ({ children }: { children: ReactNode }) => {children}
,
+ SheetTitle: ({ children }: { children: ReactNode }) => {children}
,
+ SheetDescription: ({ children }: { children: ReactNode }) => {children}
,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+async function act(callback: () => void | Promise) {
+ let result: void | Promise = undefined;
+ flushSync(() => {
+ result = callback();
+ });
+ await result;
+ await Promise.resolve();
+ flushSync(() => {});
+}
+
class ResizeObserverStub {
observe() {}
unobserve() {}
@@ -92,7 +108,22 @@ const CEO_AGENT = {
status: "active",
icon: null,
};
-const BOARD_ISSUE = { id: "issue-board", title: "Board Operations", status: "in_progress" };
+const BOARD_ISSUE = {
+ id: "issue-board",
+ identifier: "PAP-1",
+ title: "Board Operations",
+ status: "in_progress",
+ createdAt: "2026-06-10T00:00:00.000Z",
+ updatedAt: "2026-06-10T00:00:00.000Z",
+};
+const OLDER_BOARD_ISSUE = {
+ id: "issue-board-old",
+ identifier: "PAP-0",
+ title: "Board Operations",
+ status: "todo",
+ createdAt: "2026-06-09T00:00:00.000Z",
+ updatedAt: "2026-06-09T00:00:00.000Z",
+};
const USER_COMMENT = {
id: "comment-user-1",
body: "Hi Alex!",
@@ -304,4 +335,84 @@ describe("BoardChat staged typing intro", () => {
expect(mockChatComposerProps.at(-1)?.submitKey).toBe("mod-enter");
});
+
+ 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) => {
+ const body = new ReadableStream({
+ start(controller) {
+ controller.enqueue(
+ encoder.encode(
+ `data: ${JSON.stringify({ type: "start", issueId: "issue-new" })}\n\n` +
+ `data: ${JSON.stringify({ type: "done", issueId: "issue-new" })}\n\n`,
+ ),
+ );
+ controller.close();
+ },
+ });
+ return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" } });
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ await render();
+
+ const newChatButton = container.querySelector(
+ 'button[aria-label="new chat"]',
+ ) as HTMLButtonElement | null;
+ expect(newChatButton).not.toBeNull();
+
+ await act(async () => {
+ newChatButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await act(async () => {
+ (mockChatComposerProps.at(-1)?.onChange as (value: string) => void)("Fresh start");
+ });
+ await act(async () => {
+ (mockChatComposerProps.at(-1)?.onSubmit as () => void)();
+ });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
+ expect(body).toEqual({
+ companyId: "company-1",
+ message: "Fresh start",
+ newConversation: true,
+ });
+ });
+
+ it("switches comment history when a prior chat is selected", async () => {
+ mockIssuesApi.list.mockResolvedValue([BOARD_ISSUE, OLDER_BOARD_ISSUE]);
+ mockIssuesApi.listComments.mockImplementation(async (issueId: string) =>
+ issueId === OLDER_BOARD_ISSUE.id
+ ? [{ ...USER_COMMENT, id: "comment-old", body: "Older chat" }]
+ : [],
+ );
+ await render();
+
+ expect(mockIssuesApi.listComments).toHaveBeenCalledWith(BOARD_ISSUE.id);
+
+ const historyButton = container.querySelector(
+ 'button[aria-label="chat history"]',
+ ) as HTMLButtonElement | null;
+ expect(historyButton).not.toBeNull();
+ await act(async () => {
+ historyButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ const olderButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes(OLDER_BOARD_ISSUE.identifier),
+ ) as HTMLButtonElement | undefined;
+ expect(olderButton).toBeDefined();
+ await act(async () => {
+ olderButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+
+ expect(mockIssuesApi.listComments).toHaveBeenCalledWith(OLDER_BOARD_ISSUE.id);
+ expect(container.textContent).toContain("Older chat");
+ });
});
diff --git a/ui/src/pages/BoardChat.tsx b/ui/src/pages/BoardChat.tsx
index e956d27ea16..3059a443786 100644
--- a/ui/src/pages/BoardChat.tsx
+++ b/ui/src/pages/BoardChat.tsx
@@ -22,7 +22,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { Activity, ArrowDown, History, MessageSquarePlus, X } from "lucide-react";
+import { Activity, ArrowDown, History, MessageSquarePlus } from "lucide-react";
import { ActivityFeed } from "../components/ActivityFeed";
import { ChatComposer, type ChatComposerHandle } from "../components/ChatComposer";
import {
@@ -30,9 +30,16 @@ import {
agentBubbleDateLabel,
} from "../components/AgentBubbleActionRow";
import { AgentIcon } from "../components/AgentIconPicker";
-import { cn, formatDateTime } from "../lib/utils";
+import { cn, formatDateTime, relativeTime } from "../lib/utils";
import type { FeedbackVoteValue } from "@paperclipai/shared";
-import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
/**
* Board Concierge Chat โ a chat interface powered by the board-member skill.
@@ -49,6 +56,7 @@ const DEFAULT_CHAT_FRACTION = 2 / 3;
/** Wrapped markdown in bubbles; pre/table scroll horizontally when needed. */
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 boardChatBubbleShell =
"min-w-0 max-w-[85%] break-words px-3 py-2 text-sm overflow-x-auto overflow-y-visible";
@@ -190,6 +198,8 @@ export function BoardChat() {
const [statusText, setStatusText] = useState("");
const [errorText, setErrorText] = useState("");
const [boardIssueId, setBoardIssueId] = useState(null);
+ const [isComposingNewConversation, setIsComposingNewConversation] = useState(false);
+ const [historyOpen, setHistoryOpen] = useState(false);
const [elapsedSec, setElapsedSec] = useState(0);
const [optimisticMessage, setOptimisticMessage] = useState(null);
const messagesEndRef = useRef(null);
@@ -233,8 +243,11 @@ export function BoardChat() {
queryClient.removeQueries({ queryKey: queryKeys.issues.comments(boardIssueId) });
}
setBoardIssueId(null);
+ setIsComposingNewConversation(false);
+ setHistoryOpen(false);
setStreamingText("");
setStatusText("");
+ setErrorText("");
setSending(false);
setOptimisticMessage(null);
prevCompanyRef.current = selectedCompanyId;
@@ -297,29 +310,125 @@ export function BoardChat() {
return active?.title ?? null;
}, [goals]);
- // Find or detect the board operations issue
+ // Find or detect board chat conversations. They are backed by ordinary
+ // company-scoped issues so history survives reloads without a separate chat
+ // store.
const { data: issues } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!),
- queryFn: () => issuesApi.list(selectedCompanyId!),
+ queryFn: () =>
+ issuesApi.list(selectedCompanyId!, {
+ q: BOARD_CHAT_ISSUE_TITLE,
+ limit: 50,
+ sortField: "updated",
+ sortDir: "desc",
+ }),
enabled: !!selectedCompanyId,
});
+ const boardChatIssues = useMemo(
+ () =>
+ (issues ?? [])
+ .filter(
+ (i) =>
+ i.title === BOARD_CHAT_ISSUE_TITLE &&
+ i.status !== "cancelled",
+ )
+ .sort(
+ (a, b) =>
+ new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
+ ),
+ [issues],
+ );
+
useEffect(() => {
- if (!issues) {
- setBoardIssueId(null);
+ if (!issues || isComposingNewConversation) {
+ if (!issues) setBoardIssueId(null);
return;
}
- const boardIssue = issues.find(
- (i) => i.title === "Board Operations" && i.status !== "done" && i.status !== "cancelled",
- );
- setBoardIssueId(boardIssue?.id ?? null);
- }, [issues]);
+ if (boardIssueId && boardChatIssues.some((issue) => issue.id === boardIssueId)) {
+ return;
+ }
+ const boardIssue = boardChatIssues.find((i) => i.status !== "done");
+ setBoardIssueId(boardIssue?.id ?? boardChatIssues[0]?.id ?? null);
+ }, [issues, boardChatIssues, boardIssueId, isComposingNewConversation]);
- // Fetch comments for the board issue
+ const activeBoardIssue = useMemo(
+ () => boardChatIssues.find((issue) => issue.id === boardIssueId) ?? null,
+ [boardChatIssues, boardIssueId],
+ );
+
+ const scrollStorageKey = useMemo(
+ () =>
+ selectedCompanyId
+ ? `paperclip.boardChat.scrollTop.${selectedCompanyId}.${boardIssueId ?? "new"}`
+ : null,
+ [selectedCompanyId, boardIssueId],
+ );
+
+ const resetConversationViewport = useCallback(() => {
+ hasRestoredScrollRef.current = false;
+ wasNearBottomRef.current = true;
+ setHasNewBelow(false);
+ const scrollContainer = scrollContainerRef.current;
+ if (typeof scrollContainer?.scrollTo === "function") {
+ scrollContainer.scrollTo({ top: 0 });
+ } else if (scrollContainer) {
+ scrollContainer.scrollTop = 0;
+ }
+ }, []);
+
+ const handleNewChat = useCallback(() => {
+ if (sending) return;
+ if (boardIssueId) {
+ queryClient.removeQueries({ queryKey: queryKeys.issues.comments(boardIssueId) });
+ }
+ setBoardIssueId(null);
+ setIsComposingNewConversation(true);
+ setHistoryOpen(false);
+ setStreamingText("");
+ setStatusText("");
+ setErrorText("");
+ setOptimisticMessage(null);
+ setWelcomeRevealed(false);
+ setChipsRevealed(false);
+ setInput("");
+ resetConversationViewport();
+ composerRef.current?.focus();
+ }, [boardIssueId, queryClient, resetConversationViewport, sending]);
+
+ const handleSelectConversation = useCallback(
+ (issueId: string) => {
+ if (sending) return;
+ setBoardIssueId(issueId);
+ setIsComposingNewConversation(false);
+ setHistoryOpen(false);
+ setStreamingText("");
+ setStatusText("");
+ setErrorText("");
+ setOptimisticMessage(null);
+ resetConversationViewport();
+ },
+ [resetConversationViewport, sending],
+ );
+
+ useEffect(() => {
+ resetConversationViewport();
+ }, [boardIssueId, resetConversationViewport]);
+
+ useEffect(() => {
+ if (!selectedCompanyId || boardIssueId || isComposingNewConversation) return;
+ if (!issues) return;
+ if (boardChatIssues.length > 0) return;
+ setIsComposingNewConversation(true);
+ }, [selectedCompanyId, boardIssueId, isComposingNewConversation, issues, boardChatIssues.length]);
+
+ // Fetch comments for the selected board issue. New Chat intentionally has
+ // no issue id until the first message is sent, so it renders as an empty
+ // conversation instead of snapping back to the latest historical issue.
const { data: comments } = useQuery({
queryKey: queryKeys.issues.comments(boardIssueId ?? ""),
queryFn: () => issuesApi.listComments(boardIssueId!),
- enabled: !!boardIssueId,
+ enabled: !!boardIssueId && !isComposingNewConversation,
refetchInterval: 3000,
});
@@ -338,7 +447,7 @@ export function BoardChat() {
const { data: feedbackVotes } = useQuery({
queryKey: queryKeys.issues.feedbackVotes(boardIssueId ?? ""),
queryFn: () => issuesApi.listFeedbackVotes(boardIssueId!),
- enabled: !!boardIssueId,
+ enabled: !!boardIssueId && !isComposingNewConversation,
});
const voteByComment = useMemo(() => {
@@ -355,7 +464,7 @@ export function BoardChat() {
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => {
- if (!boardIssueId) return;
+ if (!boardIssueId || isComposingNewConversation) return;
await issuesApi.upsertFeedbackVote(boardIssueId, {
targetType: "issue_comment",
targetId: commentId,
@@ -367,7 +476,7 @@ export function BoardChat() {
queryKey: queryKeys.issues.feedbackVotes(boardIssueId),
});
},
- [boardIssueId, queryClient],
+ [boardIssueId, isComposingNewConversation, queryClient],
);
// Reset the staged reveal on mount AND whenever the active company
@@ -460,7 +569,8 @@ export function BoardChat() {
if (!container) return;
try {
- const saved = sessionStorage.getItem("paperclip.boardChat.scrollTop");
+ if (!scrollStorageKey) return;
+ const saved = sessionStorage.getItem(scrollStorageKey);
if (saved != null) {
const parsed = Number(saved);
if (Number.isFinite(parsed)) {
@@ -473,7 +583,7 @@ export function BoardChat() {
container.scrollTop = container.scrollHeight;
hasRestoredScrollRef.current = true;
- }, [sortedComments.length]);
+ }, [sortedComments.length, scrollStorageKey]);
// User sent a message: always scroll so their just-typed message is in
// view, even if they were scrolled up reading history.
@@ -510,10 +620,9 @@ export function BoardChat() {
rafId = requestAnimationFrame(() => {
rafId = null;
try {
- sessionStorage.setItem(
- "paperclip.boardChat.scrollTop",
- String(container.scrollTop),
- );
+ if (scrollStorageKey) {
+ sessionStorage.setItem(scrollStorageKey, String(container.scrollTop));
+ }
} catch { /* sessionStorage unavailable */ }
});
};
@@ -522,7 +631,7 @@ export function BoardChat() {
container.removeEventListener("scroll", handleScroll);
if (rafId != null) cancelAnimationFrame(rafId);
};
- }, []);
+ }, [scrollStorageKey]);
// Elapsed timer for thinking state โ tick at 100ms so the tenths place
// updates smoothly and the wait feels quicker than a whole-second counter.
@@ -567,7 +676,8 @@ export function BoardChat() {
body: JSON.stringify({
companyId: selectedCompanyId,
message: trimmed,
- taskId: boardIssueId ?? undefined,
+ taskId: isComposingNewConversation ? undefined : boardIssueId ?? undefined,
+ newConversation: isComposingNewConversation,
}),
signal: controller.signal,
});
@@ -608,6 +718,7 @@ export function BoardChat() {
setStatusText(event.text);
} else if (event.type === "start" && event.issueId) {
setBoardIssueId(event.issueId);
+ setIsComposingNewConversation(false);
} else if (event.type === "error") {
setErrorText(
event.message ||
@@ -648,7 +759,7 @@ export function BoardChat() {
composerRef.current?.focus();
}
},
- [sending, selectedCompanyId, boardIssueId, queryClient],
+ [sending, selectedCompanyId, boardIssueId, isComposingNewConversation, queryClient],
);
const handleSend = useCallback(() => {
@@ -702,7 +813,11 @@ export function BoardChat() {
{ceoAgent?.name ?? "Conference Room"}
- {selectedCompany?.name ?? "Your company"}
+ {isComposingNewConversation
+ ? "New chat"
+ : activeBoardIssue
+ ? `${selectedCompany?.name ?? "Your company"} ยท updated ${relativeTime(activeBoardIssue.updatedAt)}`
+ : selectedCompany?.name ?? "Your company"}
@@ -714,6 +829,7 @@ export function BoardChat() {
size="icon-sm"
className="text-muted-foreground"
aria-label="chat history"
+ onClick={() => setHistoryOpen(true)}
>
@@ -728,6 +844,8 @@ export function BoardChat() {
size="icon-sm"
className="text-muted-foreground"
aria-label="new chat"
+ onClick={handleNewChat}
+ disabled={sending || isComposingNewConversation}
>
@@ -736,6 +854,64 @@ export function BoardChat() {
+
+
+
+ Chat history
+
+ Pick up a previous CEO conversation.
+
+
+
+
+
+
+ {boardChatIssues.length === 0 ? (
+
+ No previous chats yet.
+
+ ) : (
+
+ {boardChatIssues.map((issue) => {
+ const active = issue.id === boardIssueId && !isComposingNewConversation;
+ return (
+
+ );
+ })}
+
+ )}
+
+
+
{/* Messages โ scroll viewport flush right so the scrollbar sits on the pane/divider edge */}