From a30c25a50c6c8a9d0104b442fef4bbd7d8bdc7fd Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 1 Jul 2026 23:27:17 +0000 Subject: [PATCH] Implement stable conference room chat URLs Co-Authored-By: Paperclip --- .../board-chat-conversations-routes.test.ts | 52 ++ server/src/routes/board-chat.ts | 61 ++- ui/src/App.tsx | 1 + ui/src/api/boardChat.ts | 7 + ui/src/pages/BoardChat.test.tsx | 161 +++++- ui/src/pages/BoardChat.tsx | 459 ++++++++++++------ 6 files changed, 579 insertions(+), 162 deletions(-) diff --git a/server/src/__tests__/board-chat-conversations-routes.test.ts b/server/src/__tests__/board-chat-conversations-routes.test.ts index ae389fb1bdc..15b8424189e 100644 --- a/server/src/__tests__/board-chat-conversations-routes.test.ts +++ b/server/src/__tests__/board-chat-conversations-routes.test.ts @@ -7,6 +7,7 @@ const mockIssueService = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn(), update: vi.fn(), + getById: vi.fn(), })); const mockAssertInstanceAdmin = vi.hoisted(() => vi.fn()); const mockAssertCompanyAccess = vi.hoisted(() => vi.fn()); @@ -35,6 +36,8 @@ async function createApp( const OPEN_BOARD_ISSUE = { id: "issue-open", + companyId: "company-1", + identifier: "PAP-1", title: "How is hiring going?", originKind: "board_chat", status: "todo", @@ -49,6 +52,7 @@ describe("POST /api/board/chat/conversations (PAP-11123)", () => { mockIssueService.list.mockResolvedValue([]); mockIssueService.create.mockResolvedValue({ id: "issue-new", originKind: "board_chat" }); mockIssueService.update.mockResolvedValue(undefined); + mockIssueService.getById.mockResolvedValue(null); }); it("returns 403 FEATURE_DISABLED when enableConferenceRoomChat is off", async () => { @@ -107,6 +111,19 @@ describe("POST /api/board/chat/conversations (PAP-11123)", () => { expect(mockIssueService.create).not.toHaveBeenCalled(); }); + it("reuses a done board_chat issue because stable URLs stay available until cancellation", async () => { + mockIssueService.list.mockResolvedValue([{ ...OPEN_BOARD_ISSUE, id: "issue-done", status: "done" }]); + 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-done"); + expect(mockIssueService.create).not.toHaveBeenCalled(); + }); + it("mints an origin-tagged todo issue when no conversation is open", async () => { const app = await createApp(); @@ -155,4 +172,39 @@ describe("POST /api/board/chat/conversations (PAP-11123)", () => { expect(mockGetExperimental).not.toHaveBeenCalled(); expect(mockIssueService.create).not.toHaveBeenCalled(); }); + + it("resolves a valid direct board_chat conversation ref without creating", async () => { + mockIssueService.getById.mockResolvedValue(OPEN_BOARD_ISSUE); + const app = await createApp(); + + const res = await request(app) + .get("/api/board/chat/conversations/PAP-1") + .query({ companyId: "company-1" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + issue: expect.objectContaining({ id: "issue-open", identifier: "PAP-1" }), + unavailableReason: null, + }); + expect(mockIssueService.getById).toHaveBeenCalledWith("PAP-1"); + expect(mockIssueService.create).not.toHaveBeenCalled(); + }); + + it.each([ + ["not_found", null], + ["cancelled", { ...OPEN_BOARD_ISSUE, status: "cancelled" }], + ["wrong_company", { ...OPEN_BOARD_ISSUE, companyId: "company-2" }], + ["wrong_kind", { ...OPEN_BOARD_ISSUE, originKind: "manual" }], + ])("returns %s for unavailable direct conversation refs", async (reason, issue) => { + mockIssueService.getById.mockResolvedValue(issue); + const app = await createApp(); + + const res = await request(app) + .get("/api/board/chat/conversations/some-ref") + .query({ companyId: "company-1" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ issue: null, unavailableReason: reason }); + expect(mockIssueService.create).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/routes/board-chat.ts b/server/src/routes/board-chat.ts index 976f467dcb3..812f80c7c80 100644 --- a/server/src/routes/board-chat.ts +++ b/server/src/routes/board-chat.ts @@ -8,6 +8,12 @@ const BOARD_CHAT_ORIGIN_KIND = "board_chat"; const LEGACY_BOARD_CHAT_TITLE = "Board Operations"; const LEGACY_BOARD_CHAT_DESCRIPTION = "Standing issue for board concierge conversations and decision log"; const BOARD_CHAT_DESCRIPTION = "Standing issue for Conference Room conversations and decision log"; +const BOARD_CHAT_UNAVAILABLE_REASONS = { + NOT_FOUND: "not_found", + WRONG_COMPANY: "wrong_company", + WRONG_KIND: "wrong_kind", + CANCELLED: "cancelled", +} as const; function deriveBoardChatIssueTitle(message: string): string { const singleLine = message.replace(/\s+/g, " ").trim(); @@ -16,8 +22,8 @@ function deriveBoardChatIssueTitle(message: string): string { return `${singleLine.slice(0, 77).trimEnd()}...`; } -function isOpenBoardChatIssue(issue: { status?: string | null }) { - return issue.status !== "done" && issue.status !== "cancelled"; +function isAvailableBoardChatIssue(issue: { status?: string | null }) { + return issue.status !== "cancelled"; } function isLegacyBoardChatIssue(issue: { @@ -34,10 +40,25 @@ function isLegacyBoardChatIssue(issue: { (issue.originKind === undefined || issue.originKind === null || issue.originKind === "manual") && issue.assigneeAgentId === null && issue.assigneeUserId === null && - isOpenBoardChatIssue(issue) + isAvailableBoardChatIssue(issue) ); } +function getBoardChatUnavailableReason( + issue: { + companyId?: string | null; + originKind?: string | null; + status?: string | null; + } | null, + companyId: string, +) { + if (!issue) return BOARD_CHAT_UNAVAILABLE_REASONS.NOT_FOUND; + if (issue.companyId !== companyId) return BOARD_CHAT_UNAVAILABLE_REASONS.WRONG_COMPANY; + if (issue.originKind !== BOARD_CHAT_ORIGIN_KIND) return BOARD_CHAT_UNAVAILABLE_REASONS.WRONG_KIND; + if (issue.status === "cancelled") return BOARD_CHAT_UNAVAILABLE_REASONS.CANCELLED; + return null; +} + /** * Resolve the standing `board_chat` conversation issue for a company, creating * one (origin-tagged) if none is open. The real-agent Conference Room @@ -46,7 +67,7 @@ function isLegacyBoardChatIssue(issue: { * * - `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 + * - Otherwise reuse the most-recent non-cancelled 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". @@ -62,7 +83,7 @@ async function resolveOrCreateBoardChatIssue( sortField: "updated", sortDir: "desc", }); - const boardIssue = boardChatIssues.find(isOpenBoardChatIssue); + const boardIssue = boardChatIssues.find(isAvailableBoardChatIssue); if (boardIssue) return boardIssue; const legacyIssues = await issueSvc.list(companyId, { @@ -136,5 +157,35 @@ export function boardChatRoutes( res.status(200).json({ issue }); }); + // Resolve a direct Conference Room URL ref. This intentionally does not + // create or redirect: invalid direct links need a visible unavailable state + // while valid UUID links can be canonicalized client-side to identifiers. + router.get("/board/chat/conversations/:conversationRef", 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 = typeof req.query.companyId === "string" ? req.query.companyId : null; + if (!companyId) { + res.status(400).json({ error: "companyId is required" }); + return; + } + + assertInstanceAdmin(req); + assertCompanyAccess(req, companyId); + + const issue = await issueService(db).getById(req.params.conversationRef as string); + const unavailableReason = getBoardChatUnavailableReason(issue, companyId); + res.status(200).json({ + issue: unavailableReason ? null : issue, + unavailableReason, + }); + }); + return router; } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 4a9e971f7c8..422d1a97da4 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -194,6 +194,7 @@ function boardRoutes() { `/artifacts` in both modes. */} }> } /> + } /> } /> } /> diff --git a/ui/src/api/boardChat.ts b/ui/src/api/boardChat.ts index a706ef5ee15..30af6be401c 100644 --- a/ui/src/api/boardChat.ts +++ b/ui/src/api/boardChat.ts @@ -22,4 +22,11 @@ export const boardChatApi = { companyId, ...(options?.newConversation ? { newConversation: true } : {}), }), + getConversation: (companyId: string, conversationRef: string) => { + const params = new URLSearchParams({ companyId }); + return api.get<{ + issue: Issue | null; + unavailableReason: "not_found" | "wrong_company" | "wrong_kind" | "cancelled" | null; + }>(`/board/chat/conversations/${encodeURIComponent(conversationRef)}?${params.toString()}`); + }, }; diff --git a/ui/src/pages/BoardChat.test.tsx b/ui/src/pages/BoardChat.test.tsx index d40ec94822d..4104af08cb6 100644 --- a/ui/src/pages/BoardChat.test.tsx +++ b/ui/src/pages/BoardChat.test.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BoardChat } from "./BoardChat"; @@ -18,13 +19,18 @@ import { BoardChat } from "./BoardChat"; const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn() })); const mockIssuesApi = vi.hoisted(() => ({ list: vi.fn() })); const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() })); -const mockBoardChatApi = vi.hoisted(() => ({ resolveConversation: vi.fn() })); +const mockBoardChatApi = vi.hoisted(() => ({ + resolveConversation: vi.fn(), + getConversation: vi.fn(), +})); const mockAssistantChatProps = vi.hoisted(() => [] as Array>); +const mockCopyTextToClipboard = vi.hoisted(() => vi.fn()); vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi })); vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi })); vi.mock("../api/auth", () => ({ authApi: mockAuthApi })); vi.mock("../api/boardChat", () => ({ boardChatApi: mockBoardChatApi })); +vi.mock("../lib/clipboard", () => ({ copyTextToClipboard: mockCopyTextToClipboard })); vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ @@ -104,6 +110,7 @@ const CTO_AGENT = { }; const BOARD_ISSUE = { id: "issue-board", + companyId: "company-1", identifier: "PAP-1", title: "How is hiring going?", originKind: "board_chat", @@ -114,6 +121,7 @@ const BOARD_ISSUE = { }; const LEGACY_BOARD_ISSUE = { id: "issue-board-old", + companyId: "company-1", identifier: "PAP-0", title: "Board Operations", originKind: "board_chat", @@ -127,6 +135,7 @@ describe("BoardChat Conference Room transport", () => { let container: HTMLDivElement; let root: Root | null = null; let queryClient: QueryClient | null = null; + let currentPath = ""; beforeEach(() => { vi.useFakeTimers(); @@ -140,7 +149,18 @@ describe("BoardChat Conference Room transport", () => { user: { id: "user-1", name: "Board" }, }); mockBoardChatApi.resolveConversation.mockResolvedValue({ issue: BOARD_ISSUE }); + mockBoardChatApi.getConversation.mockImplementation( + async (_companyId: string, ref: string) => ({ + issue: + ref === LEGACY_BOARD_ISSUE.identifier || ref === LEGACY_BOARD_ISSUE.id + ? LEGACY_BOARD_ISSUE + : BOARD_ISSUE, + unavailableReason: null, + }), + ); + mockCopyTextToClipboard.mockResolvedValue(undefined); mockAssistantChatProps.length = 0; + currentPath = ""; }); afterEach(async () => { @@ -154,30 +174,52 @@ describe("BoardChat Conference Room transport", () => { vi.clearAllMocks(); }); - function buildElement() { + function LocationProbe() { + const location = useLocation(); + currentPath = `${location.pathname}${location.search}${location.hash}`; + return null; + } + + function buildElement(initialPath: string) { return ( - - - + + + + + } /> + } /> + + + ); } - async function render() { + async function render(initialPath = "/PAP/board-chat") { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); root = createRoot(container); await act(async () => { - root!.render(buildElement()); + root!.render(buildElement(initialPath)); }); // Flush the agent/session/issue queries plus follow-up effect renders. - for (let i = 0; i < 8; i++) { + await flushEffects(); + } + + async function flushEffects(times = 12) { + for (let i = 0; i < times; i++) { await act(async () => { await vi.advanceTimersByTimeAsync(0); }); } } + async function waitForPath(path: string, times = 30) { + for (let i = 0; i < times && currentPath !== path; i++) { + await flushEffects(1); + } + } + it("loads Conference Room history by board_chat origin", async () => { await render(); @@ -208,12 +250,13 @@ describe("BoardChat Conference Room transport", () => { expect(lastProps?.showAgentSwitcher).toBe(false); expect(lastProps?.companyName).toBe("Acme Robotics"); expect(lastProps?.emptyMessage).toBeUndefined(); + expect(currentPath).toBe("/PAP/board-chat/PAP-1"); }); - it("mints the first conversation when the company has no history", async () => { + it("mints the first conversation when the company has no history and replaces to its URL", async () => { mockIssuesApi.list.mockResolvedValue([]); mockBoardChatApi.resolveConversation.mockResolvedValue({ - issue: { ...BOARD_ISSUE, id: "issue-minted", title: "New chat" }, + issue: { ...BOARD_ISSUE, id: "issue-minted", identifier: "PAP-2", title: "New chat" }, }); await render(); @@ -225,11 +268,13 @@ describe("BoardChat Conference Room transport", () => { '[data-testid="selected-agent-chat"]', ) as HTMLDivElement | null; expect(surface?.getAttribute("data-issue-id")).toBe("issue-minted"); + await waitForPath("/PAP/board-chat/PAP-2"); + expect(currentPath).toBe("/PAP/board-chat/PAP-2"); }); it("forces a fresh conversation when New chat is clicked", async () => { mockBoardChatApi.resolveConversation.mockResolvedValue({ - issue: { ...BOARD_ISSUE, id: "issue-fresh", title: "New chat" }, + issue: { ...BOARD_ISSUE, id: "issue-fresh", identifier: "PAP-3", title: "New chat" }, }); await render(); @@ -241,11 +286,7 @@ describe("BoardChat Conference Room transport", () => { await act(async () => { newChatButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - for (let i = 0; i < 4; i++) { - await act(async () => { - await vi.advanceTimersByTimeAsync(0); - }); - } + await flushEffects(); expect(mockBoardChatApi.resolveConversation).toHaveBeenCalledWith("company-1", { newConversation: true, @@ -254,6 +295,94 @@ describe("BoardChat Conference Room transport", () => { '[data-testid="selected-agent-chat"]', ) as HTMLDivElement | null; expect(surface?.getAttribute("data-issue-id")).toBe("issue-fresh"); + await flushEffects(); + expect(currentPath).toBe("/PAP/board-chat/PAP-3"); + }); + + it("renders the exact direct chat URL after refresh without resolving the latest chat", async () => { + const directIssue = { + ...BOARD_ISSUE, + id: "issue-direct", + identifier: "PAP-44", + title: "Budget review", + }; + mockIssuesApi.list.mockResolvedValue([BOARD_ISSUE]); + mockBoardChatApi.getConversation.mockResolvedValue({ + issue: directIssue, + unavailableReason: null, + }); + + await render("/PAP/board-chat/PAP-44"); + + expect(mockBoardChatApi.getConversation).toHaveBeenCalledWith("company-1", "PAP-44"); + expect(mockBoardChatApi.resolveConversation).not.toHaveBeenCalled(); + const surface = container.querySelector( + '[data-testid="selected-agent-chat"]', + ) as HTMLDivElement | null; + expect(surface?.getAttribute("data-issue-id")).toBe("issue-direct"); + expect(currentPath).toBe("/PAP/board-chat/PAP-44"); + }); + + it("canonicalizes UUID direct links to the issue identifier", async () => { + mockBoardChatApi.getConversation.mockResolvedValue({ + issue: BOARD_ISSUE, + unavailableReason: null, + }); + + await render("/PAP/board-chat/issue-board"); + + expect(mockBoardChatApi.getConversation).toHaveBeenCalledWith("company-1", "issue-board"); + expect(currentPath).toBe("/PAP/board-chat/PAP-1"); + }); + + it("shows an explicit unavailable state for invalid direct links", async () => { + mockBoardChatApi.getConversation.mockResolvedValue({ + issue: null, + unavailableReason: "wrong_kind", + }); + + await render("/PAP/board-chat/PAP-99"); + + expect(container.textContent).toContain( + "This link points to an issue, but it is not a Conference Room chat.", + ); + expect(container.querySelector('[data-testid="selected-agent-chat"]')).toBeNull(); + expect(mockBoardChatApi.resolveConversation).not.toHaveBeenCalled(); + }); + + it("history rows navigate by canonical URL", async () => { + mockIssuesApi.list.mockResolvedValue([ + BOARD_ISSUE, + { ...LEGACY_BOARD_ISSUE, id: "issue-board-old", identifier: "PAP-0" }, + ]); + await render(); + + const oldChatButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Chat from"), + ) as HTMLButtonElement | undefined; + expect(oldChatButton).toBeTruthy(); + + await act(async () => { + oldChatButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushEffects(); + + expect(currentPath).toBe("/PAP/board-chat/PAP-0"); + }); + + it("copies the canonical link for the current chat", async () => { + await render(); + + const copyButton = container.querySelector( + 'button[aria-label="copy chat link"]', + ) as HTMLButtonElement | null; + expect(copyButton).not.toBeNull(); + + await act(async () => { + copyButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(mockCopyTextToClipboard).toHaveBeenCalledWith("http://localhost:3000/PAP/board-chat/PAP-1"); }); it("uses a friendly date label for legacy Board Operations history rows", async () => { diff --git a/ui/src/pages/BoardChat.tsx b/ui/src/pages/BoardChat.tsx index 9d767c37d6d..6f34e053bd9 100644 --- a/ui/src/pages/BoardChat.tsx +++ b/ui/src/pages/BoardChat.tsx @@ -9,6 +9,7 @@ import { import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { Navigate, useNavigate, useParams } from "@/lib/router"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; import { boardChatApi } from "../api/boardChat"; @@ -20,10 +21,11 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { Activity, AlertCircle, History, Loader2, MessageSquarePlus } from "lucide-react"; +import { Activity, AlertCircle, Check, Copy, History, Loader2, MessageSquarePlus } from "lucide-react"; import { ActivityFeed } from "../components/ActivityFeed"; import { AssistantChat } from "../components/AssistantChat"; import { cn, relativeTime } from "../lib/utils"; +import { copyTextToClipboard } from "../lib/clipboard"; import { AGENT_ROLE_LABELS, type Agent, type Issue } from "@paperclipai/shared"; import { Sheet, @@ -88,10 +90,31 @@ function boardChatHistoryAgentLabel( return `${agent.name} · ${role}`; } +function boardChatConversationRef(issue: Pick): string { + return issue.identifier || issue.id; +} + +function boardChatUnavailableMessage(reason: string | null | undefined): string { + switch (reason) { + case "cancelled": + return "This Conference Room chat was cancelled and is no longer available."; + case "wrong_company": + return "This chat does not belong to the selected company."; + case "wrong_kind": + return "This link points to an issue, but it is not a Conference Room chat."; + case "not_found": + return "This Conference Room chat could not be found."; + default: + return "This Conference Room chat is unavailable."; + } +} + export function BoardChat() { const { selectedCompanyId, selectedCompany } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { conversationRef } = useParams<{ conversationRef?: string }>(); useEffect(() => { setBreadcrumbs([{ label: "Conference Room" }]); @@ -169,9 +192,11 @@ export function BoardChat() { ); const [boardIssueId, setBoardIssueId] = useState(null); + const [localBoardIssue, setLocalBoardIssue] = useState(null); const [historyOpen, setHistoryOpen] = useState(false); const [minting, setMinting] = useState(false); const [mintError, setMintError] = useState(null); + const [copiedLink, setCopiedLink] = useState(false); /** Guards the resolve-or-create endpoint against overlapping calls. */ const mintingRef = useRef(false); const [mobileFeedOpen, setMobileFeedOpen] = useState(false); @@ -226,9 +251,22 @@ export function BoardChat() { [issues], ); + const { data: directConversation } = useQuery({ + queryKey: ["board-chat-conversation", selectedCompanyId, conversationRef], + queryFn: () => boardChatApi.getConversation(selectedCompanyId!, conversationRef!), + enabled: !!selectedCompanyId && !!conversationRef, + retry: false, + staleTime: 30_000, + }); + + const directBoardIssue = directConversation?.issue ?? null; + const activeBoardIssue = useMemo( - () => boardChatIssues.find((issue) => issue.id === boardIssueId) ?? null, - [boardChatIssues, boardIssueId], + () => + boardChatIssues.find((issue) => issue.id === boardIssueId) ?? + (localBoardIssue?.id === boardIssueId ? localBoardIssue : null) ?? + (directBoardIssue?.id === boardIssueId ? directBoardIssue : null), + [boardChatIssues, boardIssueId, localBoardIssue, directBoardIssue], ); const historyQueryKey = useMemo( @@ -241,8 +279,17 @@ export function BoardChat() { // 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 boardChatPath = useCallback( + (issue: Pick) => { + const prefix = selectedCompany?.issuePrefix || issue.identifier?.split("-")[0] || null; + const path = `/board-chat/${boardChatConversationRef(issue)}`; + return prefix ? `/${prefix}${path}` : path; + }, + [selectedCompany], + ); + const ensureConversation = useCallback( - async (opts?: { newConversation?: boolean }) => { + async (opts?: { newConversation?: boolean; replace?: boolean }) => { if (!selectedCompanyId || mintingRef.current) return; mintingRef.current = true; setMinting(true); @@ -250,9 +297,15 @@ export function BoardChat() { try { const { issue } = await boardChatApi.resolveConversation( selectedCompanyId, - opts, + opts?.newConversation ? { newConversation: true } : undefined, ); setBoardIssueId(issue.id); + setLocalBoardIssue(issue); + queryClient.setQueryData( + ["board-chat-conversation", selectedCompanyId, boardChatConversationRef(issue)], + { issue, unavailableReason: null }, + ); + navigate(boardChatPath(issue), { replace: opts?.replace ?? false }); if (historyQueryKey) { await queryClient.invalidateQueries({ queryKey: historyQueryKey }); } @@ -267,7 +320,7 @@ export function BoardChat() { setMinting(false); } }, - [selectedCompanyId, historyQueryKey, queryClient], + [selectedCompanyId, historyQueryKey, queryClient, navigate, boardChatPath], ); // Reset the active conversation when the company changes; the resolve effect @@ -276,27 +329,74 @@ export function BoardChat() { useEffect(() => { if (prevCompanyRef.current !== selectedCompanyId) { setBoardIssueId(null); + setLocalBoardIssue(null); setHistoryOpen(false); setMintError(null); + setCopiedLink(false); prevCompanyRef.current = selectedCompanyId; } }, [selectedCompanyId]); - // 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. + // The default room URL is only an entry point. Once history loads, replace it + // with the latest non-cancelled conversation, or mint the first one and + // replace to that canonical URL. 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); + if (!selectedCompanyId || conversationRef || !issues) return; + if (localBoardIssue && localBoardIssue.id === boardIssueId) { + navigate(boardChatPath(localBoardIssue), { replace: true }); return; } - void ensureConversation(); - }, [selectedCompanyId, issues, boardIssueId, boardChatIssues, ensureConversation]); + if (boardChatIssues.length > 0) { + const latest = boardChatIssues[0] ?? null; + if (!latest) return; + setBoardIssueId(latest.id); + setLocalBoardIssue(null); + navigate(boardChatPath(latest), { replace: true }); + return; + } + void ensureConversation({ replace: true }); + }, [ + selectedCompanyId, + conversationRef, + issues, + boardChatIssues, + localBoardIssue, + boardIssueId, + ensureConversation, + navigate, + boardChatPath, + ]); + + useEffect(() => { + if (!selectedCompanyId || conversationRef || !localBoardIssue) return; + if (localBoardIssue.id !== boardIssueId) return; + navigate(boardChatPath(localBoardIssue), { replace: true }); + }, [ + selectedCompanyId, + conversationRef, + localBoardIssue, + boardIssueId, + navigate, + boardChatPath, + ]); + + useEffect(() => { + if (!conversationRef || !directConversation) return; + if (directConversation.issue) { + const issue = directConversation.issue; + setMintError(null); + setBoardIssueId(issue.id); + setLocalBoardIssue(issue); + const canonicalRef = boardChatConversationRef(issue); + if (conversationRef !== canonicalRef) { + navigate(boardChatPath(issue), { replace: true }); + } + return; + } + setBoardIssueId(null); + setLocalBoardIssue(null); + setMintError(null); + }, [conversationRef, directConversation, navigate, boardChatPath]); const handleNewChat = useCallback(() => { if (minting) return; @@ -304,11 +404,32 @@ export function BoardChat() { void ensureConversation({ newConversation: true }); }, [minting, ensureConversation]); - const handleSelectConversation = useCallback((issueId: string) => { - setBoardIssueId(issueId); + const handleSelectConversation = useCallback((issue: Issue) => { + setBoardIssueId(issue.id); + setLocalBoardIssue(issue); setHistoryOpen(false); setMintError(null); - }, []); + if (selectedCompanyId) { + queryClient.setQueryData( + ["board-chat-conversation", selectedCompanyId, boardChatConversationRef(issue)], + { issue, unavailableReason: null }, + ); + } + navigate(boardChatPath(issue)); + }, [selectedCompanyId, queryClient, navigate, boardChatPath]); + + const currentChatUrl = useMemo(() => { + if (!activeBoardIssue || typeof window === "undefined") return null; + return new URL(boardChatPath(activeBoardIssue), window.location.origin).toString(); + }, [activeBoardIssue, boardChatPath]); + + const handleCopyLink = useCallback(() => { + if (!currentChatUrl) return; + void copyTextToClipboard(currentChatUrl).then(() => { + setCopiedLink(true); + window.setTimeout(() => setCopiedLink(false), 1500); + }); + }, [currentChatUrl]); const refreshBoardChatHistory = useCallback(async () => { if (!historyQueryKey) return; @@ -328,136 +449,188 @@ export function BoardChat() { ); } + const defaultRouteRedirect = + !conversationRef && activeBoardIssue ? ( + + ) : null; + return ( -
+ <> + {defaultRouteRedirect}
- {/* Left: chat (self-contained pane) — full width on mobile, 2/3 default on desktop */}
0 && containerWidth >= 2 * SPLIT_MIN_PANE_PX + SPLIT_DIVIDER_PX ? { width: leftPaneWidth } : undefined} + ref={splitContainerRef} + className="flex min-h-0 min-w-0 flex-1 flex-row" > - {/* Room toolbar — history + new-chat controls. The agent identity + {/* Left: chat (self-contained pane) — full width on mobile, 2/3 default on desktop */} +
0 && containerWidth >= 2 * SPLIT_MIN_PANE_PX + SPLIT_DIVIDER_PX + ? { width: leftPaneWidth } + : undefined + } + > + {/* Room toolbar — history + new-chat controls. The agent identity (real CEO, no concierge persona) is rendered by AssistantChat just below. */} -
-

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

-
- - - - - chat history - - - - - - new chat - -
-
- - - - Chat history - - Pick up a previous CEO conversation. - - -
-
- -
- {boardChatIssues.length === 0 ? ( -
- No previous chats yet. -
- ) : ( -
- {boardChatIssues.map((issue) => { - const active = issue.id === boardIssueId; - const label = boardChatHistoryLabel(issue); - const agentLabel = boardChatHistoryAgentLabel( - issue, - agentsById, - ceoAgent ?? null, - ); - return ( - - ); - })} -
- )} +
+

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

+
+ + + + + chat history + + + + + + copy chat link + + + + + + new chat +
- - +
+ + + + Chat history + + Pick up a previous CEO conversation. + + +
+
+ +
+ {boardChatIssues.length === 0 ? ( +
+ No previous chats yet. +
+ ) : ( +
+ {boardChatIssues.map((issue) => { + const active = issue.id === boardIssueId; + const label = boardChatHistoryLabel(issue); + const agentLabel = boardChatHistoryAgentLabel( + issue, + agentsById, + ceoAgent ?? null, + ); + return ( + + ); + })} +
+ )} +
+
+
{/* Center column — the real-agent conversation. AssistantChat owns the identity header, message stream, interaction cards, live/active-run row, and composer. */} - {boardIssueId ? ( + {directConversation && !directConversation.issue ? ( +
+
+ + + {boardChatUnavailableMessage(directConversation.unavailableReason)} + +
+ +
+ ) : boardIssueId ? ( ) : (
- +
)}
@@ -535,6 +711,7 @@ export function BoardChat() {
-
+
+ ); }