Fix board chat history controls

This commit is contained in:
Dotta committed 2026-06-16 10:02:04 +00:00
1 parent 97ac44664b
commit ee7cb3141e
4 files changed
+379 -42

No files matched your search

@@ -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", () => {
+22 -12
View File
@@ -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.
+114 -3
View File
@@ -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 }) => <div>{children}</div>,
SheetTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
SheetContent: () => null,
SheetContent: ({ children }: { children: ReactNode }) => (
<div data-testid="sheet-content">{children}</div>
),
SheetHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SheetDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = 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");
});
});
+203 -27
View File
@@ -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<string | null>(null);
const [isComposingNewConversation, setIsComposingNewConversation] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [elapsedSec, setElapsedSec] = useState(0);
const [optimisticMessage, setOptimisticMessage] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(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"}
</h3>
<p className="text-xs text-muted-foreground">
{selectedCompany?.name ?? "Your company"}
{isComposingNewConversation
? "New chat"
: activeBoardIssue
? `${selectedCompany?.name ?? "Your company"} · updated ${relativeTime(activeBoardIssue.updatedAt)}`
: selectedCompany?.name ?? "Your company"}
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
@@ -714,6 +829,7 @@ export function BoardChat() {
size="icon-sm"
className="text-muted-foreground"
aria-label="chat history"
onClick={() => setHistoryOpen(true)}
>
<History className="h-4 w-4" />
</Button>
@@ -728,6 +844,8 @@ export function BoardChat() {
size="icon-sm"
className="text-muted-foreground"
aria-label="new chat"
onClick={handleNewChat}
disabled={sending || isComposingNewConversation}
>
<MessageSquarePlus className="h-4 w-4" />
</Button>
@@ -736,6 +854,64 @@ export function BoardChat() {
</Tooltip>
</div>
</div>
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
<SheetContent side="right" className="w-[min(24rem,100vw)] p-0 sm:max-w-md">
<SheetHeader className="border-b px-4 py-3">
<SheetTitle className="text-sm">Chat history</SheetTitle>
<SheetDescription>
Pick up a previous CEO conversation.
</SheetDescription>
</SheetHeader>
<div className="min-h-0 overflow-y-auto py-2">
<div className="px-3 pb-2">
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-start"
onClick={handleNewChat}
disabled={sending || isComposingNewConversation}
>
<MessageSquarePlus className="h-4 w-4" />
New chat
</Button>
</div>
{boardChatIssues.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No previous chats yet.
</div>
) : (
<div className="divide-y divide-border">
{boardChatIssues.map((issue) => {
const active = issue.id === boardIssueId && !isComposingNewConversation;
return (
<button
key={issue.id}
type="button"
className={cn(
"flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent",
active && "bg-accent/70",
)}
onClick={() => handleSelectConversation(issue.id)}
disabled={sending || active}
>
<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}
</span>
<span className="block truncate text-xs text-muted-foreground">
Updated {relativeTime(issue.updatedAt)}
</span>
</span>
</button>
);
})}
</div>
)}
</div>
</SheetContent>
</Sheet>
{/* Messages — scroll viewport flush right so the scrollbar sits on the pane/divider edge */}
<div className="relative min-h-0 min-w-0 flex-1">
<div