Implement stable conference room chat URLs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-01 23:27:17 +00:00
1 parent 0e6f0f1fd8
commit a30c25a50c
6 files changed
+579 -162

No files matched your search

@@ -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();
});
});
+56 -5
View File
@@ -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;
}
+1
View File
@@ -194,6 +194,7 @@ function boardRoutes() {
`/artifacts` in both modes. */}
<Route element={<ConferenceRoomChatGate />}>
<Route path="board-chat" element={<BoardChat />} />
<Route path="board-chat/:conversationRef" element={<BoardChat />} />
<Route path="artifacts" element={<Artifacts />} />
</Route>
<Route path="inbox" element={<InboxRootRedirect />} />
+7
View File
@@ -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()}`);
},
};
+145 -16
View File
@@ -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<Record<string, unknown>>);
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 (
<QueryClientProvider client={queryClient!}>
<BoardChat />
</QueryClientProvider>
<MemoryRouter initialEntries={[initialPath]}>
<QueryClientProvider client={queryClient!}>
<LocationProbe />
<Routes>
<Route path="/:companyPrefix/board-chat" element={<BoardChat />} />
<Route path="/:companyPrefix/board-chat/:conversationRef" element={<BoardChat />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>
);
}
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 () => {
+318 -141
View File
@@ -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<Issue, "id" | "identifier">): 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<string | null>(null);
const [localBoardIssue, setLocalBoardIssue] = useState<Issue | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [minting, setMinting] = useState(false);
const [mintError, setMintError] = useState<string | null>(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<Issue, "id" | "identifier">) => {
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 ? (
<Navigate to={boardChatPath(activeBoardIssue)} replace />
) : null;
return (
<div
data-testid="board-chat-shell"
className="flex h-[calc(100dvh_-_3rem_-_4rem_-_env(safe-area-inset-top)_-_env(safe-area-inset-bottom))] flex-col -m-4 md:h-[calc(100%_+_3rem)] md:-m-6"
>
<>
{defaultRouteRedirect}
<div
ref={splitContainerRef}
className="flex min-h-0 min-w-0 flex-1 flex-row"
data-testid="board-chat-shell"
className="flex h-[calc(100dvh_-_3rem_-_4rem_-_env(safe-area-inset-top)_-_env(safe-area-inset-bottom))] flex-col -m-4 md:h-[calc(100%_+_3rem)] md:-m-6"
>
{/* Left: chat (self-contained pane) — full width on mobile, 2/3 default on desktop */}
<div
className={cn(
"relative flex min-h-0 min-w-0 shrink-0 flex-col bg-background",
"w-full md:w-auto",
innerWidth <= 0 && "md:w-2/3",
)}
style={innerWidth > 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 */}
<div
className={cn(
"relative flex min-h-0 min-w-0 shrink-0 flex-col bg-background",
"w-full md:w-auto",
innerWidth <= 0 && "md:w-2/3",
)}
style={
innerWidth > 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. */}
<div className="relative flex shrink-0 items-center justify-between gap-2 px-4 py-2">
<p className="min-w-0 truncate text-xs text-muted-foreground">
{activeBoardIssue
? `${selectedCompany?.name ?? "Your company"} · updated ${relativeTime(activeBoardIssue.updatedAt)}`
: selectedCompany?.name ?? "Your company"}
</p>
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="chat history"
onClick={() => setHistoryOpen(true)}
>
<History className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">chat history</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="new chat"
onClick={handleNewChat}
disabled={minting}
>
<MessageSquarePlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">new chat</TooltipContent>
</Tooltip>
</div>
</div>
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
<SheetContent side="right" className="w-[min(24rem,100vw)] p-0 sm:max-w-md">
<SheetHeader className="border-b px-4 py-3">
<SheetTitle className="text-sm">Chat history</SheetTitle>
<SheetDescription>
Pick up a previous CEO conversation.
</SheetDescription>
</SheetHeader>
<div className="min-h-0 overflow-y-auto py-2">
<div className="px-3 pb-2">
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-start"
onClick={handleNewChat}
disabled={minting}
>
<MessageSquarePlus className="h-4 w-4" />
New chat
</Button>
</div>
{boardChatIssues.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No previous chats yet.
</div>
) : (
<div className="divide-y divide-border">
{boardChatIssues.map((issue) => {
const active = issue.id === boardIssueId;
const label = boardChatHistoryLabel(issue);
const agentLabel = boardChatHistoryAgentLabel(
issue,
agentsById,
ceoAgent ?? null,
);
return (
<button
key={issue.id}
type="button"
className={cn(
"flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent",
active && "bg-accent/70",
)}
onClick={() => handleSelectConversation(issue.id)}
disabled={active}
>
<History className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{label}
</span>
<span className="block truncate text-xs text-muted-foreground">
{agentLabel ? `${agentLabel} · ` : ""}updated {relativeTime(issue.updatedAt)}
</span>
</span>
</button>
);
})}
</div>
)}
<div className="relative flex shrink-0 items-center justify-between gap-2 px-4 py-2">
<p className="min-w-0 truncate text-xs text-muted-foreground">
{activeBoardIssue
? `${selectedCompany?.name ?? "Your company"} · updated ${relativeTime(activeBoardIssue.updatedAt)}`
: selectedCompany?.name ?? "Your company"}
</p>
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="chat history"
onClick={() => setHistoryOpen(true)}
>
<History className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">chat history</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="copy chat link"
onClick={handleCopyLink}
disabled={!currentChatUrl}
>
{copiedLink ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">copy chat link</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="new chat"
onClick={handleNewChat}
disabled={minting}
>
<MessageSquarePlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">new chat</TooltipContent>
</Tooltip>
</div>
</SheetContent>
</Sheet>
</div>
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
<SheetContent side="right" className="w-[min(24rem,100vw)] p-0 sm:max-w-md">
<SheetHeader className="border-b px-4 py-3">
<SheetTitle className="text-sm">Chat history</SheetTitle>
<SheetDescription>
Pick up a previous CEO conversation.
</SheetDescription>
</SheetHeader>
<div className="min-h-0 overflow-y-auto py-2">
<div className="px-3 pb-2">
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-start"
onClick={handleNewChat}
disabled={minting}
>
<MessageSquarePlus className="h-4 w-4" />
New chat
</Button>
</div>
{boardChatIssues.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No previous chats yet.
</div>
) : (
<div className="divide-y divide-border">
{boardChatIssues.map((issue) => {
const active = issue.id === boardIssueId;
const label = boardChatHistoryLabel(issue);
const agentLabel = boardChatHistoryAgentLabel(
issue,
agentsById,
ceoAgent ?? null,
);
return (
<button
key={issue.id}
type="button"
className={cn(
"flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent",
active && "bg-accent/70",
)}
onClick={() => handleSelectConversation(issue)}
disabled={active}
>
<History className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{label}
</span>
<span className="block truncate text-xs text-muted-foreground">
{agentLabel ? `${agentLabel} · ` : ""}updated{" "}
{relativeTime(issue.updatedAt)}
</span>
</span>
</button>
);
})}
</div>
)}
</div>
</SheetContent>
</Sheet>
{/* 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 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div
role="alert"
className="flex max-w-md items-start gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-foreground"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<span className="break-words">
{boardChatUnavailableMessage(directConversation.unavailableReason)}
</span>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => navigate("/board-chat", { replace: true })}
>
Open latest chat
</Button>
</div>
) : boardIssueId ? (
<AssistantChat
key={boardIssueId}
issueId={boardIssueId}
@@ -491,7 +664,10 @@ export function BoardChat() {
</div>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" aria-label="Opening the Conference Room" />
<Loader2
className="h-5 w-5 animate-spin"
aria-label="Opening the Conference Room"
/>
</div>
)}
</div>
@@ -535,6 +711,7 @@ export function BoardChat() {
</SheetContent>
</Sheet>
</div>
</div>
</div>
</>
);
}