From dbe241c6b9e5cb372ed9f11f27cbee1f214f7ea8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 1 Jul 2026 19:52:46 +0000 Subject: [PATCH] Fix conference room empty chat Co-Authored-By: Paperclip --- ui/src/components/IssueChatThread.test.tsx | 11 ++- ui/src/components/IssueChatThread.tsx | 25 ++++-- ui/src/components/SelectedAgentChat.test.tsx | 48 +++++++++++- ui/src/components/SelectedAgentChat.tsx | 80 +++++++++++++++++++- ui/src/pages/BoardChat.test.tsx | 3 + ui/src/pages/BoardChat.tsx | 3 +- 6 files changed, 156 insertions(+), 14 deletions(-) diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 29285804d0f..9d0244ae4b8 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -16,6 +16,7 @@ import { resolveAssistantMessageFoldedState, resolveIssueChatHumanAuthor, } from "./IssueChatThread"; +import type { IssueChatComposerHandle } from "./IssueChatThread"; import type { AskUserQuestionsInteraction, RequestConfirmationInteraction, @@ -3179,7 +3180,7 @@ describe("IssueChatThread", () => { it("exposes a composer focus handle that forwards to the editor", () => { const root = createRoot(container); - const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>(); + const composerRef = createRef(); const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {}); const requestAnimationFrameMock = vi .spyOn(window, "requestAnimationFrame") @@ -3228,7 +3229,7 @@ describe("IssueChatThread", () => { it("restores a cancelled queued draft into the composer handle", () => { const root = createRoot(container); - const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>(); + const composerRef = createRef(); const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {}); const requestAnimationFrameMock = vi .spyOn(window, "requestAnimationFrame") @@ -3264,6 +3265,12 @@ describe("IssueChatThread", () => { expect(markdownEditorFocusMock).toHaveBeenCalledTimes(1); expect(scrollByMock).toHaveBeenCalledWith({ top: 96, behavior: "smooth" }); + act(() => { + composerRef.current?.setDraft("Starter prompt"); + }); + + expect(editor?.value).toBe("Starter prompt"); + scrollByMock.mockRestore(); requestAnimationFrameMock.mockRestore(); act(() => { diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 0153f4f6aaa..d778af0599a 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -331,6 +331,7 @@ export function shouldRenderComposerHandoffPreview(body: string, preview: Compos export interface IssueChatComposerHandle { focus: () => void; restoreDraft: (submittedBody: string) => void; + setDraft: (body: string) => void; } interface IssueChatComposerProps { @@ -416,6 +417,7 @@ interface IssueChatThreadProps { showJumpToLatest?: boolean; autoScrollToLatestOnInitialLoad?: boolean; emptyMessage?: string; + emptyState?: ReactNode; footer?: ReactNode; variant?: "full" | "embedded"; enableLiveTranscriptPolling?: boolean; @@ -3557,6 +3559,10 @@ const IssueChatComposer = forwardRef { + setBody(nextBody); + focusComposer(); + }, }), []); async function handleSubmit() { @@ -4162,6 +4168,7 @@ export function IssueChatThread({ showJumpToLatest, autoScrollToLatestOnInitialLoad = true, emptyMessage, + emptyState, footer, variant = "full", enableLiveTranscriptPolling = true, @@ -4794,14 +4801,16 @@ export function IssueChatThread({ className={variant === "embedded" ? "space-y-3" : "space-y-4"} > {messages.length === 0 ? ( -
- {resolvedEmptyMessage} -
+ emptyState ?? ( +
+ {resolvedEmptyMessage} +
+ ) ) : messages.length >= VIRTUALIZED_THREAD_ROW_THRESHOLD ? ( ({ IssueChatThread: (props: { emptyMessage?: string; + emptyState?: ReactNode; + showJumpToLatest?: boolean; onAdd: (b: string) => Promise; backgroundWorkChildren?: unknown[]; suppressIssueStatusNotices?: boolean; composerHint?: string | null; + imageUploadHandler?: (file: File) => Promise; + onAttachImage?: (file: File) => Promise; }) => (
- {props.emptyMessage} + {props.emptyState ? "" : props.emptyMessage} +
{props.emptyState}
+ {String(props.showJumpToLatest)} {props.backgroundWorkChildren?.length ?? 0} {props.suppressIssueStatusNotices ? "suppressed" : "visible"} {props.composerHint} + {String(Boolean(props.imageUploadHandler))} + {String(Boolean(props.onAttachImage))} @@ -235,6 +243,44 @@ describe("SelectedAgentChatView", () => { ); }); + it("renders Conference Room starter prompts without the large start-message placeholder", () => { + render( + {}} + />, + ); + + expect(container.textContent).toContain("Draft a Company Brief"); + expect(container.textContent).toContain("Create a hiring plan"); + expect(container.textContent).toContain("Outline our first 30 days"); + expect(container.textContent).toContain("Write an intro pitch"); + expect(container.querySelector('[data-testid="empty-message"]')?.textContent).not.toContain( + "Send Sarah a message to start the conversation.", + ); + expect(container.querySelector('[data-testid="jump-to-latest"]')?.textContent).toBe("false"); + }); + + it("forwards attachment handlers to the shared issue chat composer", () => { + render( + "/api/attachments/image/content"} + onAttachImage={async () => undefined} + onSend={async () => {}} + />, + ); + + expect(container.querySelector('[data-testid="image-upload-enabled"]')?.textContent).toBe("true"); + expect(container.querySelector('[data-testid="attach-enabled"]')?.textContent).toBe("true"); + }); + it("offers the switcher only when more than one agent is invokable", () => { render( {}} onTargetAgentChange={() => {}} />, diff --git a/ui/src/components/SelectedAgentChat.tsx b/ui/src/components/SelectedAgentChat.tsx index 416421f6bbd..ccfb0cda0be 100644 --- a/ui/src/components/SelectedAgentChat.tsx +++ b/ui/src/components/SelectedAgentChat.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AGENT_ROLE_LABELS, @@ -7,6 +7,7 @@ import { type AskUserQuestionsInteraction, type FeedbackVote, type FeedbackVoteValue, + type IssueAttachment, type IssueRelationIssueSummary, type IssueThreadInteraction, type RequestCheckboxConfirmationInteraction, @@ -14,7 +15,7 @@ import { type SuggestTasksInteraction, } from "@paperclipai/shared"; import { AlertCircle, Loader2 } from "lucide-react"; -import { IssueChatThread } from "./IssueChatThread"; +import { IssueChatThread, type IssueChatComposerHandle } from "./IssueChatThread"; import { AgentIcon } from "./AgentIconPicker"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; @@ -38,6 +39,27 @@ const roleLabels = AGENT_ROLE_LABELS as Record; /** Poll cadence for the issue-backed conversation while the surface is open. */ const SELECTED_AGENT_CHAT_POLL_MS = 3000; +function buildStarterPrompts(companyName: string) { + return [ + { + label: "Draft a Company Brief", + prompt: `Draft a one-page Company Brief for ${companyName} - include our mission, team roster, and first priorities.`, + }, + { + label: "Create a hiring plan", + prompt: `Create a hiring plan for ${companyName}. List the next roles to hire, in priority order, with a short rationale for each.`, + }, + { + label: "Outline our first 30 days", + prompt: "Outline our first 30 days. Break it into weekly priorities with who owns what.", + }, + { + label: "Write an intro pitch", + prompt: `Write a short intro pitch for ${companyName} that I could reuse for investors, customers, or recruits.`, + }, + ]; +} + function agentInitials(name: string): string { const parts = name.trim().split(/\s+/); return (((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase()) || "A"; @@ -98,7 +120,11 @@ export interface SelectedAgentChatViewProps { currentUserId?: string | null; backgroundWorkChildren?: IssueRelationIssueSummary[]; suppressIssueStatusNotices?: boolean; + conferenceRoomMode?: boolean; + companyName?: string | null; composerHint?: string | null; + imageUploadHandler?: (file: File) => Promise; + onAttachImage?: (file: File) => Promise; /** True while the first comments fetch is in flight (no data yet). */ loading?: boolean; /** Surface a delivery/transport failure inline (CR8). */ @@ -149,7 +175,11 @@ export function SelectedAgentChatView({ currentUserId, backgroundWorkChildren = [], suppressIssueStatusNotices = false, + conferenceRoomMode = false, + companyName = null, composerHint = null, + imageUploadHandler, + onAttachImage, loading = false, errorText, onRetry, @@ -164,6 +194,7 @@ export function SelectedAgentChatView({ className, }: SelectedAgentChatViewProps) { const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a] as const)), [agents]); + const composerRef = useRef(null); const targetAgent = targetAgentId ? agentMap.get(targetAgentId) ?? null : null; const invokableAgents = useMemo(() => orderInvokableAgents(agents), [agents]); @@ -187,6 +218,26 @@ export function SelectedAgentChatView({ const targetName = targetAgent?.name ?? "Assistant"; const targetRole = targetAgent ? roleLabels[targetAgent.role] ?? targetAgent.role : null; const canSwitch = showAgentSwitcher && invokableAgents.length > 1 && !!onTargetAgentChange; + const starterPrompts = useMemo( + () => buildStarterPrompts(companyName?.trim() || "this company"), + [companyName], + ); + const emptyState = conferenceRoomMode ? ( +
+
+ {starterPrompts.map((prompt) => ( + + ))} +
+
+ ) : undefined; return (
@@ -283,6 +334,7 @@ export function SelectedAgentChatView({ >
)} @@ -324,6 +380,7 @@ export interface SelectedAgentChatProps { defaultTargetAgentId?: string | null; showAgentSwitcher?: boolean; conferenceRoomMode?: boolean; + companyName?: string | null; currentUserId?: string | null; emptyMessage?: string; onMessageSent?: () => void | Promise; @@ -344,6 +401,7 @@ export function SelectedAgentChat({ defaultTargetAgentId, showAgentSwitcher = true, conferenceRoomMode = false, + companyName = null, currentUserId, emptyMessage, onMessageSent, @@ -526,6 +584,20 @@ export function SelectedAgentChat({ [issueId, invalidateRuns], ); + const handleImageUpload = useCallback(async (file: File) => { + const attachment = await issuesApi.uploadAttachment(companyId, issueId, file); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId) }); + return attachment.contentPath; + }, [companyId, issueId, queryClient]); + + const handleAttachFile = useCallback(async (file: File) => { + const attachment = await issuesApi.uploadAttachment(companyId, issueId, file); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId) }); + return attachment; + }, [companyId, issueId, queryClient]); + return ( setErrorText(null) : undefined} diff --git a/ui/src/pages/BoardChat.test.tsx b/ui/src/pages/BoardChat.test.tsx index 30ac9c514c8..98aa6a5c4b8 100644 --- a/ui/src/pages/BoardChat.test.tsx +++ b/ui/src/pages/BoardChat.test.tsx @@ -205,6 +205,9 @@ describe("BoardChat Conference Room transport", () => { expect(lastProps?.companyId).toBe("company-1"); expect(lastProps?.currentUserId).toBe("user-1"); expect(lastProps?.conferenceRoomMode).toBe(true); + expect(lastProps?.showAgentSwitcher).toBe(false); + expect(lastProps?.companyName).toBe("Acme Robotics"); + expect(lastProps?.emptyMessage).toBeUndefined(); }); it("mints the first conversation when the company has no history", async () => { diff --git a/ui/src/pages/BoardChat.tsx b/ui/src/pages/BoardChat.tsx index 8217bbefa1e..0f656ac113d 100644 --- a/ui/src/pages/BoardChat.tsx +++ b/ui/src/pages/BoardChat.tsx @@ -464,9 +464,10 @@ export function BoardChat() { companyId={selectedCompanyId} agents={agents} defaultTargetAgentId={ceoAgent?.id ?? null} + showAgentSwitcher={false} conferenceRoomMode + companyName={selectedCompany?.name ?? null} currentUserId={currentUserId} - emptyMessage={`Send ${ceoAgent?.name ?? "your CEO"} a message to start the conversation.`} onMessageSent={refreshBoardChatHistory} className="min-h-0 flex-1" />