mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
Fix conference room empty chat
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2675a82da0
commit
dbe241c6b9
6 files changed
+156
-14
No files matched your search
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={() => {}} />,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user