From ee7cb3141ec12c5730e4c0fc087b9000224247bd Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 14 Jun 2026 12:07:22 +0000 Subject: [PATCH] Fix board chat history controls --- .../board-chat-route-feature-flag.test.ts | 40 +++ server/src/routes/board-chat.ts | 34 ++- ui/src/pages/BoardChat.test.tsx | 117 ++++++++- ui/src/pages/BoardChat.tsx | 230 ++++++++++++++++-- 4 files changed, 379 insertions(+), 42 deletions(-) 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 */}