mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
Fix board chat history controls
This commit is contained in:
1 parent
97ac44664b
commit
ee7cb3141e
4 files changed
+379
-42
No files matched your search
@@ -96,6 +96,10 @@ describe("POST /api/board/chat/stream feature flag guard (PAP-137)", () => {
|
||||
});
|
||||
|
||||
describe("board-chat client disconnect", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeFakeProc() {
|
||||
const proc = new EventEmitter() as any;
|
||||
proc.stdout = new EventEmitter();
|
||||
@@ -141,6 +145,42 @@ describe("board-chat client disconnect", () => {
|
||||
fakeProc.emit("close", 143);
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("tags newly created Board Operations issues as board chat conversations", async () => {
|
||||
mockGetExperimental.mockResolvedValue({ enableConferenceRoomChat: true });
|
||||
mockIssueService.list.mockResolvedValue([]);
|
||||
mockIssueService.create.mockResolvedValue({ id: "issue-new" });
|
||||
mockIssueService.addComment.mockResolvedValue({ id: "comment-1" });
|
||||
mockIssueService.listComments.mockResolvedValue([]);
|
||||
const fakeProc = makeFakeProc();
|
||||
mockSpawn.mockReturnValue(fakeProc);
|
||||
const app = await createApp();
|
||||
|
||||
const req = request(app)
|
||||
.post("/api/board/chat/stream")
|
||||
.set("Content-Type", "application/json")
|
||||
.send(JSON.stringify({ companyId: "company-1", message: "hello" }));
|
||||
const pending = req.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
|
||||
fakeProc.exitCode = 0;
|
||||
fakeProc.emit("close", 0);
|
||||
|
||||
expect(mockIssueService.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
title: "Board Operations",
|
||||
originKind: "board_chat",
|
||||
}));
|
||||
expect(mockIssueService.addComment).toHaveBeenCalledWith(
|
||||
"issue-new",
|
||||
"hello",
|
||||
expect.any(Object),
|
||||
);
|
||||
req.abort();
|
||||
await pending;
|
||||
});
|
||||
});
|
||||
|
||||
describe("board-chat history role classification", () => {
|
||||
|
||||
@@ -123,11 +123,13 @@ export function boardChatRoutes(
|
||||
return;
|
||||
}
|
||||
|
||||
const { companyId, message, taskId } = req.body as {
|
||||
const { companyId, message, taskId, newConversation } = req.body as {
|
||||
companyId?: string;
|
||||
message?: string;
|
||||
taskId?: string;
|
||||
newConversation?: boolean | string;
|
||||
};
|
||||
const wantsNewConversation = newConversation === true || newConversation === "true";
|
||||
|
||||
if (!companyId || !message) {
|
||||
res.status(400).json({ error: "companyId and message are required" });
|
||||
@@ -150,25 +152,33 @@ export function boardChatRoutes(
|
||||
}
|
||||
|
||||
const issueSvc = issueService(db);
|
||||
let issueId = taskId;
|
||||
let issueId = wantsNewConversation ? undefined : taskId;
|
||||
|
||||
// Find or create the standing "Board Operations" issue that anchors the
|
||||
// board conversation + decision log.
|
||||
if (!issueId) {
|
||||
const companyIssues = await issueSvc.list(companyId, { q: "Board Operations" });
|
||||
const boardIssue = companyIssues.find(
|
||||
(i) =>
|
||||
i.title === "Board Operations" &&
|
||||
i.status !== "done" &&
|
||||
i.status !== "cancelled",
|
||||
);
|
||||
if (boardIssue) {
|
||||
issueId = boardIssue.id;
|
||||
} else {
|
||||
if (!wantsNewConversation) {
|
||||
const companyIssues = await issueSvc.list(companyId, {
|
||||
q: "Board Operations",
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
});
|
||||
const boardIssue = companyIssues.find(
|
||||
(i) =>
|
||||
i.title === "Board Operations" &&
|
||||
i.status !== "done" &&
|
||||
i.status !== "cancelled",
|
||||
);
|
||||
if (boardIssue) {
|
||||
issueId = boardIssue.id;
|
||||
}
|
||||
}
|
||||
if (!issueId) {
|
||||
const created = await issueSvc.create(companyId, {
|
||||
title: "Board Operations",
|
||||
description:
|
||||
"Standing issue for board concierge conversations and decision log",
|
||||
originKind: "board_chat",
|
||||
// `todo` rather than `in_progress`: this is an unassigned standing
|
||||
// issue, and the service rejects in_progress issues without an
|
||||
// assignee.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, forwardRef, useImperativeHandle } from "react";
|
||||
import { forwardRef, useImperativeHandle } from "react";
|
||||
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -73,12 +74,27 @@ vi.mock("@/components/ui/tooltip", () => ({
|
||||
vi.mock("@/components/ui/sheet", () => ({
|
||||
Sheet: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SheetTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
SheetContent: () => null,
|
||||
SheetContent: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="sheet-content">{children}</div>
|
||||
),
|
||||
SheetHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SheetTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SheetDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
await Promise.resolve();
|
||||
flushSync(() => {});
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
@@ -92,7 +108,22 @@ const CEO_AGENT = {
|
||||
status: "active",
|
||||
icon: null,
|
||||
};
|
||||
const BOARD_ISSUE = { id: "issue-board", title: "Board Operations", status: "in_progress" };
|
||||
const BOARD_ISSUE = {
|
||||
id: "issue-board",
|
||||
identifier: "PAP-1",
|
||||
title: "Board Operations",
|
||||
status: "in_progress",
|
||||
createdAt: "2026-06-10T00:00:00.000Z",
|
||||
updatedAt: "2026-06-10T00:00:00.000Z",
|
||||
};
|
||||
const OLDER_BOARD_ISSUE = {
|
||||
id: "issue-board-old",
|
||||
identifier: "PAP-0",
|
||||
title: "Board Operations",
|
||||
status: "todo",
|
||||
createdAt: "2026-06-09T00:00:00.000Z",
|
||||
updatedAt: "2026-06-09T00:00:00.000Z",
|
||||
};
|
||||
const USER_COMMENT = {
|
||||
id: "comment-user-1",
|
||||
body: "Hi Alex!",
|
||||
@@ -304,4 +335,84 @@ describe("BoardChat staged typing intro", () => {
|
||||
|
||||
expect(mockChatComposerProps.at(-1)?.submitKey).toBe("mod-enter");
|
||||
});
|
||||
|
||||
it("starts a fresh server-side conversation after New chat is clicked", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: "start", issueId: "issue-new" })}\n\n` +
|
||||
`data: ${JSON.stringify({ type: "done", issueId: "issue-new" })}\n\n`,
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" } });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await render();
|
||||
|
||||
const newChatButton = container.querySelector(
|
||||
'button[aria-label="new chat"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(newChatButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
newChatButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
(mockChatComposerProps.at(-1)?.onChange as (value: string) => void)("Fresh start");
|
||||
});
|
||||
await act(async () => {
|
||||
(mockChatComposerProps.at(-1)?.onSubmit as () => void)();
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
|
||||
expect(body).toEqual({
|
||||
companyId: "company-1",
|
||||
message: "Fresh start",
|
||||
newConversation: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("switches comment history when a prior chat is selected", async () => {
|
||||
mockIssuesApi.list.mockResolvedValue([BOARD_ISSUE, OLDER_BOARD_ISSUE]);
|
||||
mockIssuesApi.listComments.mockImplementation(async (issueId: string) =>
|
||||
issueId === OLDER_BOARD_ISSUE.id
|
||||
? [{ ...USER_COMMENT, id: "comment-old", body: "Older chat" }]
|
||||
: [],
|
||||
);
|
||||
await render();
|
||||
|
||||
expect(mockIssuesApi.listComments).toHaveBeenCalledWith(BOARD_ISSUE.id);
|
||||
|
||||
const historyButton = container.querySelector(
|
||||
'button[aria-label="chat history"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(historyButton).not.toBeNull();
|
||||
await act(async () => {
|
||||
historyButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const olderButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes(OLDER_BOARD_ISSUE.identifier),
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(olderButton).toBeDefined();
|
||||
await act(async () => {
|
||||
olderButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
expect(mockIssuesApi.listComments).toHaveBeenCalledWith(OLDER_BOARD_ISSUE.id);
|
||||
expect(container.textContent).toContain("Older chat");
|
||||
});
|
||||
});
|
||||
+203
-27
@@ -22,7 +22,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Activity, ArrowDown, History, MessageSquarePlus, X } from "lucide-react";
|
||||
import { Activity, ArrowDown, History, MessageSquarePlus } from "lucide-react";
|
||||
import { ActivityFeed } from "../components/ActivityFeed";
|
||||
import { ChatComposer, type ChatComposerHandle } from "../components/ChatComposer";
|
||||
import {
|
||||
@@ -30,9 +30,16 @@ import {
|
||||
agentBubbleDateLabel,
|
||||
} from "../components/AgentBubbleActionRow";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import { cn, formatDateTime } from "../lib/utils";
|
||||
import { cn, formatDateTime, relativeTime } from "../lib/utils";
|
||||
import type { FeedbackVoteValue } from "@paperclipai/shared";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
/**
|
||||
* Board Concierge Chat — a chat interface powered by the board-member skill.
|
||||
@@ -49,6 +56,7 @@ const DEFAULT_CHAT_FRACTION = 2 / 3;
|
||||
/** Wrapped markdown in bubbles; pre/table scroll horizontally when needed. */
|
||||
const BOARD_CHAT_MARKDOWN_CLASS =
|
||||
"max-w-full overflow-visible [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_table]:block [&_table]:max-w-full [&_table]:overflow-x-auto";
|
||||
const BOARD_CHAT_ISSUE_TITLE = "Board Operations";
|
||||
|
||||
const boardChatBubbleShell =
|
||||
"min-w-0 max-w-[85%] break-words px-3 py-2 text-sm overflow-x-auto overflow-y-visible";
|
||||
@@ -190,6 +198,8 @@ export function BoardChat() {
|
||||
const [statusText, setStatusText] = useState("");
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const [boardIssueId, setBoardIssueId] = useState<string | null>(null);
|
||||
const [isComposingNewConversation, setIsComposingNewConversation] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [elapsedSec, setElapsedSec] = useState(0);
|
||||
const [optimisticMessage, setOptimisticMessage] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -233,8 +243,11 @@ export function BoardChat() {
|
||||
queryClient.removeQueries({ queryKey: queryKeys.issues.comments(boardIssueId) });
|
||||
}
|
||||
setBoardIssueId(null);
|
||||
setIsComposingNewConversation(false);
|
||||
setHistoryOpen(false);
|
||||
setStreamingText("");
|
||||
setStatusText("");
|
||||
setErrorText("");
|
||||
setSending(false);
|
||||
setOptimisticMessage(null);
|
||||
prevCompanyRef.current = selectedCompanyId;
|
||||
@@ -297,29 +310,125 @@ export function BoardChat() {
|
||||
return active?.title ?? null;
|
||||
}, [goals]);
|
||||
|
||||
// Find or detect the board operations issue
|
||||
// Find or detect board chat conversations. They are backed by ordinary
|
||||
// company-scoped issues so history survives reloads without a separate chat
|
||||
// store.
|
||||
const { data: issues } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
q: BOARD_CHAT_ISSUE_TITLE,
|
||||
limit: 50,
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const boardChatIssues = useMemo(
|
||||
() =>
|
||||
(issues ?? [])
|
||||
.filter(
|
||||
(i) =>
|
||||
i.title === BOARD_CHAT_ISSUE_TITLE &&
|
||||
i.status !== "cancelled",
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
),
|
||||
[issues],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!issues) {
|
||||
setBoardIssueId(null);
|
||||
if (!issues || isComposingNewConversation) {
|
||||
if (!issues) setBoardIssueId(null);
|
||||
return;
|
||||
}
|
||||
const boardIssue = issues.find(
|
||||
(i) => i.title === "Board Operations" && i.status !== "done" && i.status !== "cancelled",
|
||||
);
|
||||
setBoardIssueId(boardIssue?.id ?? null);
|
||||
}, [issues]);
|
||||
if (boardIssueId && boardChatIssues.some((issue) => issue.id === boardIssueId)) {
|
||||
return;
|
||||
}
|
||||
const boardIssue = boardChatIssues.find((i) => i.status !== "done");
|
||||
setBoardIssueId(boardIssue?.id ?? boardChatIssues[0]?.id ?? null);
|
||||
}, [issues, boardChatIssues, boardIssueId, isComposingNewConversation]);
|
||||
|
||||
// Fetch comments for the board issue
|
||||
const activeBoardIssue = useMemo(
|
||||
() => boardChatIssues.find((issue) => issue.id === boardIssueId) ?? null,
|
||||
[boardChatIssues, boardIssueId],
|
||||
);
|
||||
|
||||
const scrollStorageKey = useMemo(
|
||||
() =>
|
||||
selectedCompanyId
|
||||
? `paperclip.boardChat.scrollTop.${selectedCompanyId}.${boardIssueId ?? "new"}`
|
||||
: null,
|
||||
[selectedCompanyId, boardIssueId],
|
||||
);
|
||||
|
||||
const resetConversationViewport = useCallback(() => {
|
||||
hasRestoredScrollRef.current = false;
|
||||
wasNearBottomRef.current = true;
|
||||
setHasNewBelow(false);
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (typeof scrollContainer?.scrollTo === "function") {
|
||||
scrollContainer.scrollTo({ top: 0 });
|
||||
} else if (scrollContainer) {
|
||||
scrollContainer.scrollTop = 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
if (sending) return;
|
||||
if (boardIssueId) {
|
||||
queryClient.removeQueries({ queryKey: queryKeys.issues.comments(boardIssueId) });
|
||||
}
|
||||
setBoardIssueId(null);
|
||||
setIsComposingNewConversation(true);
|
||||
setHistoryOpen(false);
|
||||
setStreamingText("");
|
||||
setStatusText("");
|
||||
setErrorText("");
|
||||
setOptimisticMessage(null);
|
||||
setWelcomeRevealed(false);
|
||||
setChipsRevealed(false);
|
||||
setInput("");
|
||||
resetConversationViewport();
|
||||
composerRef.current?.focus();
|
||||
}, [boardIssueId, queryClient, resetConversationViewport, sending]);
|
||||
|
||||
const handleSelectConversation = useCallback(
|
||||
(issueId: string) => {
|
||||
if (sending) return;
|
||||
setBoardIssueId(issueId);
|
||||
setIsComposingNewConversation(false);
|
||||
setHistoryOpen(false);
|
||||
setStreamingText("");
|
||||
setStatusText("");
|
||||
setErrorText("");
|
||||
setOptimisticMessage(null);
|
||||
resetConversationViewport();
|
||||
},
|
||||
[resetConversationViewport, sending],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
resetConversationViewport();
|
||||
}, [boardIssueId, resetConversationViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCompanyId || boardIssueId || isComposingNewConversation) return;
|
||||
if (!issues) return;
|
||||
if (boardChatIssues.length > 0) return;
|
||||
setIsComposingNewConversation(true);
|
||||
}, [selectedCompanyId, boardIssueId, isComposingNewConversation, issues, boardChatIssues.length]);
|
||||
|
||||
// Fetch comments for the selected board issue. New Chat intentionally has
|
||||
// no issue id until the first message is sent, so it renders as an empty
|
||||
// conversation instead of snapping back to the latest historical issue.
|
||||
const { data: comments } = useQuery({
|
||||
queryKey: queryKeys.issues.comments(boardIssueId ?? ""),
|
||||
queryFn: () => issuesApi.listComments(boardIssueId!),
|
||||
enabled: !!boardIssueId,
|
||||
enabled: !!boardIssueId && !isComposingNewConversation,
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
@@ -338,7 +447,7 @@ export function BoardChat() {
|
||||
const { data: feedbackVotes } = useQuery({
|
||||
queryKey: queryKeys.issues.feedbackVotes(boardIssueId ?? ""),
|
||||
queryFn: () => issuesApi.listFeedbackVotes(boardIssueId!),
|
||||
enabled: !!boardIssueId,
|
||||
enabled: !!boardIssueId && !isComposingNewConversation,
|
||||
});
|
||||
|
||||
const voteByComment = useMemo(() => {
|
||||
@@ -355,7 +464,7 @@ export function BoardChat() {
|
||||
vote: FeedbackVoteValue,
|
||||
options?: { allowSharing?: boolean; reason?: string },
|
||||
) => {
|
||||
if (!boardIssueId) return;
|
||||
if (!boardIssueId || isComposingNewConversation) return;
|
||||
await issuesApi.upsertFeedbackVote(boardIssueId, {
|
||||
targetType: "issue_comment",
|
||||
targetId: commentId,
|
||||
@@ -367,7 +476,7 @@ export function BoardChat() {
|
||||
queryKey: queryKeys.issues.feedbackVotes(boardIssueId),
|
||||
});
|
||||
},
|
||||
[boardIssueId, queryClient],
|
||||
[boardIssueId, isComposingNewConversation, queryClient],
|
||||
);
|
||||
|
||||
// Reset the staged reveal on mount AND whenever the active company
|
||||
@@ -460,7 +569,8 @@ export function BoardChat() {
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const saved = sessionStorage.getItem("paperclip.boardChat.scrollTop");
|
||||
if (!scrollStorageKey) return;
|
||||
const saved = sessionStorage.getItem(scrollStorageKey);
|
||||
if (saved != null) {
|
||||
const parsed = Number(saved);
|
||||
if (Number.isFinite(parsed)) {
|
||||
@@ -473,7 +583,7 @@ export function BoardChat() {
|
||||
|
||||
container.scrollTop = container.scrollHeight;
|
||||
hasRestoredScrollRef.current = true;
|
||||
}, [sortedComments.length]);
|
||||
}, [sortedComments.length, scrollStorageKey]);
|
||||
|
||||
// User sent a message: always scroll so their just-typed message is in
|
||||
// view, even if they were scrolled up reading history.
|
||||
@@ -510,10 +620,9 @@ export function BoardChat() {
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
"paperclip.boardChat.scrollTop",
|
||||
String(container.scrollTop),
|
||||
);
|
||||
if (scrollStorageKey) {
|
||||
sessionStorage.setItem(scrollStorageKey, String(container.scrollTop));
|
||||
}
|
||||
} catch { /* sessionStorage unavailable */ }
|
||||
});
|
||||
};
|
||||
@@ -522,7 +631,7 @@ export function BoardChat() {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
if (rafId != null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, []);
|
||||
}, [scrollStorageKey]);
|
||||
|
||||
// Elapsed timer for thinking state — tick at 100ms so the tenths place
|
||||
// updates smoothly and the wait feels quicker than a whole-second counter.
|
||||
@@ -567,7 +676,8 @@ export function BoardChat() {
|
||||
body: JSON.stringify({
|
||||
companyId: selectedCompanyId,
|
||||
message: trimmed,
|
||||
taskId: boardIssueId ?? undefined,
|
||||
taskId: isComposingNewConversation ? undefined : boardIssueId ?? undefined,
|
||||
newConversation: isComposingNewConversation,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -608,6 +718,7 @@ export function BoardChat() {
|
||||
setStatusText(event.text);
|
||||
} else if (event.type === "start" && event.issueId) {
|
||||
setBoardIssueId(event.issueId);
|
||||
setIsComposingNewConversation(false);
|
||||
} else if (event.type === "error") {
|
||||
setErrorText(
|
||||
event.message ||
|
||||
@@ -648,7 +759,7 @@ export function BoardChat() {
|
||||
composerRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[sending, selectedCompanyId, boardIssueId, queryClient],
|
||||
[sending, selectedCompanyId, boardIssueId, isComposingNewConversation, queryClient],
|
||||
);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
@@ -702,7 +813,11 @@ export function BoardChat() {
|
||||
{ceoAgent?.name ?? "Conference Room"}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedCompany?.name ?? "Your company"}
|
||||
{isComposingNewConversation
|
||||
? "New chat"
|
||||
: activeBoardIssue
|
||||
? `${selectedCompany?.name ?? "Your company"} · updated ${relativeTime(activeBoardIssue.updatedAt)}`
|
||||
: selectedCompany?.name ?? "Your company"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
@@ -714,6 +829,7 @@ export function BoardChat() {
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="chat history"
|
||||
onClick={() => setHistoryOpen(true)}
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -728,6 +844,8 @@ export function BoardChat() {
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="new chat"
|
||||
onClick={handleNewChat}
|
||||
disabled={sending || isComposingNewConversation}
|
||||
>
|
||||
<MessageSquarePlus className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -736,6 +854,64 @@ export function BoardChat() {
|
||||
</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={sending || isComposingNewConversation}
|
||||
>
|
||||
<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 && !isComposingNewConversation;
|
||||
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={sending || 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">
|
||||
{issue.identifier ?? issue.title}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
Updated {relativeTime(issue.updatedAt)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{/* Messages — scroll viewport flush right so the scrollbar sits on the pane/divider edge */}
|
||||
<div className="relative min-h-0 min-w-0 flex-1">
|
||||
<div
|
||||
|
||||
Reference in new issue
Block a user