mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
Implement stable conference room chat URLs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
0e6f0f1fd8
commit
a30c25a50c
6 files changed
+579
-162
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user