mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
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:
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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user