diff --git a/server/src/__tests__/board-chat-conversations-routes.test.ts b/server/src/__tests__/board-chat-conversations-routes.test.ts new file mode 100644 index 00000000000..e2ee886a77f --- /dev/null +++ b/server/src/__tests__/board-chat-conversations-routes.test.ts @@ -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" }), + ); + }); +}); diff --git a/server/src/routes/board-chat.ts b/server/src/routes/board-chat.ts index 6e5247e927d..56c286b6c26 100644 --- a/server/src/routes/board-chat.ts +++ b/server/src/routes/board-chat.ts @@ -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, + 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; } diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 38a4a319867..8d63bc55f09 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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({ diff --git a/ui/src/api/boardChat.ts b/ui/src/api/boardChat.ts new file mode 100644 index 00000000000..90a67ba89ea --- /dev/null +++ b/ui/src/api/boardChat.ts @@ -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 } : {}), + }), +}; diff --git a/ui/src/pages/BoardChat.test.tsx b/ui/src/pages/BoardChat.test.tsx index 1ae9ba316b7..5f85bb62aa9 100644 --- a/ui/src/pages/BoardChat.test.tsx +++ b/ui/src/pages/BoardChat.test.tsx @@ -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>); +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>); 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: () =>
, })); -vi.mock("../components/MarkdownBody", () => ({ - MarkdownBody: ({ children }: { children: ReactNode }) =>
{children}
, -})); -vi.mock("../components/ChatComposer", () => ({ - ChatComposer: forwardRef((props: Record, ref) => { - mockChatComposerProps.push(props); - useImperativeHandle(ref, () => ({ focus: vi.fn() })); - return
; - }), -})); -vi.mock("../components/AgentBubbleActionRow", () => ({ - AgentBubbleActionRow: () => null, - agentBubbleDateLabel: () => "", -})); -vi.mock("../components/AgentIconPicker", () => ({ - AgentIcon: () => null, +vi.mock("../components/SelectedAgentChat", () => ({ + SelectedAgentChat: (props: Record) => { + mockSelectedAgentChatProps.push(props); + return ( +
+ ); + }, })); 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 ( @@ -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"); - }); }); diff --git a/ui/src/pages/BoardChat.tsx b/ui/src/pages/BoardChat.tsx index 26f48e3a6ea..af116192217 100644 --- a/ui/src/pages/BoardChat.tsx +++ b/ui/src/pages/BoardChat.tsx @@ -9,29 +9,22 @@ import { import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useDialogState } from "../context/DialogContext"; import { agentsApi } from "../api/agents"; +import { authApi } from "../api/auth"; +import { boardChatApi } from "../api/boardChat"; import { issuesApi } from "../api/issues"; -import { goalsApi } from "../api/goals"; import { queryKeys } from "../lib/queryKeys"; -import { MarkdownBody } from "../components/MarkdownBody"; import { Button } from "@/components/ui/button"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { Activity, ArrowDown, History, MessageSquarePlus } from "lucide-react"; +import { Activity, AlertCircle, History, Loader2, MessageSquarePlus } from "lucide-react"; import { ActivityFeed } from "../components/ActivityFeed"; -import { ChatComposer, type ChatComposerHandle } from "../components/ChatComposer"; -import { - AgentBubbleActionRow, - agentBubbleDateLabel, -} from "../components/AgentBubbleActionRow"; -import { AgentIcon } from "../components/AgentIconPicker"; -import { cn, formatDateTime, relativeTime } from "../lib/utils"; -import type { FeedbackVoteValue, Issue } from "@paperclipai/shared"; +import { SelectedAgentChat } from "../components/SelectedAgentChat"; +import { cn, relativeTime } from "../lib/utils"; +import type { Issue } from "@paperclipai/shared"; import { Sheet, SheetContent, @@ -42,9 +35,13 @@ import { } from "@/components/ui/sheet"; /** - * Board Concierge Chat — a chat interface powered by the board-member skill. - * Uses /board/chat/stream to invoke Claude with the board skill as system prompt. - * The user manages their Paperclip company through natural conversation. + * Conference Room — the board user's live conversation with the real company + * CEO (PAP-11099). The room is backed by an ordinary company-scoped + * `board_chat` issue (so history survives reloads) and the conversation runs + * over the reusable {@link SelectedAgentChat} surface: durable history is the + * issue's comments, live output is the target agent's active run, and + * next-step choices are real issue-thread interactions. Sending wakes the real + * agent (default CEO) — no board-concierge persona. */ /** Hit zone to the right of the 1px line (line sits on chat pane’s right edge). */ const SPLIT_DIVIDER_PX = 12; @@ -52,22 +49,9 @@ const SPLIT_MIN_PANE_PX = 280; /** Chat pane share of width below the divider (agent feed gets the rest). */ 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 BOARD_CHAT_ORIGIN_KIND = "board_chat"; -const boardChatBubbleShell = - "min-w-0 max-w-[85%] break-words px-3 py-2 text-sm overflow-x-auto overflow-y-visible"; - -/** First-letter(s) fallback for an agent with no icon. */ -function agentInitials(name: string): string { - const parts = name.trim().split(/\s+/); - return (((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase()) || "A"; -} - const boardChatHistoryDateFormatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", @@ -82,53 +66,12 @@ function formatBoardChatHistoryDate(value: Date | string | null | undefined): st } function boardChatHistoryLabel(issue: Pick): string { - if (!issue.title || issue.title === BOARD_CHAT_ISSUE_TITLE) { + if (!issue.title || issue.title === BOARD_CHAT_ISSUE_TITLE || issue.title === "New chat") { return `Chat from ${formatBoardChatHistoryDate(issue.createdAt ?? issue.updatedAt)}`; } return issue.title; } -/** - * Icon-adjacent-to-name header rendered directly above an agent bubble — - * the shared `[agent icon][agent name]` convention (PAP-105 / PAP-97). - */ -function AgentBubbleHeader({ name, icon }: { name: string; icon: string | null }) { - return ( -
- - - {icon ? ( - - ) : ( - agentInitials(name) - )} - - - {name} -
- ); -} - -/** Agent-styled chat bubble containing the three-dot typing indicator. */ -function TypingBubble() { - return ( -
-
- - - - - -
-
- ); -} - export function BoardChat() { const { selectedCompanyId, selectedCompany } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -143,7 +86,6 @@ export function BoardChat() { const [chatPaneFraction, setChatPaneFraction] = useState(DEFAULT_CHAT_FRACTION); const splitDragging = useRef(false); - useLayoutEffect(() => { const el = splitContainerRef.current; if (!el) return; @@ -210,102 +152,19 @@ export function BoardChat() { [containerWidth, leftPaneWidth], ); - const [input, setInput] = useState(""); - /** Guards the draft-persistence effect so it doesn't overwrite a saved - * draft with "" before we've had a chance to load it. */ - const loadedDraftCompanyRef = useRef(null); - const [sending, setSending] = useState(false); - const [streamingText, setStreamingText] = useState(""); - 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); - const scrollContainerRef = useRef(null); - const hasRestoredScrollRef = useRef(false); - const composerRef = useRef(null); - const elapsedTimerRef = useRef | null>(null); + const [minting, setMinting] = useState(false); + const [mintError, setMintError] = useState(null); + /** Guards the resolve-or-create endpoint against overlapping calls. */ + const mintingRef = useRef(false); + const [mobileFeedOpen, setMobileFeedOpen] = useState(false); - /** True when the user is scrolled away from the bottom AND new content - * has arrived they can't see. Drives the floating "jump to latest" chip. */ - const [hasNewBelow, setHasNewBelow] = useState(false); - - /** Tracks whether the user was near the bottom BEFORE the latest content - * change. Updated on scroll events (and after programmatic scrolls) so - * that when a tall new message inflates scrollHeight, we still know the - * user's pre-update position and can decide whether to auto-scroll. */ - const wasNearBottomRef = useRef(true); - - const scrollToLatest = useCallback((behavior: ScrollBehavior = "smooth") => { - messagesEndRef.current?.scrollIntoView({ behavior, block: "end" }); - wasNearBottomRef.current = true; - setHasNewBelow(false); - }, []); - - // Welcome typing intro: staged reveal of typing → welcome bubble → chips. - // The timers don't start until the data needed to render the welcome is - // actually loaded, so the animation plays at the moment the user arrives - // at the chat (e.g. right after creating a new company) rather than - // burning off while a spinner is on screen. - const [welcomeRevealed, setWelcomeRevealed] = useState(false); - const [chipsRevealed, setChipsRevealed] = useState(false); - - // Reset state and clear cached comments when company changes. The - // composer draft is NOT wiped — it's loaded from per-company - // sessionStorage in the effect below so users don't lose typed content - // when switching between companies or navigating away and back. - const prevCompanyRef = useRef(selectedCompanyId); - useEffect(() => { - if (prevCompanyRef.current !== selectedCompanyId) { - if (boardIssueId) { - queryClient.removeQueries({ queryKey: queryKeys.issues.comments(boardIssueId) }); - } - setBoardIssueId(null); - setIsComposingNewConversation(false); - setHistoryOpen(false); - setStreamingText(""); - setStatusText(""); - setErrorText(""); - setSending(false); - setOptimisticMessage(null); - prevCompanyRef.current = selectedCompanyId; - } - }, [selectedCompanyId, boardIssueId, queryClient]); - - // Load a saved composer draft (if any) whenever the active company - // changes — runs on first mount too. - useEffect(() => { - if (!selectedCompanyId) return; - if (loadedDraftCompanyRef.current === selectedCompanyId) return; - try { - const saved = sessionStorage.getItem( - `paperclip.boardChat.draft.${selectedCompanyId}`, - ); - setInput(saved ?? ""); - } catch { - setInput(""); - } - loadedDraftCompanyRef.current = selectedCompanyId; - }, [selectedCompanyId]); - - // Persist composer draft to sessionStorage on change (per company). - // Only runs after the initial load for this company to avoid clobbering - // a saved draft with an empty initial value. - useEffect(() => { - if (!selectedCompanyId) return; - if (loadedDraftCompanyRef.current !== selectedCompanyId) return; - try { - const key = `paperclip.boardChat.draft.${selectedCompanyId}`; - if (input) { - sessionStorage.setItem(key, input); - } else { - sessionStorage.removeItem(key); - } - } catch { /* sessionStorage unavailable */ } - }, [input, selectedCompanyId]); + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + }); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -318,22 +177,8 @@ export function BoardChat() { [agents], ); - // Pull the company's top-level goal so the CEO's welcome can reference - // the mission verbatim. - const { data: goals } = useQuery({ - queryKey: queryKeys.goals.list(selectedCompanyId!), - queryFn: () => goalsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, - }); - - const missionText = useMemo(() => { - const active = (goals ?? []).find((g) => g.status === "active"); - return active?.title ?? null; - }, [goals]); - - // Find or detect board chat conversations. They are backed by ordinary - // company-scoped issues so history survives reloads without a separate chat - // store. + // Conference Room conversations are backed by ordinary company-scoped + // `board_chat` issues so history survives reloads without a separate store. const { data: issues } = useQuery({ queryKey: [...queryKeys.issues.list(selectedCompanyId!), "board-chat-history"], queryFn: () => @@ -361,437 +206,89 @@ export function BoardChat() { [issues], ); - useEffect(() => { - if (!issues || isComposingNewConversation) { - if (!issues) setBoardIssueId(null); - return; - } - 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]); - const activeBoardIssue = useMemo( () => boardChatIssues.find((issue) => issue.id === boardIssueId) ?? null, [boardChatIssues, boardIssueId], ); - const scrollStorageKey = useMemo( + const historyQueryKey = useMemo( () => selectedCompanyId - ? `paperclip.boardChat.scrollTop.${selectedCompanyId}.${boardIssueId ?? "new"}` + ? [...queryKeys.issues.list(selectedCompanyId), "board-chat-history"] : null, - [selectedCompanyId, boardIssueId], + [selectedCompanyId], ); - 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 && !isComposingNewConversation, - refetchInterval: 3000, - }); - - const sortedComments = (comments ?? []) - .slice() - .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); - - // Agent lookup so each bubble can show its author's name + icon header. - const agentMap = useMemo( - () => new Map((agents ?? []).map((a) => [a.id, a] as const)), - [agents], - ); - - // Feedback votes for the board issue power the 👍/👎 affordance — the same - // store the task thread reads (PAP-105 shares the action row). - const { data: feedbackVotes } = useQuery({ - queryKey: queryKeys.issues.feedbackVotes(boardIssueId ?? ""), - queryFn: () => issuesApi.listFeedbackVotes(boardIssueId!), - enabled: !!boardIssueId && !isComposingNewConversation, - }); - - const voteByComment = useMemo(() => { - const map = new Map(); - for (const vote of feedbackVotes ?? []) { - if (vote.targetType === "issue_comment") map.set(vote.targetId, vote.vote); - } - return map; - }, [feedbackVotes]); - - const handleCommentVote = useCallback( - async ( - commentId: string, - vote: FeedbackVoteValue, - options?: { allowSharing?: boolean; reason?: string }, - ) => { - if (!boardIssueId || isComposingNewConversation) return; - await issuesApi.upsertFeedbackVote(boardIssueId, { - targetType: "issue_comment", - targetId: commentId, - vote, - reason: options?.reason, - allowSharing: options?.allowSharing, - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.issues.feedbackVotes(boardIssueId), - }); - }, - [boardIssueId, isComposingNewConversation, queryClient], - ); - - // Reset the staged reveal on mount AND whenever the active company - // changes, so every arrival at the Conference Room replays the typing - // intro from scratch (a freshly-created company included). The effect's - // mount run is intentional — it keeps the intro fresh even if a future - // refactor preserves this component instance across navigations (PAP-134). - useEffect(() => { - setWelcomeRevealed(false); - setChipsRevealed(false); - }, [selectedCompanyId]); - - // The onboarding wizard renders as an overlay above an already-mounted - // Conference Room (sidebar "Create new team..." path). Holding the reveal - // timer while it's open guarantees the dots window can't burn off behind - // the wizard before the user ever sees the chat (PAP-134). - const { onboardingOpen } = useDialogState(); - - // Likewise, don't let the dots window burn while the tab is hidden — - // e.g. the user completes the wizard, switches tabs, and comes back. - const [pageVisible, setPageVisible] = useState( - () => document.visibilityState !== "hidden", - ); - useEffect(() => { - const onVisibilityChange = () => - setPageVisible(document.visibilityState !== "hidden"); - document.addEventListener("visibilitychange", onVisibilityChange); - return () => - document.removeEventListener("visibilitychange", onVisibilityChange); - }, []); - - // Start the typing → welcome timer only once we have the ingredients - // needed to render the welcome bubble. This guarantees the animation is - // visible at the moment the user arrives, even if agent/goal queries - // take a beat to resolve. - const canRenderWelcome = !!ceoAgent && !!selectedCompany; - useEffect(() => { - if (!canRenderWelcome) return; - if (welcomeRevealed) return; - if (onboardingOpen || !pageVisible) return; - const timeout = setTimeout(() => setWelcomeRevealed(true), 2000); - return () => clearTimeout(timeout); - }, [canRenderWelcome, welcomeRevealed, onboardingOpen, pageVisible]); - - // Stage the suggestion chips in shortly after the welcome bubble lands - // so the eye reads the message first, then the actions. - useEffect(() => { - if (!welcomeRevealed) return; - if (chipsRevealed) return; - const timeout = setTimeout(() => setChipsRevealed(true), 700); - return () => clearTimeout(timeout); - }, [welcomeRevealed, chipsRevealed]); - - // If the user has already replied in this conversation, fast-forward - // past the intro — the welcome isn't a "new" event anymore. - useEffect(() => { - if (welcomeRevealed && chipsRevealed) return; - if (!comments) return; - const userHasReplied = comments.some( - (c) => !c.authorAgentId && c.authorUserId !== "board-concierge", - ); - if (userHasReplied) { - setWelcomeRevealed(true); - setChipsRevealed(true); - } - }, [comments, welcomeRevealed, chipsRevealed]); - - // Clear optimistic message once server-persisted comments include it - useEffect(() => { - if (optimisticMessage && sortedComments.length > 0) { - const lastUserComment = [...sortedComments] - .reverse() - .find((c) => !c.authorAgentId && c.authorUserId !== "board-concierge"); - if (lastUserComment?.body === optimisticMessage) { - setOptimisticMessage(null); - } - } - }, [sortedComments, optimisticMessage]); - - // Scroll behavior: - // - First mount in a session (no saved position): jump to bottom instantly. - // - Returning to the page within the same session: restore last scrollTop. - // - New content arriving: smooth-scroll to bottom only if user is already - // near the bottom, so we don't yank them away from reading history. - // - Scroll position is persisted to sessionStorage (cleared when tab closes). - useEffect(() => { - if (hasRestoredScrollRef.current) return; - if (sortedComments.length === 0) return; - const container = scrollContainerRef.current; - if (!container) return; - - try { - if (!scrollStorageKey) return; - const saved = sessionStorage.getItem(scrollStorageKey); - if (saved != null) { - const parsed = Number(saved); - if (Number.isFinite(parsed)) { - container.scrollTop = parsed; - hasRestoredScrollRef.current = true; - return; - } - } - } catch { /* sessionStorage unavailable */ } - - container.scrollTop = container.scrollHeight; - hasRestoredScrollRef.current = true; - }, [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. - useEffect(() => { - if (!optimisticMessage) return; - scrollToLatest("smooth"); - }, [optimisticMessage, scrollToLatest]); - - // Agent activity (new persisted comment, streaming chunks, status): - // auto-scroll only if the user was near the bottom BEFORE the new content - // arrived. Using the ref (updated on scroll events) instead of measuring - // after the render, because the new content has already grown scrollHeight - // by the time this effect fires — making the post-update "distance from - // bottom" misleading. - useEffect(() => { - if (!hasRestoredScrollRef.current) return; - if (wasNearBottomRef.current) { - scrollToLatest("smooth"); - } else { - setHasNewBelow(true); - } - }, [sortedComments.length, streamingText, statusText, scrollToLatest]); - - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) return; - let rafId: number | null = null; - const handleScroll = () => { - const near = container.scrollHeight - container.scrollTop - container.clientHeight <= 80; - wasNearBottomRef.current = near; - if (near) setHasNewBelow(false); - - if (rafId != null) return; - rafId = requestAnimationFrame(() => { - rafId = null; - try { - if (scrollStorageKey) { - sessionStorage.setItem(scrollStorageKey, String(container.scrollTop)); - } - } catch { /* sessionStorage unavailable */ } - }); - }; - container.addEventListener("scroll", handleScroll, { passive: true }); - return () => { - 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. - useEffect(() => { - if (sending) { - setElapsedSec(0); - const startedAt = Date.now(); - elapsedTimerRef.current = setInterval(() => { - setElapsedSec((Date.now() - startedAt) / 1000); - }, 100); - } else { - if (elapsedTimerRef.current) { - clearInterval(elapsedTimerRef.current); - elapsedTimerRef.current = null; - } - } - return () => { - if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current); - }; - }, [sending]); - - const sendMessage = useCallback( - async (body: string) => { - const trimmed = body.trim(); - if (!trimmed || sending || !selectedCompanyId) return; - - // Show user message immediately - setOptimisticMessage(trimmed); - setSending(true); - setInput(""); - setStreamingText(""); - setErrorText(""); - setStatusText("Connecting..."); - + // Resolve-or-create the backing board_chat issue. The web client can't mint + // it directly (the create schema strips `originKind`), so the server owns it. + const ensureConversation = useCallback( + async (opts?: { newConversation?: boolean }) => { + if (!selectedCompanyId || mintingRef.current) return; + mintingRef.current = true; + setMinting(true); + setMintError(null); try { - const controller = new AbortController(); - const fetchTimeout = setTimeout(() => controller.abort(), 130000); - const res = await fetch("/api/board/chat/stream", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ - companyId: selectedCompanyId, - message: trimmed, - taskId: isComposingNewConversation ? undefined : boardIssueId ?? undefined, - newConversation: isComposingNewConversation, - }), - signal: controller.signal, - }); - clearTimeout(fetchTimeout); - - if (!res.ok || !res.body) { - const errorBody = await res.json().catch(() => null); - const message = - (errorBody as { error?: string } | null)?.error || - "Board chat stream not available"; - throw new Error(message); - } - - setStatusText("Thinking..."); - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let accumulated = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - try { - const event = JSON.parse(line.slice(6)); - if (event.type === "chunk" && event.text) { - accumulated += event.text; - setStreamingText(accumulated); - setStatusText(""); - } else if (event.type === "status" && event.text) { - setStatusText(event.text); - } else if (event.type === "start" && event.issueId) { - setBoardIssueId(event.issueId); - setIsComposingNewConversation(false); - } else if (event.type === "error") { - setErrorText( - event.message || - "The board assistant couldn't respond. Please try again.", - ); - setStatusText(""); - } else if (event.type === "done") { - if (event.issueId) { - queryClient.invalidateQueries({ - queryKey: queryKeys.issues.comments(event.issueId), - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.issues.list(selectedCompanyId), - }); - } - } - } catch { - /* malformed SSE line */ - } - } - } - - setStreamingText(""); - setStatusText(""); - if (boardIssueId) { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(boardIssueId) }); + const { issue } = await boardChatApi.resolveConversation( + selectedCompanyId, + opts, + ); + setBoardIssueId(issue.id); + if (historyQueryKey) { + await queryClient.invalidateQueries({ queryKey: historyQueryKey }); } } catch (err) { - console.error("Board chat error:", err); - setStatusText(""); - setErrorText( + setMintError( err instanceof Error ? err.message - : "The board assistant is unavailable right now. Please try again in a moment.", + : "Couldn't open the Conference Room. Please try again.", ); } finally { - setSending(false); - composerRef.current?.focus(); + mintingRef.current = false; + setMinting(false); } }, - [sending, selectedCompanyId, boardIssueId, isComposingNewConversation, queryClient], + [selectedCompanyId, historyQueryKey, queryClient], ); - const handleSend = useCallback(() => { - sendMessage(input); - }, [input, sendMessage]); + // Reset the active conversation when the company changes; the resolve effect + // below re-anchors (or mints) for the newly selected company. + const prevCompanyRef = useRef(selectedCompanyId); + useEffect(() => { + if (prevCompanyRef.current !== selectedCompanyId) { + setBoardIssueId(null); + setHistoryOpen(false); + setMintError(null); + prevCompanyRef.current = selectedCompanyId; + } + }, [selectedCompanyId]); - // NOTE: declared before the early return below — all hooks must run on - // every render (Rules of Hooks). Placing it after the `!selectedCompanyId` - // guard caused "Rendered more hooks than during the previous render" and a - // blank page once a company was selected. - const [mobileFeedOpen, setMobileFeedOpen] = useState(false); + // Once history loads, anchor onto the most-recent open conversation; if the + // company has none yet, mint the first one so the room is immediately live. + // A conversation the user (or a fresh mint) already selected is respected — + // a just-minted id may not be in the history list yet, and re-anchoring here + // would snap the room back to the previous conversation. + useEffect(() => { + if (!selectedCompanyId || !issues) return; + if (boardIssueId) return; + if (boardChatIssues.length > 0) { + const open = boardChatIssues.find((i) => i.status !== "done"); + setBoardIssueId(open?.id ?? boardChatIssues[0]?.id ?? null); + return; + } + void ensureConversation(); + }, [selectedCompanyId, issues, boardIssueId, boardChatIssues, ensureConversation]); + + const handleNewChat = useCallback(() => { + if (minting) return; + setHistoryOpen(false); + void ensureConversation({ newConversation: true }); + }, [minting, ensureConversation]); + + const handleSelectConversation = useCallback((issueId: string) => { + setBoardIssueId(issueId); + setHistoryOpen(false); + setMintError(null); + }, []); if (!selectedCompanyId) { return ( @@ -799,7 +296,7 @@ export function BoardChat() {

No company selected

- Select a company to start chatting with your board concierge. + Select a company to start chatting with your CEO.

@@ -824,23 +321,15 @@ export function BoardChat() { )} style={innerWidth > 0 && containerWidth >= 2 * SPLIT_MIN_PANE_PX + SPLIT_DIVIDER_PX ? { width: leftPaneWidth } : undefined} > -
-
-
-

- {ceoAgent?.name ?? "Conference Room"} -

-

- {isComposingNewConversation - ? "New chat" - : activeBoardIssue - ? `${selectedCompany?.name ?? "Your company"} · updated ${relativeTime(activeBoardIssue.updatedAt)}` - : selectedCompany?.name ?? "Your company"} -

-
+ {/* Room toolbar — history + new-chat controls. The agent identity + (real CEO, no concierge persona) is rendered by SelectedAgentChat + just below. */} +
+

+ {activeBoardIssue + ? `${selectedCompany?.name ?? "Your company"} · updated ${relativeTime(activeBoardIssue.updatedAt)}` + : selectedCompany?.name ?? "Your company"} +

@@ -866,7 +355,7 @@ export function BoardChat() { className="text-muted-foreground" aria-label="new chat" onClick={handleNewChat} - disabled={sending || isComposingNewConversation} + disabled={minting} > @@ -891,7 +380,7 @@ export function BoardChat() { size="sm" className="w-full justify-start" onClick={handleNewChat} - disabled={sending || isComposingNewConversation} + disabled={minting} > New chat @@ -904,7 +393,7 @@ export function BoardChat() { ) : (
{boardChatIssues.map((issue) => { - const active = issue.id === boardIssueId && !isComposingNewConversation; + const active = issue.id === boardIssueId; const label = boardChatHistoryLabel(issue); return (
- {/* Messages — scroll viewport flush right so the scrollbar sits on the pane/divider edge */} -
-
- {/* pb clears the floating glass dock (PAP-131) so the last bubble can - scroll fully above the composer. */} -
- {/* Typing bubble — shown unconditionally until the reveal - timer fires, so the animation is guaranteed to be - visible even while agent/goal data is still loading. */} - {!welcomeRevealed && } - {welcomeRevealed && ceoAgent && selectedCompany && (() => { - const ceoName = ceoAgent.name; - const companyName = selectedCompany.name; - const missionLine = missionText - ? ` — your mission is "${missionText}".` - : "."; - const welcomeBody = - `Welcome to **${companyName}**! I'm ${ceoName}, your team lead. I've read through what you shared in the wizard${missionLine}\n\n` + - `Here are a few things I can help you put on paper right now. Pick one below and I'll draft it for you using everything you told us.`; - - const userHasReplied = sortedComments.some( - (c) => !c.authorAgentId && c.authorUserId !== "board-concierge", - ); - - const chips: Array<{ label: string; prompt: string }> = [ - { - label: "Draft a Company Brief", - prompt: `Draft a one-page Company Brief for ${companyName} — include our mission, team roster, and first priorities.`, - }, - { - label: "Create a hiring plan", - prompt: `Create a hiring plan for ${companyName}. List the next roles to hire, in priority order, with a short rationale for each.`, - }, - { - label: "Outline our first 30 days", - prompt: `Outline our first 30 days. Break it into weekly priorities with who owns what.`, - }, - { - label: "Write an intro pitch", - prompt: `Write a short intro pitch for ${companyName} that I could reuse for investors, customers, or recruits.`, - }, - ]; - - return ( - <> -
- -
- {welcomeBody} -
-
- {!userHasReplied && chipsRevealed && ( -
- {chips.map((chip) => ( - - ))} -
- )} - - ); - })()} - - {sortedComments.map((comment) => { - const isUser = !comment.authorAgentId && comment.authorUserId !== "board-concierge"; - if (isUser) { - return ( -
-
- {comment.body ?? ""} -
-
- ); - } - // Agent bubble — name/icon header above + action row below so - // the room speaks the same bubble language as the task thread. - const agent = comment.authorAgentId - ? agentMap.get(comment.authorAgentId) ?? null - : ceoAgent ?? null; - const agentName = agent?.name ?? "Assistant"; - const agentIconValue = agent?.icon ?? null; - return ( -
- -
- - {comment.body ?? ""} - -
- - handleCommentVote(comment.id, vote, options), - } - : null - } - /> -
- ); - })} - - {/* Optimistic user message — shows instantly before server persists */} - {optimisticMessage && ( -
-
- {optimisticMessage} -
-
- )} - - {/* Streaming response */} - {streamingText && ( -
- {ceoAgent && ( - - )} -
- {streamingText} -
-
- )} - - {/* Typing bubble — sits above the status line while the agent - is preparing a reply but no text has streamed yet. Shows - alongside the user's optimistic bubble to make the - turn-taking feel alive. */} - {sending && !streamingText && } - - {/* Status bar — always visible while sending, independent from the chat bubble */} - {sending && ( -
- - {statusText || "Thinking..."} - {elapsedSec > 0 && ( - {elapsedSec.toFixed(1)}s - )} -
- )} - - {/* Error notice — surfaced when the stream endpoint fails so - the message doesn't silently sit with no response. */} - {errorText && !sending && ( -
-
- {errorText} -
-
- )} - -
-
-
-
- - {/* Jump-to-latest — shows when user is scrolled away and new content has arrived */} - {hasNewBelow && ( - - )} - - {/* Input — shared ChatComposer (PAP-95a), adopted bare: textarea + send. - No mode chip (the room has no task lifecycle). Multiline like task - comments (PAP-116): text soft-wraps and the box auto-grows instead of - clipping / showing a horizontal scrollbar. Sends on ⌘/Ctrl+Enter; - plain Enter inserts a newline. - - PAP-131 (PAP-128 A): the dock floats over the message stream so text - scrolls behind the translucent glass box. The old hard black gradient - mask is gone — the dock carries the task-style soft top fade instead - (mirrors IssueChatThread's composer dock). pointer-events pass through - the fade so the scrollbar stays usable; the composer re-enables them. */} -
- -
+ ) : mintError ? ( +
+
+ + {mintError} +
+ +
+ ) : ( +
+ +
+ )}
{/* Resize handle — hidden on mobile */}