Wire selected-agent chat into the live Conference Room

PAP-11123 / PAP-11099 Phase 3b. The Conference Room now runs a real-agent
conversation over the reusable SelectedAgentChat surface (default target =
company CEO) instead of the board-concierge SSE relay.

Backend: add POST /api/board/chat/conversations to resolve-or-mint the
backing board_chat issue without spawning the concierge subprocess (the web
client can't mint it directly — originKind is stripped by the create schema).
Gated like /board/chat/stream on the experimental flag + instance admin +
company access. Factor the resolve-or-create logic into a shared helper reused
by the stream relay.

UI: BoardChat resolves/mints the board issue and renders the connected
SelectedAgentChat for the room's center column; the history Sheet, new-chat
control, and split-pane ActivityFeed are kept. Durable history is the issue's
comments and live output is the target agent's active run (real reply comment
surfaced by polling) — the streaming-token bubble is gone.

Tests: new server unit test mirroring the selected-agent-chat route test;
BoardChat.test.tsx reworked for the new transport (SSE-stream + staged-intro
assertions dropped; shell/history kept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 4.8 committed 2026-06-16 10:02:04 +00:00
1 parent 8eda59d9a9
commit 319aa27675
6 files changed
+530 -1172

No files matched your search

@@ -0,0 +1,146 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockGetExperimental = vi.hoisted(() => vi.fn());
const mockIssueService = vi.hoisted(() => ({
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
}));
const mockAssertInstanceAdmin = vi.hoisted(() => vi.fn());
const mockAssertCompanyAccess = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
instanceSettingsService: () => ({ getExperimental: mockGetExperimental }),
issueService: () => mockIssueService,
}));
vi.mock("../routes/authz.js", () => ({
getActorInfo: () => ({ actorId: "user-1", agentId: null, runId: null }),
assertCompanyAccess: mockAssertCompanyAccess,
assertInstanceAdmin: mockAssertInstanceAdmin,
}));
async function createApp(
deploymentMode: "local_trusted" | "authenticated" = "local_trusted",
deploymentExposure: "private" | "public" = "private",
) {
const { boardChatRoutes } = await import("../routes/board-chat.js");
const app = express();
app.use(express.json());
app.use("/api", boardChatRoutes({} as any, { deploymentMode, deploymentExposure }));
return app;
}
const OPEN_BOARD_ISSUE = {
id: "issue-open",
title: "How is hiring going?",
originKind: "board_chat",
status: "todo",
assigneeAgentId: null,
assigneeUserId: null,
};
describe("POST /api/board/chat/conversations (PAP-11123)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetExperimental.mockResolvedValue({ enableConferenceRoomChat: true });
mockIssueService.list.mockResolvedValue([]);
mockIssueService.create.mockResolvedValue({ id: "issue-new", originKind: "board_chat" });
mockIssueService.update.mockResolvedValue(undefined);
});
it("returns 403 FEATURE_DISABLED when enableConferenceRoomChat is off", async () => {
mockGetExperimental.mockResolvedValue({ enableConferenceRoomChat: false });
const app = await createApp();
const res = await request(app)
.post("/api/board/chat/conversations")
.send({ companyId: "company-1" });
expect(res.status).toBe(403);
expect(res.body).toEqual({
error: "Conference Room Chat is not enabled",
code: "FEATURE_DISABLED",
});
// The guard must fire before any authz check or persistence.
expect(mockAssertInstanceAdmin).not.toHaveBeenCalled();
expect(mockIssueService.create).not.toHaveBeenCalled();
});
it("returns 400 when companyId is missing", async () => {
const app = await createApp();
const res = await request(app).post("/api/board/chat/conversations").send({});
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "companyId is required" });
expect(mockIssueService.create).not.toHaveBeenCalled();
});
it("enforces instance-admin + company access before resolving", async () => {
const app = await createApp();
await request(app)
.post("/api/board/chat/conversations")
.send({ companyId: "company-1" });
expect(mockAssertInstanceAdmin).toHaveBeenCalledTimes(1);
expect(mockAssertCompanyAccess).toHaveBeenCalledWith(expect.anything(), "company-1");
});
it("reuses the most-recent open board_chat issue without creating", async () => {
mockIssueService.list.mockResolvedValue([OPEN_BOARD_ISSUE]);
const app = await createApp();
const res = await request(app)
.post("/api/board/chat/conversations")
.send({ companyId: "company-1" });
expect(res.status).toBe(200);
expect(res.body.issue.id).toBe("issue-open");
expect(mockIssueService.list).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ originKind: "board_chat" }),
);
expect(mockIssueService.create).not.toHaveBeenCalled();
});
it("mints an origin-tagged todo issue when no conversation is open", async () => {
const app = await createApp();
const res = await request(app)
.post("/api/board/chat/conversations")
.send({ companyId: "company-1" });
expect(res.status).toBe(200);
expect(res.body.issue.id).toBe("issue-new");
expect(mockIssueService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
originKind: "board_chat",
status: "todo",
title: "New chat",
}),
);
});
it("forces a fresh conversation when newConversation is set, even with an open one", async () => {
mockIssueService.list.mockResolvedValue([OPEN_BOARD_ISSUE]);
const app = await createApp();
const res = await request(app)
.post("/api/board/chat/conversations")
.send({ companyId: "company-1", newConversation: true });
expect(res.status).toBe(200);
expect(res.body.issue.id).toBe("issue-new");
// newConversation skips reuse — no list lookup, straight to create.
expect(mockIssueService.list).not.toHaveBeenCalled();
expect(mockIssueService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ originKind: "board_chat" }),
);
});
});
+98 -40
View File
@@ -94,6 +94,61 @@ function isLegacyBoardChatIssue(issue: {
);
}
/**
* Resolve the standing `board_chat` conversation issue for a company, creating
* one (origin-tagged) if none is open. Shared by `/board/chat/stream` (the
* legacy concierge relay) and `/board/chat/conversations` (the real-agent
* Conference Room, PAP-11099): both need a backing issue the web client cannot
* mint itself, since `createIssueBaseSchema` strips `originKind`.
*
* - `wantsNewConversation` skips reuse and always creates a fresh conversation
* (the room's "New chat" control).
* - Otherwise reuse the most-recent open origin-tagged issue, then adopt and
* repair a legacy "Board Operations" issue, then create.
* - `message`, when present, seeds a first-message title; absent (the room
* mints before the first message), it falls back to "New chat".
*/
async function resolveOrCreateBoardChatIssue(
issueSvc: ReturnType<typeof issueService>,
companyId: string,
opts: { message?: string; wantsNewConversation?: boolean },
): Promise<{ id: string }> {
if (!opts.wantsNewConversation) {
const boardChatIssues = await issueSvc.list(companyId, {
originKind: BOARD_CHAT_ORIGIN_KIND,
sortField: "updated",
sortDir: "desc",
});
const boardIssue = boardChatIssues.find(isOpenBoardChatIssue);
if (boardIssue) return boardIssue;
const legacyIssues = await issueSvc.list(companyId, {
q: LEGACY_BOARD_CHAT_TITLE,
sortField: "updated",
sortDir: "desc",
});
const legacyIssue = legacyIssues.find(isLegacyBoardChatIssue);
if (legacyIssue) {
try {
await issueSvc.update(legacyIssue.id, { originKind: BOARD_CHAT_ORIGIN_KIND });
} catch {
/* best-effort legacy repair; the selected issue still anchors this request */
}
return legacyIssue;
}
}
return issueSvc.create(companyId, {
title: opts.message ? deriveBoardChatIssueTitle(opts.message) : "New chat",
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.
status: "todo",
priority: "medium",
});
}
export function boardChatRoutes(
db: Db,
opts: { deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure },
@@ -191,46 +246,11 @@ export function boardChatRoutes(
// below is intentionally narrow so unrelated "Board Operations" tasks are
// not silently pulled into Conference Room history.
if (!issueId) {
if (!wantsNewConversation) {
const boardChatIssues = await issueSvc.list(companyId, {
originKind: BOARD_CHAT_ORIGIN_KIND,
sortField: "updated",
sortDir: "desc",
});
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: 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.
status: "todo",
priority: "medium",
});
issueId = created.id;
}
const issue = await resolveOrCreateBoardChatIssue(issueSvc, companyId, {
message,
wantsNewConversation,
});
issueId = issue.id;
}
const resolvedIssueId = issueId!;
@@ -456,5 +476,43 @@ export function boardChatRoutes(
proc.stdin.end();
});
// Mint (or resolve) the backing `board_chat` conversation issue WITHOUT
// spawning the concierge subprocess. The live Conference Room (PAP-11099
// Phase 3b) renders the real-agent `SelectedAgentChat` over this issue;
// the web client can't create it directly because `originKind` is stripped
// by the issue create schema. Gated like `/board/chat/stream` on the
// experimental flag + instance admin + company access — but with no
// deployment-mode restriction, since nothing is spawned here.
router.post("/board/chat/conversations", async (req, res) => {
const experimental = await instanceSettingsService(db).getExperimental();
if (experimental.enableConferenceRoomChat !== true) {
res.status(403).json({
error: "Conference Room Chat is not enabled",
code: "FEATURE_DISABLED",
});
return;
}
const { companyId, newConversation, message } = req.body as {
companyId?: string;
newConversation?: boolean | string;
message?: string;
};
if (!companyId) {
res.status(400).json({ error: "companyId is required" });
return;
}
assertInstanceAdmin(req);
assertCompanyAccess(req, companyId);
const wantsNewConversation = newConversation === true || newConversation === "true";
const issue = await resolveOrCreateBoardChatIssue(issueService(db), companyId, {
message,
wantsNewConversation,
});
res.status(200).json({ issue });
});
return router;
}
+18
View File
@@ -2494,6 +2494,24 @@ registry.registerPath({
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
});
registry.registerPath({
method: "post",
path: "/api/board/chat/conversations",
tags: ["instance"],
summary:
"Resolve or mint the Conference Room board_chat conversation issue (requires enableConferenceRoomChat)",
request: {
body: jsonBody(
z.object({
companyId: z.string(),
newConversation: z.boolean().optional(),
message: z.string().optional(),
}),
),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
});
// ─── Access / invites / members ───────────────────────────────────────────────
registry.registerPath({
+25
View File
@@ -0,0 +1,25 @@
import type { Issue } from "@paperclipai/shared";
import { api } from "./client";
/**
* Conference Room (board chat) transport. The room is backed by an ordinary
* company-scoped `board_chat` issue so history survives reloads; the real
* agent conversation runs over `SelectedAgentChat` (PAP-11099 Phase 3b).
*/
export const boardChatApi = {
/**
* Resolve-or-create the standing `board_chat` conversation issue for a
* company and return it. The web client can't mint the issue itself because
* `originKind` is stripped by the issue create schema, so the server owns
* this. `newConversation: true` forces a fresh conversation (the room's
* "New chat" control). Backend: `POST /api/board/chat/conversations`.
*/
resolveConversation: (
companyId: string,
options?: { newConversation?: boolean },
) =>
api.post<{ issue: Issue }>("/board/chat/conversations", {
companyId,
...(options?.newConversation ? { newConversation: true } : {}),
}),
};
+105 -272
View File
@@ -1,6 +1,5 @@
// @vitest-environment jsdom
import { forwardRef, useImperativeHandle } from "react";
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
@@ -9,26 +8,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BoardChat } from "./BoardChat";
/**
* Regression coverage for the post-wizard Conference Room intro (PAP-134,
* plan: PAP-133 A+B): a fresh mount shows the three-dot typing bubble for
* ~2s, then the CEO welcome, then the suggestion chips ~700ms later. The
* staged reveal must hold while the onboarding wizard overlay is open or
* the tab is hidden, and must fast-forward when the user already replied.
* Conference Room transport coverage (PAP-11123). The room is backed by a
* `board_chat` issue and the real-agent conversation runs over
* SelectedAgentChat — so the page resolves/mints the backing issue, surfaces
* history, and hands the resolved issue + CEO default target to the chat
* surface. The legacy SSE-stream transport assertions are gone.
*/
const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockGoalsApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
listComments: vi.fn(),
listFeedbackVotes: vi.fn(),
}));
const mockDialogState = vi.hoisted(() => ({ onboardingOpen: false }));
const mockChatComposerProps = vi.hoisted(() => [] as Array<Record<string, unknown>>);
const mockIssuesApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() }));
const mockBoardChatApi = vi.hoisted(() => ({ resolveConversation: vi.fn() }));
const mockSelectedAgentChatProps = vi.hoisted(() => [] as Array<Record<string, unknown>>);
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("../api/goals", () => ({ goalsApi: mockGoalsApi }));
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("../api/auth", () => ({ authApi: mockAuthApi }));
vi.mock("../api/boardChat", () => ({ boardChatApi: mockBoardChatApi }));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
@@ -41,30 +37,21 @@ vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("../context/DialogContext", () => ({
useDialogState: () => ({ onboardingOpen: mockDialogState.onboardingOpen }),
}));
// Heavy children that are irrelevant to the staged intro.
// Heavy children irrelevant to the transport.
vi.mock("../components/ActivityFeed", () => ({
ActivityFeed: () => <div data-testid="activity-feed" />,
}));
vi.mock("../components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("../components/ChatComposer", () => ({
ChatComposer: forwardRef((props: Record<string, unknown>, ref) => {
mockChatComposerProps.push(props);
useImperativeHandle(ref, () => ({ focus: vi.fn() }));
return <div data-testid="chat-composer" />;
}),
}));
vi.mock("../components/AgentBubbleActionRow", () => ({
AgentBubbleActionRow: () => null,
agentBubbleDateLabel: () => "",
}));
vi.mock("../components/AgentIconPicker", () => ({
AgentIcon: () => null,
vi.mock("../components/SelectedAgentChat", () => ({
SelectedAgentChat: (props: Record<string, unknown>) => {
mockSelectedAgentChatProps.push(props);
return (
<div
data-testid="selected-agent-chat"
data-issue-id={String(props.issueId)}
data-default-target={String(props.defaultTargetAgentId)}
/>
);
},
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
@@ -111,13 +98,13 @@ const CEO_AGENT = {
const BOARD_ISSUE = {
id: "issue-board",
identifier: "PAP-1",
title: "Board Operations",
title: "How is hiring going?",
originKind: "board_chat",
status: "in_progress",
createdAt: "2026-06-10T00:00:00.000Z",
updatedAt: "2026-06-10T00:00:00.000Z",
};
const OLDER_BOARD_ISSUE = {
const LEGACY_BOARD_ISSUE = {
id: "issue-board-old",
identifier: "PAP-0",
title: "Board Operations",
@@ -126,44 +113,25 @@ const OLDER_BOARD_ISSUE = {
createdAt: "2026-06-09T00:00:00.000Z",
updatedAt: "2026-06-09T00:00:00.000Z",
};
const USER_COMMENT = {
id: "comment-user-1",
body: "Hi Alex!",
authorAgentId: null,
authorUserId: "user-1",
createdAt: "2026-06-10T00:00:00.000Z",
};
function hasTypingDots(container: HTMLElement) {
return container.querySelectorAll(".typing-dots").length > 0;
}
function hasWelcome(container: HTMLElement) {
return (container.textContent ?? "").includes("Welcome to");
}
function hasChips(container: HTMLElement) {
return (container.textContent ?? "").includes("Draft a Company Brief");
}
describe("BoardChat staged typing intro", () => {
describe("BoardChat Conference Room transport", () => {
let container: HTMLDivElement;
let root: Root | null = null;
let queryClient: QueryClient | null = null;
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal("ResizeObserver", ResizeObserverStub);
container = document.createElement("div");
document.body.appendChild(container);
mockDialogState.onboardingOpen = false;
mockAgentsApi.list.mockResolvedValue([CEO_AGENT]);
mockGoalsApi.list.mockResolvedValue([
{ id: "goal-1", title: "Build affordable robots", status: "active" },
]);
mockIssuesApi.list.mockResolvedValue([BOARD_ISSUE]);
mockIssuesApi.listComments.mockResolvedValue([]);
mockIssuesApi.listFeedbackVotes.mockResolvedValue([]);
mockChatComposerProps.length = 0;
mockAuthApi.getSession.mockResolvedValue({
session: { id: "sess-1", userId: "user-1" },
user: { id: "user-1", name: "Board" },
});
mockBoardChatApi.resolveConversation.mockResolvedValue({ issue: BOARD_ISSUE });
mockSelectedAgentChatProps.length = 0;
});
afterEach(async () => {
@@ -172,20 +140,12 @@ describe("BoardChat staged typing intro", () => {
});
root = null;
container.remove();
sessionStorage.clear();
vi.unstubAllGlobals();
vi.useRealTimers();
vi.clearAllMocks();
// Drop any per-test document.visibilityState override.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (document as any).visibilityState;
});
let queryClient: QueryClient | null = null;
function buildElement() {
// A fresh element every time — rendering an identical element reference
// lets React bail out of re-rendering, which would hide mock-state flips.
return (
<QueryClientProvider client={queryClient!}>
<BoardChat />
@@ -201,108 +161,97 @@ describe("BoardChat staged typing intro", () => {
await act(async () => {
root!.render(buildElement());
});
// Let the agent/goal/issue queries resolve, plus the follow-up render
// that enables the comments query off boardIssueId. react-query batches
// notifications through zero-delay timers, so flush those too.
for (let i = 0; i < 6; i++) {
// Flush the agent/session/issue queries plus follow-up effect renders.
for (let i = 0; i < 8; i++) {
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
}
}
/** Re-render the existing tree so hooks re-read mutated mock state. */
async function rerender() {
await act(async () => {
root!.render(buildElement());
});
await act(async () => {
await Promise.resolve();
});
}
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
}
it("reveals typing dots, then the welcome at 2s, then chips at +700ms", async () => {
it("loads Conference Room history by board_chat origin", async () => {
await render();
// Fresh mount: dots only.
expect(hasTypingDots(container)).toBe(true);
expect(hasWelcome(container)).toBe(false);
// Just before the reveal: still dots.
await advance(1900);
expect(hasTypingDots(container)).toBe(true);
expect(hasWelcome(container)).toBe(false);
// t=2s: welcome bubble lands, dots leave, chips not yet.
await advance(100);
expect(hasWelcome(container)).toBe(true);
expect(hasTypingDots(container)).toBe(false);
expect(hasChips(container)).toBe(false);
// t=2.7s: chips stage in.
await advance(700);
expect(hasChips(container)).toBe(true);
expect(mockIssuesApi.list).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
originKind: "board_chat",
sortField: "updated",
sortDir: "desc",
}),
);
});
it("skips the staged reveal when a user comment already exists", async () => {
mockIssuesApi.listComments.mockResolvedValue([USER_COMMENT]);
it("renders SelectedAgentChat over the resolved board issue with the CEO as default target", async () => {
await render();
// Fast-forwarded: no dots, welcome immediately, no timer needed.
expect(hasTypingDots(container)).toBe(false);
expect(hasWelcome(container)).toBe(true);
const surface = container.querySelector(
'[data-testid="selected-agent-chat"]',
) as HTMLDivElement | null;
expect(surface).not.toBeNull();
expect(surface?.getAttribute("data-issue-id")).toBe(BOARD_ISSUE.id);
expect(surface?.getAttribute("data-default-target")).toBe(CEO_AGENT.id);
const lastProps = mockSelectedAgentChatProps.at(-1);
expect(lastProps?.companyId).toBe("company-1");
expect(lastProps?.currentUserId).toBe("user-1");
});
it("holds the dots while the onboarding wizard overlay is open (PAP-134)", async () => {
mockDialogState.onboardingOpen = true;
await render();
// The 2s window must not burn behind the wizard overlay.
await advance(2500);
expect(hasTypingDots(container)).toBe(true);
expect(hasWelcome(container)).toBe(false);
// Wizard closes → reveal timer starts fresh.
mockDialogState.onboardingOpen = false;
await rerender();
await advance(2000);
expect(hasWelcome(container)).toBe(true);
expect(hasTypingDots(container)).toBe(false);
});
it("holds the dots while the document is hidden (PAP-134)", async () => {
let visibility: DocumentVisibilityState = "hidden";
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => visibility,
it("mints the first conversation when the company has no history", async () => {
mockIssuesApi.list.mockResolvedValue([]);
mockBoardChatApi.resolveConversation.mockResolvedValue({
issue: { ...BOARD_ISSUE, id: "issue-minted", title: "New chat" },
});
await render();
// The 2s window must not burn while the tab is hidden.
await advance(2500);
expect(hasTypingDots(container)).toBe(true);
expect(hasWelcome(container)).toBe(false);
// Tab becomes visible → reveal timer starts fresh.
visibility = "visible";
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
await advance(2000);
expect(hasWelcome(container)).toBe(true);
expect(hasTypingDots(container)).toBe(false);
expect(mockBoardChatApi.resolveConversation).toHaveBeenCalledWith(
"company-1",
undefined,
);
const surface = container.querySelector(
'[data-testid="selected-agent-chat"]',
) as HTMLDivElement | null;
expect(surface?.getAttribute("data-issue-id")).toBe("issue-minted");
});
it("reserves mobile viewport height and bottom-nav space for the composer", async () => {
it("forces a fresh conversation when New chat is clicked", async () => {
mockBoardChatApi.resolveConversation.mockResolvedValue({
issue: { ...BOARD_ISSUE, id: "issue-fresh", title: "New chat" },
});
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 }));
});
for (let i = 0; i < 4; i++) {
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
}
expect(mockBoardChatApi.resolveConversation).toHaveBeenCalledWith("company-1", {
newConversation: true,
});
const surface = container.querySelector(
'[data-testid="selected-agent-chat"]',
) as HTMLDivElement | null;
expect(surface?.getAttribute("data-issue-id")).toBe("issue-fresh");
});
it("uses a friendly date label for legacy Board Operations history rows", async () => {
mockIssuesApi.list.mockResolvedValue([LEGACY_BOARD_ISSUE]);
await render();
expect(container.textContent).toMatch(/Chat from .*2026/);
expect(container.textContent).not.toContain("PAP-0");
});
it("reserves mobile viewport height and bottom-nav space for the agent feed", async () => {
await render();
const shell = container.querySelector(
@@ -315,14 +264,6 @@ describe("BoardChat staged typing intro", () => {
expect(shell?.className).toContain("-m-4");
expect(shell?.className).toContain("md:h-[calc(100%_+_3rem)]");
const dock = container.querySelector(
'[data-testid="board-chat-composer-dock"]',
) as HTMLDivElement | null;
expect(dock).not.toBeNull();
expect(dock?.className).toContain("bottom-0");
expect(dock?.className).toContain("px-4");
expect(dock?.className).toContain("md:px-6");
const feedButton = container.querySelector(
'button[aria-label="Open agent feed"]',
) as HTMLButtonElement | null;
@@ -331,112 +272,4 @@ describe("BoardChat staged typing intro", () => {
"bottom-[calc(5rem_+_env(safe-area-inset-bottom))]",
);
});
it("configures the Conference Room composer to submit on Cmd/Ctrl+Enter", async () => {
await render();
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) => {
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 () => {
const olderPlanningIssue = {
...OLDER_BOARD_ISSUE,
title: "Older planning chat",
};
mockIssuesApi.list.mockResolvedValue([BOARD_ISSUE, olderPlanningIssue]);
mockIssuesApi.listComments.mockImplementation(async (issueId: string) =>
issueId === olderPlanningIssue.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(olderPlanningIssue.title),
) 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(olderPlanningIssue.id);
expect(container.textContent).toContain("Older chat");
});
});
+138 -860
View File
File diff suppressed because it is too large. Load diff