Fix conference room empty chat

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-01 19:52:46 +00:00
1 parent 2675a82da0
commit dbe241c6b9
6 files changed
+156 -14

No files matched your search

+9 -2
View File
@@ -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<IssueChatComposerHandle>();
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<IssueChatComposerHandle>();
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(() => {
+17 -8
View File
@@ -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<IssueChatComposerHandle, IssueChatComposerP
);
focusComposer();
},
setDraft: (nextBody: string) => {
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 ? (
<div className={cn(
"text-center text-sm text-muted-foreground",
variant === "embedded"
? "rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6"
: "rounded-2xl border border-dashed border-border bg-card px-6 py-10",
)}>
{resolvedEmptyMessage}
</div>
emptyState ?? (
<div className={cn(
"text-center text-sm text-muted-foreground",
variant === "embedded"
? "rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6"
: "rounded-2xl border border-dashed border-border bg-card px-6 py-10",
)}>
{resolvedEmptyMessage}
</div>
)
) : messages.length >= VIRTUALIZED_THREAD_ROW_THRESHOLD ? (
<VirtualizedIssueChatThreadList
ref={virtualizedThreadRef}
+47 -1
View File
@@ -16,18 +16,26 @@ import type { IssueChatComment } from "../lib/issue-chat-messages";
vi.mock("./IssueChatThread", () => ({
IssueChatThread: (props: {
emptyMessage?: string;
emptyState?: ReactNode;
showJumpToLatest?: boolean;
onAdd: (b: string) => Promise<void>;
backgroundWorkChildren?: unknown[];
suppressIssueStatusNotices?: boolean;
composerHint?: string | null;
imageUploadHandler?: (file: File) => Promise<string>;
onAttachImage?: (file: File) => Promise<unknown>;
}) => (
<div data-testid="issue-chat-thread">
<span data-testid="empty-message">{props.emptyMessage}</span>
<span data-testid="empty-message">{props.emptyState ? "" : props.emptyMessage}</span>
<div data-testid="empty-state">{props.emptyState}</div>
<span data-testid="jump-to-latest">{String(props.showJumpToLatest)}</span>
<span data-testid="background-work-count">{props.backgroundWorkChildren?.length ?? 0}</span>
<span data-testid="status-notices">
{props.suppressIssueStatusNotices ? "suppressed" : "visible"}
</span>
<span data-testid="composer-hint">{props.composerHint}</span>
<span data-testid="image-upload-enabled">{String(Boolean(props.imageUploadHandler))}</span>
<span data-testid="attach-enabled">{String(Boolean(props.onAttachImage))}</span>
<button type="button" data-testid="send" onClick={() => void props.onAdd("hello")}>
send
</button>
@@ -235,6 +243,44 @@ describe("SelectedAgentChatView", () => {
);
});
it("renders Conference Room starter prompts without the large start-message placeholder", () => {
render(
<SelectedAgentChatView
agents={[ceo]}
targetAgentId={ceo.id}
comments={[]}
conferenceRoomMode
companyName="Acme Robotics"
onSend={async () => {}}
/>,
);
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(
<SelectedAgentChatView
agents={[ceo]}
targetAgentId={ceo.id}
comments={[]}
imageUploadHandler={async () => "/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(
<SelectedAgentChatView agents={[ceo]} targetAgentId={ceo.id} comments={[]} onSend={async () => {}} onTargetAgentChange={() => {}} />,
+78 -2
View File
@@ -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<string, string>;
/** 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<string>;
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
/** 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<IssueChatComposerHandle>(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 ? (
<div data-testid="selected-agent-chat-starter-prompts" className="space-y-2">
<div className="flex flex-wrap gap-2">
{starterPrompts.map((prompt) => (
<button
key={prompt.label}
type="button"
className="rounded-full border border-border bg-card px-3 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
onClick={() => composerRef.current?.setDraft(prompt.prompt)}
>
{prompt.label}
</button>
))}
</div>
</div>
) : undefined;
return (
<div className={cn("flex min-h-0 min-w-0 flex-1 flex-col", className)}>
@@ -283,6 +334,7 @@ export function SelectedAgentChatView({
>
<IssueChatThread
variant="full"
composerRef={composerRef}
comments={comments}
interactions={interactions}
liveRuns={liveRuns}
@@ -299,7 +351,10 @@ export function SelectedAgentChatView({
emptyMessage={
emptyMessage ?? `Send ${targetName} a message to start the conversation.`
}
emptyState={emptyState}
composerHint={composerHint}
imageUploadHandler={imageUploadHandler}
onAttachImage={onAttachImage}
onAdd={handleAdd}
onStopRun={onStopRun}
onVote={onVote}
@@ -307,6 +362,7 @@ export function SelectedAgentChatView({
onRejectInteraction={onRejectInteraction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction}
showJumpToLatest={!conferenceRoomMode}
/>
</div>
)}
@@ -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<void>;
@@ -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 (
<SelectedAgentChatView
agents={agents}
@@ -543,7 +615,11 @@ export function SelectedAgentChat({
currentUserId={currentUserId}
backgroundWorkChildren={backgroundWorkChildren}
suppressIssueStatusNotices={conferenceRoomMode}
conferenceRoomMode={conferenceRoomMode}
companyName={companyName}
composerHint={hasOpenBackgroundWork ? "Ask me anything while I work on this." : null}
imageUploadHandler={handleImageUpload}
onAttachImage={handleAttachFile}
loading={commentsQuery.isLoading}
errorText={errorText}
onRetry={errorText ? () => setErrorText(null) : undefined}
+3
View File
@@ -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 () => {
+2 -1
View File
@@ -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"
/>