Promote selected agent chat to assistant chat

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-01 23:09:50 +00:00
1 parent 611df4ed74
commit 6fbf006ebb
7 files changed
+1387 -954

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ import { api } from "./client";
/**
* Conference Room (board chat) transport. The room is backed by an ordinary
* company-scoped `board_chat` issue so history survives reloads; the real
* agent conversation runs over `SelectedAgentChat` (PAP-11099 Phase 3b).
* agent conversation runs over `AssistantChat` (PAP-11099 Phase 3b).
*/
export const boardChatApi = {
/**
+530
View File
@@ -0,0 +1,530 @@
// @vitest-environment jsdom
import type { ReactNode, Ref } 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";
import type { Agent } from "@paperclipai/shared";
import {
ASSISTANT_CHAT_ACTIVE_POLL_MS,
ASSISTANT_CHAT_IDLE_POLL_MS,
AssistantChat,
AssistantChatView,
mergeAssistantChatComments,
resolveAssistantChatPollInterval,
resolveDefaultChatTarget,
type PendingAssistantChatComment,
} from "./AssistantChat";
import type { IssueChatComment } from "../lib/issue-chat-messages";
const mockIssuesApi = vi.hoisted(() => ({
listComments: vi.fn(),
listInteractions: vi.fn(),
get: vi.fn(),
listFeedbackVotes: vi.fn(),
addSelectedAgentChatComment: vi.fn(),
uploadAttachment: vi.fn(),
}));
const mockHeartbeatsApi = vi.hoisted(() => ({
liveRunsForIssue: vi.fn(),
activeRunForIssue: vi.fn(),
cancel: vi.fn(),
}));
const mockComposerDraft = vi.hoisted(() => ({ value: "" }));
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("../api/heartbeats", () => ({ heartbeatsApi: mockHeartbeatsApi }));
// Stub the heavy thread renderer so the View's own wiring (identity header,
// switcher, error/loading states, send pass-through) is what we assert.
vi.mock("./IssueChatThread", async () => {
const React = await vi.importActual<typeof import("react")>("react");
return {
IssueChatThread: (props: {
preset?: string;
comments?: IssueChatComment[];
emptyMessage?: string;
emptyState?: ReactNode;
showJumpToLatest?: boolean;
composerRef?: Ref<{ setDraft: (body: string) => void; restoreDraft: (body: string) => void; focus: () => void }>;
onAdd: (b: string) => Promise<void>;
backgroundWorkChildren?: unknown[];
suppressIssueStatusNotices?: boolean;
composerHint?: string | null;
imageUploadHandler?: (file: File) => Promise<string>;
onAttachImage?: (file: File) => Promise<unknown>;
}) => {
const [draft, setDraft] = React.useState(mockComposerDraft.value || "hello");
React.useImperativeHandle(props.composerRef, () => ({
focus: () => {},
restoreDraft: (body: string) => setDraft(body),
setDraft: (body: string) => setDraft(body),
}), []);
return (
<div data-testid="issue-chat-thread">
<span data-testid="thread-preset">{props.preset}</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 ?? (props.preset === "assistant" ? false : true))}
</span>
<span data-testid="background-work-count">{props.backgroundWorkChildren?.length ?? 0}</span>
<span data-testid="status-notices">
{(props.suppressIssueStatusNotices ?? props.preset === "assistant") ? "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>
<span data-testid="composer-draft">{draft}</span>
<div data-testid="comment-bodies">
{props.comments?.map((comment) => (
<span data-testid="comment-body" key={comment.id}>{comment.body}</span>
))}
</div>
<button
type="button"
data-testid="send"
onClick={() => {
const submitted = draft;
setDraft("");
void props.onAdd(submitted).catch(() => setDraft(submitted));
}}
>
send
</button>
</div>
);
},
};
});
function makeAgent(overrides: Partial<Agent>): Agent {
return {
id: "agent-x",
companyId: "company-1",
name: "Agent X",
urlKey: "agent-x",
role: "engineer",
title: null,
icon: null,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "claude_code",
adapterConfig: {},
runtimeConfig: {} as Agent["runtimeConfig"],
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: {} as Agent["permissions"],
lastHeartbeatAt: null,
...overrides,
} as Agent;
}
const ceo = makeAgent({ id: "agent-ceo", name: "Sarah", role: "ceo" });
const eng = makeAgent({ id: "agent-eng", name: "Dev", role: "engineer" });
const terminated = makeAgent({ id: "agent-dead", name: "Zed", status: "terminated" });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function makeComment(overrides: Partial<IssueChatComment>): IssueChatComment {
const now = new Date("2026-06-10T00:00:00.000Z");
return {
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
createdByRunId: null,
body: "hello",
presentation: null,
metadata: null,
createdAt: now,
updatedAt: now,
...overrides,
} as IssueChatComment;
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function flushQueries() {
for (let i = 0; i < 4; i++) {
await Promise.resolve();
}
await new Promise((resolve) => setTimeout(resolve, 0));
flushSync(() => {});
}
describe("resolveDefaultChatTarget", () => {
it("defaults to the CEO", () => {
expect(resolveDefaultChatTarget([eng, ceo])?.id).toBe("agent-ceo");
});
it("honors a preferred agent when present", () => {
expect(resolveDefaultChatTarget([eng, ceo], "agent-eng")?.id).toBe("agent-eng");
});
it("skips terminated preferred and falls back to CEO", () => {
expect(resolveDefaultChatTarget([ceo, eng, terminated], "agent-dead")?.id).toBe("agent-ceo");
});
it("falls back to first active agent when there is no CEO", () => {
expect(resolveDefaultChatTarget([terminated, eng])?.id).toBe("agent-eng");
});
it("returns null for an empty roster", () => {
expect(resolveDefaultChatTarget([])).toBeNull();
});
});
describe("AssistantChat helpers", () => {
it("dedupes an optimistic client-nonce comment once the poll returns the server comment", () => {
const serverComment = makeComment({ id: "comment-server", body: "hello" });
const pending: PendingAssistantChatComment = {
clientNonce: "nonce-1",
targetAgentId: ceo.id,
body: "hello",
serverCommentId: serverComment.id,
comment: makeComment({
id: "optimistic:nonce-1",
body: "hello",
createdAt: new Date("2026-06-10T00:00:01.000Z"),
}),
};
expect(mergeAssistantChatComments([], [pending]).map((comment) => comment.id)).toEqual([
"optimistic:nonce-1",
]);
expect(mergeAssistantChatComments([serverComment], [pending]).map((comment) => comment.id)).toEqual([
"comment-server",
]);
});
it("switches the polling interval between idle and live run cadences", () => {
expect(resolveAssistantChatPollInterval(false)).toBe(ASSISTANT_CHAT_IDLE_POLL_MS);
expect(resolveAssistantChatPollInterval(true)).toBe(ASSISTANT_CHAT_ACTIVE_POLL_MS);
});
});
describe("AssistantChat", () => {
let container: HTMLDivElement;
let root: Root;
let queryClient: QueryClient;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
mockComposerDraft.value = "hello";
mockIssuesApi.listComments.mockResolvedValue([]);
mockIssuesApi.listInteractions.mockResolvedValue([]);
mockIssuesApi.get.mockResolvedValue({ id: "issue-1", blockedBy: [], blocks: [] });
mockIssuesApi.listFeedbackVotes.mockResolvedValue([]);
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([]);
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue(null);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
queryClient.clear();
vi.clearAllMocks();
});
function renderConnected() {
queryClient.setQueryData(["issues", "comments", "issue-1"], []);
queryClient.setQueryData(["issues", "interactions", "issue-1"], []);
queryClient.setQueryData(["issues", "detail", "issue-1"], { id: "issue-1", blockedBy: [], blocks: [] });
queryClient.setQueryData(["issues", "feedback-votes", "issue-1"], []);
queryClient.setQueryData(["issues", "live-runs", "issue-1", "agent", ceo.id], []);
queryClient.setQueryData(["issues", "active-run", "issue-1", "agent", ceo.id], null);
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<AssistantChat
issueId="issue-1"
companyId="company-1"
agents={[ceo]}
targetAgentId={ceo.id}
currentUserId="user-1"
/>
</QueryClientProvider>,
);
});
}
it("appends an optimistic user comment and dedupes when polled data includes the server comment", async () => {
const createdComment = makeComment({ id: "comment-server", body: "hello" });
const deferred = createDeferred<{ comment: IssueChatComment; targetAgent: { id: string; name: string; role: string; status: string } }>();
mockIssuesApi.addSelectedAgentChatComment.mockReturnValue(deferred.promise);
renderConnected();
await flushQueries();
const send = container.querySelector('[data-testid="send"]') as HTMLButtonElement;
flushSync(() => send.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await flushQueries();
expect(Array.from(container.querySelectorAll('[data-testid="comment-body"]')).map((el) => el.textContent)).toEqual([
"hello",
]);
deferred.resolve({
comment: createdComment,
targetAgent: { id: ceo.id, name: ceo.name, role: ceo.role, status: ceo.status },
});
await flushQueries();
queryClient.setQueryData(["issues", "comments", "issue-1"], [createdComment]);
await flushQueries();
expect(Array.from(container.querySelectorAll('[data-testid="comment-body"]')).map((el) => el.textContent)).toEqual([
"hello",
]);
});
it("preserves the submitted draft and shows an error when send fails", async () => {
mockComposerDraft.value = "keep this";
mockIssuesApi.addSelectedAgentChatComment.mockRejectedValue(new Error("Delivery failed"));
renderConnected();
await flushQueries();
const send = container.querySelector('[data-testid="send"]') as HTMLButtonElement;
flushSync(() => send.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await flushQueries();
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Delivery failed");
expect(container.querySelector('[data-testid="composer-draft"]')?.textContent).toBe("keep this");
expect(container.querySelectorAll('[data-testid="comment-body"]')).toHaveLength(0);
});
});
describe("AssistantChatView", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
mockComposerDraft.value = "";
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
vi.clearAllMocks();
});
function render(node: ReactNode) {
flushSync(() => root.render(node));
}
function countText(text: string, needle: string): number {
return text.split(needle).length - 1;
}
it("renders the real selected-agent identity in the header", () => {
render(
<AssistantChatView
agents={[ceo, eng]}
targetAgentId={ceo.id}
comments={[]}
onSend={async () => {}}
/>,
);
expect(container.textContent).toContain("Sarah");
expect(container.textContent).toContain("CEO");
// No board-concierge persona leaks into the surface.
expect(container.textContent?.toLowerCase()).not.toContain("concierge");
});
it("uses the left-side selector as the selected-agent identity without repeating the role", () => {
render(
<AssistantChatView
agents={[ceo, eng]}
targetAgentId={ceo.id}
comments={[]}
onSend={async () => {}}
onTargetAgentChange={() => {}}
/>,
);
const header = container.querySelector(
'[data-testid="selected-agent-chat-header"]',
) as HTMLDivElement | null;
const switcher = header?.querySelector('[aria-label="Choose chat agent"]');
expect(switcher).not.toBeNull();
expect(switcher?.textContent).toContain("Sarah");
expect(countText(switcher?.textContent ?? "", "CEO")).toBe(1);
expect(header?.textContent).toBe(switcher?.textContent);
});
it("pads the chat body to align with the selected-agent header", () => {
render(
<AssistantChatView
agents={[ceo]}
targetAgentId={ceo.id}
comments={[]}
onSend={async () => {}}
/>,
);
const body = container.querySelector(
'[data-testid="selected-agent-chat-body"]',
) as HTMLDivElement | null;
expect(body).not.toBeNull();
expect(body?.className).toContain("px-4");
expect(body?.className).toContain("pt-3");
expect(body?.className).toContain("pb-4");
});
it("shows a loading indicator while the first fetch is in flight", () => {
render(
<AssistantChatView agents={[ceo]} targetAgentId={ceo.id} comments={[]} loading onSend={async () => {}} />,
);
expect(container.querySelector('[aria-label="Loading conversation"]')).not.toBeNull();
expect(container.querySelector('[data-testid="issue-chat-thread"]')).toBeNull();
});
it("surfaces a delivery error with a Try again affordance", () => {
const onRetry = vi.fn();
render(
<AssistantChatView
agents={[ceo]}
targetAgentId={ceo.id}
comments={[]}
errorText="Could not deliver."
onRetry={onRetry}
onSend={async () => {}}
/>,
);
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain("Could not deliver.");
const retry = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent === "Try again",
);
expect(retry).toBeTruthy();
flushSync(() => retry!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onRetry).toHaveBeenCalledOnce();
});
it("passes a target-specific empty message and pipes sends through", async () => {
const onSend = vi.fn(async () => {});
render(
<AssistantChatView
agents={[ceo]}
targetAgentId={ceo.id}
comments={[]}
starterPrompts={[]}
onSend={onSend}
/>,
);
expect(container.querySelector('[data-testid="empty-message"]')?.textContent).toContain(
"Sarah",
);
const send = container.querySelector('[data-testid="send"]') as HTMLButtonElement;
flushSync(() => send.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await Promise.resolve();
expect(onSend).toHaveBeenCalledWith("hello");
});
it("forwards Conference Room background-work state to the issue thread", () => {
render(
<AssistantChatView
agents={[ceo]}
targetAgentId={ceo.id}
comments={[]}
backgroundWorkChildren={[
{
id: "issue-child",
identifier: "PAP-2",
title: "Background task",
status: "in_progress",
priority: "medium",
assigneeAgentId: ceo.id,
assigneeUserId: null,
},
]}
suppressIssueStatusNotices
composerHint="Ask me anything while I work on this."
onSend={async () => {}}
/>,
);
expect(container.querySelector('[data-testid="background-work-count"]')?.textContent).toBe("1");
expect(container.querySelector('[data-testid="status-notices"]')?.textContent).toBe("suppressed");
expect(container.querySelector('[data-testid="composer-hint"]')?.textContent).toBe(
"Ask me anything while I work on this.",
);
});
it("renders assistant starter prompts without the large start-message placeholder", () => {
render(
<AssistantChatView
agents={[ceo]}
targetAgentId={ceo.id}
comments={[]}
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");
expect(container.querySelector('[data-testid="thread-preset"]')?.textContent).toBe("assistant");
expect(container.querySelector('[data-testid="status-notices"]')?.textContent).toBe("suppressed");
});
it("forwards attachment handlers to the shared issue chat composer", () => {
render(
<AssistantChatView
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(
<AssistantChatView agents={[ceo]} targetAgentId={ceo.id} comments={[]} onSend={async () => {}} onTargetAgentChange={() => {}} />,
);
expect(container.querySelector('[aria-label="Choose chat agent"]')).toBeNull();
render(
<AssistantChatView
agents={[ceo, eng]}
targetAgentId={ceo.id}
comments={[] as IssueChatComment[]}
onSend={async () => {}}
onTargetAgentChange={() => {}}
/>,
);
expect(container.querySelector('[aria-label="Choose chat agent"]')).not.toBeNull();
});
});
+807
View File
@@ -0,0 +1,807 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AGENT_ROLE_LABELS,
type Agent,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
type FeedbackVote,
type FeedbackVoteValue,
type IssueAttachment,
type IssueRelationIssueSummary,
type IssueThreadInteraction,
type RequestCheckboxConfirmationInteraction,
type RequestConfirmationInteraction,
type SuggestTasksInteraction,
} from "@paperclipai/shared";
import { AlertCircle } from "lucide-react";
import { IssueChatThread, type IssueChatComposerHandle } from "./IssueChatThread";
import { AgentIcon } from "./AgentIconPicker";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { MentionOption } from "./MarkdownEditor";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { heartbeatsApi } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import type { IssueChatComment } from "../lib/issue-chat-messages";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
/** Poll cadence for the issue-backed assistant conversation while work is live. */
export const ASSISTANT_CHAT_ACTIVE_POLL_MS = 1500;
/** Poll cadence for the issue-backed assistant conversation while idle. */
export const ASSISTANT_CHAT_IDLE_POLL_MS = 8000;
export interface AssistantChatStarterPrompt {
label: string;
prompt: string;
}
export interface PendingAssistantChatComment {
clientNonce: string;
targetAgentId: string | null;
body: string;
comment: IssueChatComment;
serverCommentId?: string | null;
}
export function resolveAssistantChatPollInterval(hasLiveRun: boolean) {
return hasLiveRun ? ASSISTANT_CHAT_ACTIVE_POLL_MS : ASSISTANT_CHAT_IDLE_POLL_MS;
}
function isLiveRunStatus(status: string | null | undefined) {
return status === "queued" || status === "running";
}
function hasLiveAssistantRun(
liveRuns: readonly LiveRunForIssue[] | undefined,
activeRun: ActiveRunForIssue | null | undefined,
) {
return Boolean(activeRun && isLiveRunStatus(activeRun.status))
|| Boolean(liveRuns?.some((run) => isLiveRunStatus(run.status)));
}
function createClientNonce() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function buildStarterPrompts(companyName: string): AssistantChatStarterPrompt[] {
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 isOptimisticCommentMatched(
serverComments: readonly IssueChatComment[],
pending: PendingAssistantChatComment,
) {
if (pending.serverCommentId) {
return serverComments.some((comment) => comment.id === pending.serverCommentId);
}
const pendingCreatedAt = new Date(pending.comment.createdAt).getTime();
return serverComments.some((comment) => {
if (comment.body !== pending.body) return false;
if (comment.authorType !== pending.comment.authorType) return false;
if (pending.comment.authorUserId && comment.authorUserId !== pending.comment.authorUserId) {
return false;
}
if (pending.comment.authorAgentId && comment.authorAgentId !== pending.comment.authorAgentId) {
return false;
}
return new Date(comment.createdAt).getTime() >= pendingCreatedAt;
});
}
export function mergeAssistantChatComments(
serverComments: readonly IssueChatComment[],
pendingComments: readonly PendingAssistantChatComment[],
): IssueChatComment[] {
const unresolvedPending = pendingComments.filter(
(pending) => !isOptimisticCommentMatched(serverComments, pending),
);
return [
...serverComments,
...unresolvedPending.map((pending) => pending.comment),
].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}
function createOptimisticComment({
issueId,
companyId,
body,
currentUserId,
targetAgentId,
}: {
issueId: string;
companyId: string;
body: string;
currentUserId?: string | null;
targetAgentId: string | null;
}): PendingAssistantChatComment {
const clientNonce = createClientNonce();
const now = new Date();
return {
clientNonce,
targetAgentId,
body,
comment: {
id: `optimistic:${clientNonce}`,
companyId,
issueId,
authorType: "user",
authorAgentId: null,
authorUserId: currentUserId ?? null,
createdByRunId: null,
body,
presentation: null,
metadata: null,
createdAt: now,
updatedAt: now,
} as IssueChatComment,
};
}
function agentInitials(name: string): string {
const parts = name.trim().split(/\s+/);
return (((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase()) || "A";
}
/**
* Pick the default selected-agent chat target: the company CEO (matches the
* server default for `selected-agent-chat/comments`), falling back to an
* explicit preferred id, then the first non-terminated agent.
*/
export function resolveDefaultChatTarget(
agents: readonly Agent[] | undefined,
preferredAgentId?: string | null,
): Agent | null {
if (!agents || agents.length === 0) return null;
const active = agents.filter((a) => a.status !== "terminated");
if (preferredAgentId) {
const preferred = active.find((a) => a.id === preferredAgentId);
if (preferred) return preferred;
}
const ceo = active.find((a) => a.role === "ceo");
if (ceo) return ceo;
return active[0] ?? agents[0] ?? null;
}
/** CEO pinned first, then the rest alphabetically — the switcher ordering. */
function orderInvokableAgents(agents: readonly Agent[]): Agent[] {
return agents
.filter((a) => a.status !== "terminated")
.slice()
.sort((a, b) => {
if (a.role === "ceo" && b.role !== "ceo") return -1;
if (b.role === "ceo" && a.role !== "ceo") return 1;
return a.name.localeCompare(b.name);
});
}
type InteractionAccept =
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction;
export interface AssistantChatViewProps {
/** Agents available as chat targets (the switcher list + bubble identity). */
agents: readonly Agent[];
targetAgentId: string | null;
onTargetAgentChange?: (agentId: string) => void;
/** Hide the switcher (ship-behind-a-flag): identity still renders. */
showAgentSwitcher?: boolean;
comments: IssueChatComment[];
interactions?: IssueThreadInteraction[];
liveRuns?: LiveRunForIssue[];
activeRun?: ActiveRunForIssue | null;
feedbackVotes?: FeedbackVote[];
issueId?: string | null;
companyId?: string | null;
projectId?: string | null;
currentUserId?: string | null;
backgroundWorkChildren?: IssueRelationIssueSummary[];
suppressIssueStatusNotices?: boolean;
companyName?: string | null;
header?: ReactNode;
starterPrompts?: AssistantChatStarterPrompt[];
emptyState?: ReactNode;
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). */
errorText?: string | null;
onRetry?: () => void;
onSend: (body: string) => Promise<void>;
onStopRun?: (runId: string) => Promise<void>;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
onAcceptInteraction?: (
interaction: InteractionAccept,
selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => Promise<void> | void;
onRejectInteraction?: (interaction: InteractionAccept, reason?: string) => Promise<void> | void;
onSubmitInteractionAnswers?: (
interaction: AskUserQuestionsInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void> | void;
onCancelInteraction?: (interaction: AskUserQuestionsInteraction) => Promise<void> | void;
emptyMessage?: string;
className?: string;
}
/**
* Presentational assistant chat surface. Renders the real-agent identity
* header + switcher and delegates the message stream, interaction cards, and
* live/active-run rows to the shared {@link IssueChatThread}. Data and handlers
* are injected so the same view powers both the connected app surface and
* Storybook state fixtures (idle / loading / active-run / error / history).
*/
export function AssistantChatView({
agents,
targetAgentId,
onTargetAgentChange,
showAgentSwitcher = true,
comments,
interactions,
liveRuns,
activeRun,
feedbackVotes,
issueId,
companyId,
projectId,
currentUserId,
backgroundWorkChildren = [],
suppressIssueStatusNotices,
companyName = null,
header,
starterPrompts: providedStarterPrompts,
emptyState: providedEmptyState,
composerHint = null,
imageUploadHandler,
onAttachImage,
loading = false,
errorText,
onRetry,
onSend,
onStopRun,
onVote,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
emptyMessage,
className,
}: AssistantChatViewProps) {
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]);
const mentions = useMemo<MentionOption[]>(
() =>
invokableAgents.map((a) => ({
id: `agent:${a.id}`,
name: a.name,
kind: "agent",
agentId: a.id,
agentIcon: a.icon,
})),
[invokableAgents],
);
const handleAdd = useCallback(
(body: string) => onSend(body),
[onSend],
);
const targetName = targetAgent?.name ?? "Assistant";
const targetRole = targetAgent ? roleLabels[targetAgent.role] ?? targetAgent.role : null;
const canSwitch = showAgentSwitcher && invokableAgents.length > 1 && !!onTargetAgentChange;
const starterPrompts = useMemo(
() => providedStarterPrompts ?? buildStarterPrompts(companyName?.trim() || "this company"),
[companyName, providedStarterPrompts],
);
const starterPromptState = starterPrompts.length > 0 ? (
<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;
const emptyState = providedEmptyState ?? starterPromptState;
return (
<div className={cn("flex min-h-0 min-w-0 flex-1 flex-col", className)}>
{header ?? (
<div
data-testid="selected-agent-chat-header"
className="relative flex shrink-0 items-center gap-2 px-4 py-3"
>
<div
className="pointer-events-none absolute bottom-0 left-0 right-0 h-px bg-border"
aria-hidden
/>
{canSwitch ? (
<Select
value={targetAgentId ?? undefined}
onValueChange={(value) => onTargetAgentChange?.(value)}
>
<SelectTrigger
size="sm"
className="h-auto w-auto min-w-0 max-w-full gap-2 px-2 py-1.5"
aria-label="Choose chat agent"
>
<SelectValue placeholder="Choose agent" />
</SelectTrigger>
<SelectContent align="start">
{invokableAgents.map((a) => (
<SelectItem key={a.id} value={a.id}>
<span className="flex items-center gap-2">
<AgentIcon icon={a.icon} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{a.name}</span>
<span className="text-xs text-muted-foreground">
{roleLabels[a.role] ?? a.role}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex min-w-0 items-center gap-2">
<Avatar size="sm" className="shrink-0">
<AvatarFallback>
{targetAgent?.icon ? (
<AgentIcon icon={targetAgent.icon} className="h-3.5 w-3.5" />
) : (
agentInitials(targetName)
)}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-foreground">{targetName}</div>
{targetRole ? (
<div className="truncate text-xs text-muted-foreground">{targetRole}</div>
) : null}
</div>
</div>
)}
</div>
)}
{errorText ? (
<div
role="alert"
className="mx-4 mt-3 flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="break-words">{errorText}</p>
<p className="mt-0.5 text-xs text-destructive/80">
Your message was kept in the composer so you can try again.
</p>
</div>
{onRetry ? (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 border-destructive/40 text-destructive hover:bg-destructive/10"
onClick={onRetry}
>
Try again
</Button>
) : null}
</div>
) : null}
{loading && comments.length === 0 ? (
<div
data-testid="assistant-chat-skeleton"
className="flex flex-1 flex-col gap-4 px-4 py-4"
aria-label="Loading conversation"
>
<div className="h-16 w-2/3 animate-pulse rounded-md bg-muted" />
<div className="ml-auto h-14 w-3/5 animate-pulse rounded-md bg-muted" />
<div className="h-20 w-4/5 animate-pulse rounded-md bg-muted" />
</div>
) : (
<div
data-testid="selected-agent-chat-body"
className="min-w-0 px-4 pb-4 pt-3"
>
<IssueChatThread
preset="assistant"
variant="full"
composerRef={composerRef}
comments={comments}
interactions={interactions}
liveRuns={liveRuns}
activeRun={activeRun}
feedbackVotes={feedbackVotes}
issueId={issueId}
companyId={companyId}
projectId={projectId}
agentMap={agentMap}
currentUserId={currentUserId}
backgroundWorkChildren={backgroundWorkChildren}
suppressIssueStatusNotices={suppressIssueStatusNotices}
mentions={mentions}
emptyMessage={
emptyMessage ?? `Send ${targetName} a message to start the conversation.`
}
emptyState={emptyState}
composerHint={composerHint}
imageUploadHandler={imageUploadHandler}
onAttachImage={onAttachImage}
onAdd={handleAdd}
onStopRun={onStopRun}
onVote={onVote}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction}
/>
</div>
)}
</div>
);
}
export interface AssistantChatProps {
issueId: string;
companyId: string;
projectId?: string | null;
/** Pre-loaded agents (e.g. from the page). Fetched if omitted. */
agents?: readonly Agent[];
/** Preferred initial target; defaults to the company CEO. */
targetAgentId?: string | null;
showAgentSwitcher?: boolean;
companyName?: string | null;
currentUserId?: string | null;
starterPrompts?: AssistantChatStarterPrompt[];
header?: ReactNode;
emptyState?: ReactNode;
emptyMessage?: string;
onMessageSent?: () => void | Promise<void>;
className?: string;
}
/**
* Connected assistant chat. Issue-backed: durable history is the issue's
* comments, live output is the target agent's active run, and next-step
* choices are real issue-thread interactions. Sending wakes the target agent
* (default CEO) via `selected-agent-chat/comments`.
*/
export function AssistantChat({
issueId,
companyId,
projectId,
agents: providedAgents,
targetAgentId: preferredTargetAgentId,
showAgentSwitcher = true,
companyName = null,
currentUserId,
starterPrompts,
header,
emptyState,
emptyMessage,
onMessageSent,
className,
}: AssistantChatProps) {
const queryClient = useQueryClient();
const { data: fetchedAgents } = useQuery({
queryKey: queryKeys.agents.list(companyId),
queryFn: () => import("../api/agents").then((m) => m.agentsApi.list(companyId)),
enabled: !providedAgents,
});
const agents = providedAgents ?? fetchedAgents ?? [];
const [targetAgentId, setTargetAgentId] = useState<string | null>(
preferredTargetAgentId ?? null,
);
useEffect(() => {
if (preferredTargetAgentId) setTargetAgentId(preferredTargetAgentId);
}, [preferredTargetAgentId]);
// Lock the default target onto the resolved CEO once agents load.
useEffect(() => {
if (targetAgentId) return;
const fallback = resolveDefaultChatTarget(agents, preferredTargetAgentId);
if (fallback) setTargetAgentId(fallback.id);
}, [agents, preferredTargetAgentId, targetAgentId]);
const [errorText, setErrorText] = useState<string | null>(null);
const [pendingComments, setPendingComments] = useState<PendingAssistantChatComment[]>([]);
const liveRunsQuery = useQuery({
queryKey: targetAgentId
? queryKeys.issues.selectedAgentChatLiveRuns(issueId, targetAgentId)
: queryKeys.issues.liveRuns(issueId),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId, targetAgentId),
refetchInterval: (query) =>
resolveAssistantChatPollInterval(hasLiveAssistantRun(query.state.data as LiveRunForIssue[] | undefined, null)),
enabled: !!targetAgentId,
});
const activeRunQuery = useQuery({
queryKey: targetAgentId
? queryKeys.issues.selectedAgentChatActiveRun(issueId, targetAgentId)
: queryKeys.issues.activeRun(issueId),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId, targetAgentId),
refetchInterval: (query) =>
resolveAssistantChatPollInterval(hasLiveAssistantRun(liveRunsQuery.data, query.state.data as ActiveRunForIssue | null | undefined)),
enabled: !!targetAgentId,
});
const assistantPollInterval = resolveAssistantChatPollInterval(
hasLiveAssistantRun(liveRunsQuery.data, activeRunQuery.data),
);
const commentsQuery = useQuery({
queryKey: queryKeys.issues.comments(issueId),
queryFn: () => issuesApi.listComments(issueId),
refetchInterval: assistantPollInterval,
});
const interactionsQuery = useQuery({
queryKey: queryKeys.issues.interactions(issueId),
queryFn: () => issuesApi.listInteractions(issueId),
refetchInterval: assistantPollInterval,
});
const issueQuery = useQuery({
queryKey: queryKeys.issues.detail(issueId),
queryFn: () => issuesApi.get(issueId),
refetchInterval: assistantPollInterval,
});
const feedbackVotesQuery = useQuery({
queryKey: queryKeys.issues.feedbackVotes(issueId),
queryFn: () => issuesApi.listFeedbackVotes(issueId),
});
const backgroundWorkChildren = useMemo(() => {
const seen = new Set<string>();
const children: IssueRelationIssueSummary[] = [];
for (const child of [
...(issueQuery.data?.blockedBy ?? []),
...(issueQuery.data?.blocks ?? []),
]) {
if (seen.has(child.id)) continue;
seen.add(child.id);
children.push(child);
}
return children;
}, [issueQuery.data?.blockedBy, issueQuery.data?.blocks]);
const comments = useMemo(
() => mergeAssistantChatComments((commentsQuery.data ?? []) as IssueChatComment[], pendingComments),
[commentsQuery.data, pendingComments],
);
useEffect(() => {
const serverComments = (commentsQuery.data ?? []) as IssueChatComment[];
if (serverComments.length === 0) return;
setPendingComments((current) =>
current.filter((pending) => !isOptimisticCommentMatched(serverComments, pending)),
);
}, [commentsQuery.data]);
const hasOpenBackgroundWork = backgroundWorkChildren.some(
(child) => child.status !== "done" && child.status !== "cancelled",
);
const invalidateRuns = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId) });
if (targetAgentId) {
queryClient.invalidateQueries({
queryKey: queryKeys.issues.selectedAgentChatLiveRuns(issueId, targetAgentId),
});
queryClient.invalidateQueries({
queryKey: queryKeys.issues.selectedAgentChatActiveRun(issueId, targetAgentId),
});
}
}, [issueId, queryClient, targetAgentId]);
const handleSend = useCallback(
async (body: string) => {
setErrorText(null);
const optimistic = createOptimisticComment({
issueId,
companyId,
body,
currentUserId,
targetAgentId,
});
setPendingComments((current) => [...current, optimistic]);
try {
const response = await issuesApi.addSelectedAgentChatComment(issueId, body, { targetAgentId });
setPendingComments((current) =>
current.map((pending) =>
pending.clientNonce === optimistic.clientNonce
? {
...pending,
comment: response.comment as IssueChatComment,
serverCommentId: response.comment.id,
}
: pending,
),
);
await onMessageSent?.();
invalidateRuns();
} catch (err) {
setPendingComments((current) =>
current.filter((pending) => pending.clientNonce !== optimistic.clientNonce),
);
setErrorText(
err instanceof Error
? err.message
: "The message couldn't be delivered. Please try again.",
);
// Rethrow so the composer keeps the typed message as a draft.
throw err;
}
},
[issueId, companyId, currentUserId, targetAgentId, onMessageSent, invalidateRuns],
);
const handleStopRun = useCallback(
async (runId: string) => {
await heartbeatsApi.cancel(runId);
invalidateRuns();
},
[invalidateRuns],
);
const handleVote = useCallback(
async (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => {
await issuesApi.upsertFeedbackVote(issueId, {
targetType: "issue_comment",
targetId: commentId,
vote,
reason: options?.reason,
allowSharing: options?.allowSharing,
});
queryClient.invalidateQueries({ queryKey: queryKeys.issues.feedbackVotes(issueId) });
},
[issueId, queryClient],
);
const handleAcceptInteraction = useCallback(
async (interaction: InteractionAccept, selectedClientKeys?: string[], selectedOptionIds?: string[]) => {
await issuesApi.acceptInteraction(issueId, interaction.id, {
selectedClientKeys,
selectedOptionIds,
});
invalidateRuns();
},
[issueId, invalidateRuns],
);
const handleRejectInteraction = useCallback(
async (interaction: InteractionAccept, reason?: string) => {
await issuesApi.rejectInteraction(issueId, interaction.id, reason);
invalidateRuns();
},
[issueId, invalidateRuns],
);
const handleSubmitInteractionAnswers = useCallback(
async (interaction: AskUserQuestionsInteraction, answers: AskUserQuestionsAnswer[]) => {
await issuesApi.respondToInteraction(issueId, interaction.id, { answers });
invalidateRuns();
},
[issueId, invalidateRuns],
);
const handleCancelInteraction = useCallback(
async (interaction: AskUserQuestionsInteraction) => {
await issuesApi.cancelInteraction(issueId, interaction.id);
invalidateRuns();
},
[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 (
<AssistantChatView
agents={agents}
targetAgentId={targetAgentId}
onTargetAgentChange={setTargetAgentId}
showAgentSwitcher={showAgentSwitcher}
comments={comments}
interactions={interactionsQuery.data}
liveRuns={liveRunsQuery.data}
activeRun={activeRunQuery.data ?? null}
feedbackVotes={feedbackVotesQuery.data}
issueId={issueId}
companyId={companyId}
projectId={projectId}
currentUserId={currentUserId}
backgroundWorkChildren={backgroundWorkChildren}
suppressIssueStatusNotices
companyName={companyName}
header={header}
starterPrompts={starterPrompts}
emptyState={emptyState}
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}
onSend={handleSend}
onStopRun={handleStopRun}
onVote={handleVote}
onAcceptInteraction={handleAcceptInteraction}
onRejectInteraction={handleRejectInteraction}
onSubmitInteractionAnswers={handleSubmitInteractionAnswers}
onCancelInteraction={handleCancelInteraction}
emptyMessage={emptyMessage}
className={className}
/>
);
}
@@ -1,307 +0,0 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Agent } from "@paperclipai/shared";
import {
SelectedAgentChatView,
resolveDefaultChatTarget,
} from "./SelectedAgentChat";
import type { IssueChatComment } from "../lib/issue-chat-messages";
// Stub the heavy thread renderer so the View's own wiring (identity header,
// switcher, error/loading states, send pass-through) is what we assert.
vi.mock("./IssueChatThread", () => ({
IssueChatThread: (props: {
preset?: string;
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="thread-preset">{props.preset}</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 ?? (props.preset === "assistant" ? false : true))}
</span>
<span data-testid="background-work-count">{props.backgroundWorkChildren?.length ?? 0}</span>
<span data-testid="status-notices">
{(props.suppressIssueStatusNotices ?? props.preset === "assistant") ? "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>
</div>
),
}));
function makeAgent(overrides: Partial<Agent>): Agent {
return {
id: "agent-x",
companyId: "company-1",
name: "Agent X",
urlKey: "agent-x",
role: "engineer",
title: null,
icon: null,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "claude_code",
adapterConfig: {},
runtimeConfig: {} as Agent["runtimeConfig"],
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: {} as Agent["permissions"],
lastHeartbeatAt: null,
...overrides,
} as Agent;
}
const ceo = makeAgent({ id: "agent-ceo", name: "Sarah", role: "ceo" });
const eng = makeAgent({ id: "agent-eng", name: "Dev", role: "engineer" });
const terminated = makeAgent({ id: "agent-dead", name: "Zed", status: "terminated" });
describe("resolveDefaultChatTarget", () => {
it("defaults to the CEO", () => {
expect(resolveDefaultChatTarget([eng, ceo])?.id).toBe("agent-ceo");
});
it("honors a preferred agent when present", () => {
expect(resolveDefaultChatTarget([eng, ceo], "agent-eng")?.id).toBe("agent-eng");
});
it("skips terminated preferred and falls back to CEO", () => {
expect(resolveDefaultChatTarget([ceo, eng, terminated], "agent-dead")?.id).toBe("agent-ceo");
});
it("falls back to first active agent when there is no CEO", () => {
expect(resolveDefaultChatTarget([terminated, eng])?.id).toBe("agent-eng");
});
it("returns null for an empty roster", () => {
expect(resolveDefaultChatTarget([])).toBeNull();
});
});
describe("SelectedAgentChatView", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
vi.clearAllMocks();
});
function render(node: ReactNode) {
flushSync(() => root.render(node));
}
function countText(text: string, needle: string): number {
return text.split(needle).length - 1;
}
it("renders the real selected-agent identity in the header", () => {
render(
<SelectedAgentChatView
agents={[ceo, eng]}
targetAgentId={ceo.id}
comments={[]}
onSend={async () => {}}
/>,
);
expect(container.textContent).toContain("Sarah");
expect(container.textContent).toContain("CEO");
// No board-concierge persona leaks into the surface.
expect(container.textContent?.toLowerCase()).not.toContain("concierge");
});
it("uses the left-side selector as the selected-agent identity without repeating the role", () => {
render(
<SelectedAgentChatView
agents={[ceo, eng]}
targetAgentId={ceo.id}
comments={[]}
onSend={async () => {}}
onTargetAgentChange={() => {}}
/>,
);
const header = container.querySelector(
'[data-testid="selected-agent-chat-header"]',
) as HTMLDivElement | null;
const switcher = header?.querySelector('[aria-label="Choose chat agent"]');
expect(switcher).not.toBeNull();
expect(switcher?.textContent).toContain("Sarah");
expect(countText(switcher?.textContent ?? "", "CEO")).toBe(1);
expect(header?.textContent).toBe(switcher?.textContent);
});
it("pads the chat body to align with the selected-agent header", () => {
render(
<SelectedAgentChatView
agents={[ceo]}
targetAgentId={ceo.id}
comments={[]}
onSend={async () => {}}
/>,
);
const body = container.querySelector(
'[data-testid="selected-agent-chat-body"]',
) as HTMLDivElement | null;
expect(body).not.toBeNull();
expect(body?.className).toContain("px-4");
expect(body?.className).toContain("pt-3");
expect(body?.className).toContain("pb-4");
});
it("shows a loading indicator while the first fetch is in flight", () => {
render(
<SelectedAgentChatView agents={[ceo]} targetAgentId={ceo.id} comments={[]} loading onSend={async () => {}} />,
);
expect(container.querySelector('[aria-label="Loading conversation"]')).not.toBeNull();
expect(container.querySelector('[data-testid="issue-chat-thread"]')).toBeNull();
});
it("surfaces a delivery error with a Try again affordance", () => {
const onRetry = vi.fn();
render(
<SelectedAgentChatView
agents={[ceo]}
targetAgentId={ceo.id}
comments={[]}
errorText="Could not deliver."
onRetry={onRetry}
onSend={async () => {}}
/>,
);
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain("Could not deliver.");
const retry = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent === "Try again",
);
expect(retry).toBeTruthy();
flushSync(() => retry!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onRetry).toHaveBeenCalledOnce();
});
it("passes a target-specific empty message and pipes sends through", async () => {
const onSend = vi.fn(async () => {});
render(
<SelectedAgentChatView agents={[ceo]} targetAgentId={ceo.id} comments={[]} onSend={onSend} />,
);
expect(container.querySelector('[data-testid="empty-message"]')?.textContent).toContain(
"Sarah",
);
const send = container.querySelector('[data-testid="send"]') as HTMLButtonElement;
flushSync(() => send.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await Promise.resolve();
expect(onSend).toHaveBeenCalledWith("hello");
});
it("forwards Conference Room background-work state to the issue thread", () => {
render(
<SelectedAgentChatView
agents={[ceo]}
targetAgentId={ceo.id}
comments={[]}
backgroundWorkChildren={[
{
id: "issue-child",
identifier: "PAP-2",
title: "Background task",
status: "in_progress",
priority: "medium",
assigneeAgentId: ceo.id,
assigneeUserId: null,
},
]}
suppressIssueStatusNotices
composerHint="Ask me anything while I work on this."
onSend={async () => {}}
/>,
);
expect(container.querySelector('[data-testid="background-work-count"]')?.textContent).toBe("1");
expect(container.querySelector('[data-testid="status-notices"]')?.textContent).toBe("suppressed");
expect(container.querySelector('[data-testid="composer-hint"]')?.textContent).toBe(
"Ask me anything while I work on this.",
);
});
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");
expect(container.querySelector('[data-testid="thread-preset"]')?.textContent).toBe("assistant");
expect(container.querySelector('[data-testid="status-notices"]')?.textContent).toBe("suppressed");
});
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={() => {}} />,
);
expect(container.querySelector('[aria-label="Choose chat agent"]')).toBeNull();
render(
<SelectedAgentChatView
agents={[ceo, eng]}
targetAgentId={ceo.id}
comments={[] as IssueChatComment[]}
onSend={async () => {}}
onTargetAgentChange={() => {}}
/>,
);
expect(container.querySelector('[aria-label="Choose chat agent"]')).not.toBeNull();
});
});
+32 -628
View File
@@ -1,638 +1,42 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AGENT_ROLE_LABELS,
type Agent,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
type FeedbackVote,
type FeedbackVoteValue,
type IssueAttachment,
type IssueRelationIssueSummary,
type IssueThreadInteraction,
type RequestCheckboxConfirmationInteraction,
type RequestConfirmationInteraction,
type SuggestTasksInteraction,
} from "@paperclipai/shared";
import { AlertCircle, Loader2 } from "lucide-react";
import { IssueChatThread, type IssueChatComposerHandle } from "./IssueChatThread";
import { AgentIcon } from "./AgentIconPicker";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { MentionOption } from "./MarkdownEditor";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { heartbeatsApi } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import type { IssueChatComment } from "../lib/issue-chat-messages";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
AssistantChat,
AssistantChatView,
type AssistantChatProps,
type AssistantChatViewProps,
} from "./AssistantChat";
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
export {
ASSISTANT_CHAT_ACTIVE_POLL_MS,
ASSISTANT_CHAT_IDLE_POLL_MS,
mergeAssistantChatComments,
resolveAssistantChatPollInterval,
resolveDefaultChatTarget,
type AssistantChatStarterPrompt,
type PendingAssistantChatComment,
} from "./AssistantChat";
/** 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";
}
/**
* Pick the default selected-agent chat target: the company CEO (matches the
* server default for `selected-agent-chat/comments`), falling back to an
* explicit preferred id, then the first non-terminated agent.
*/
export function resolveDefaultChatTarget(
agents: readonly Agent[] | undefined,
preferredAgentId?: string | null,
): Agent | null {
if (!agents || agents.length === 0) return null;
const active = agents.filter((a) => a.status !== "terminated");
if (preferredAgentId) {
const preferred = active.find((a) => a.id === preferredAgentId);
if (preferred) return preferred;
}
const ceo = active.find((a) => a.role === "ceo");
if (ceo) return ceo;
return active[0] ?? agents[0] ?? null;
}
/** CEO pinned first, then the rest alphabetically — the switcher ordering. */
function orderInvokableAgents(agents: readonly Agent[]): Agent[] {
return agents
.filter((a) => a.status !== "terminated")
.slice()
.sort((a, b) => {
if (a.role === "ceo" && b.role !== "ceo") return -1;
if (b.role === "ceo" && a.role !== "ceo") return 1;
return a.name.localeCompare(b.name);
});
}
type InteractionAccept =
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction;
export interface SelectedAgentChatViewProps {
/** Agents available as chat targets (the switcher list + bubble identity). */
agents: readonly Agent[];
targetAgentId: string | null;
onTargetAgentChange?: (agentId: string) => void;
/** Hide the switcher (ship-behind-a-flag): identity still renders. */
showAgentSwitcher?: boolean;
comments: IssueChatComment[];
interactions?: IssueThreadInteraction[];
liveRuns?: LiveRunForIssue[];
activeRun?: ActiveRunForIssue | null;
feedbackVotes?: FeedbackVote[];
issueId?: string | null;
companyId?: string | null;
projectId?: string | null;
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). */
errorText?: string | null;
onRetry?: () => void;
onSend: (body: string) => Promise<void>;
onStopRun?: (runId: string) => Promise<void>;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
onAcceptInteraction?: (
interaction: InteractionAccept,
selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => Promise<void> | void;
onRejectInteraction?: (interaction: InteractionAccept, reason?: string) => Promise<void> | void;
onSubmitInteractionAnswers?: (
interaction: AskUserQuestionsInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void> | void;
onCancelInteraction?: (interaction: AskUserQuestionsInteraction) => Promise<void> | void;
emptyMessage?: string;
className?: string;
}
/**
* Presentational selected-agent chat surface. Renders the real-agent identity
* header + switcher and delegates the message stream, interaction cards, and
* live/active-run rows to the shared {@link IssueChatThread}. Data and handlers
* are injected so the same view powers both the connected app surface and
* Storybook state fixtures (idle / loading / active-run / error / history).
*/
export function SelectedAgentChatView({
agents,
targetAgentId,
onTargetAgentChange,
showAgentSwitcher = true,
comments,
interactions,
liveRuns,
activeRun,
feedbackVotes,
issueId,
companyId,
projectId,
currentUserId,
backgroundWorkChildren = [],
suppressIssueStatusNotices,
conferenceRoomMode = false,
companyName = null,
composerHint = null,
imageUploadHandler,
onAttachImage,
loading = false,
errorText,
onRetry,
onSend,
onStopRun,
onVote,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
emptyMessage,
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]);
const mentions = useMemo<MentionOption[]>(
() =>
invokableAgents.map((a) => ({
id: `agent:${a.id}`,
name: a.name,
kind: "agent",
agentId: a.id,
agentIcon: a.icon,
})),
[invokableAgents],
);
const handleAdd = useCallback(
(body: string) => onSend(body),
[onSend],
);
const targetName = targetAgent?.name ?? "Assistant";
const threadPreset = conferenceRoomMode ? "assistant" : "task";
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)}>
{/* Identity header — real selected agent (no board-concierge persona). */}
<div
data-testid="selected-agent-chat-header"
className="relative flex shrink-0 items-center gap-2 px-4 py-3"
>
<div
className="pointer-events-none absolute bottom-0 left-0 right-0 h-px bg-border"
aria-hidden
/>
{canSwitch ? (
<Select
value={targetAgentId ?? undefined}
onValueChange={(value) => onTargetAgentChange?.(value)}
>
<SelectTrigger
size="sm"
className="h-auto w-auto min-w-0 max-w-full gap-2 px-2 py-1.5"
aria-label="Choose chat agent"
>
<SelectValue placeholder="Choose agent" />
</SelectTrigger>
<SelectContent align="start">
{invokableAgents.map((a) => (
<SelectItem key={a.id} value={a.id}>
<span className="flex items-center gap-2">
<AgentIcon icon={a.icon} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{a.name}</span>
<span className="text-xs text-muted-foreground">
{roleLabels[a.role] ?? a.role}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex min-w-0 items-center gap-2">
<Avatar size="sm" className="shrink-0">
<AvatarFallback>
{targetAgent?.icon ? (
<AgentIcon icon={targetAgent.icon} className="h-3.5 w-3.5" />
) : (
agentInitials(targetName)
)}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-foreground">{targetName}</div>
{targetRole ? (
<div className="truncate text-xs text-muted-foreground">{targetRole}</div>
) : null}
</div>
</div>
)}
</div>
{errorText ? (
<div
role="alert"
className="mx-4 mt-3 flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="break-words">{errorText}</p>
<p className="mt-0.5 text-xs text-destructive/80">
Your message was kept in the composer so you can try again.
</p>
</div>
{onRetry ? (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 border-destructive/40 text-destructive hover:bg-destructive/10"
onClick={onRetry}
>
Try again
</Button>
) : null}
</div>
) : null}
{loading && comments.length === 0 ? (
<div className="flex flex-1 items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading conversation" />
</div>
) : (
<div
data-testid="selected-agent-chat-body"
className="min-w-0 px-4 pb-4 pt-3"
>
<IssueChatThread
preset={threadPreset}
variant="full"
composerRef={composerRef}
comments={comments}
interactions={interactions}
liveRuns={liveRuns}
activeRun={activeRun}
feedbackVotes={feedbackVotes}
issueId={issueId}
companyId={companyId}
projectId={projectId}
agentMap={agentMap}
currentUserId={currentUserId}
backgroundWorkChildren={backgroundWorkChildren}
suppressIssueStatusNotices={suppressIssueStatusNotices}
mentions={mentions}
emptyMessage={
emptyMessage ?? `Send ${targetName} a message to start the conversation.`
}
emptyState={emptyState}
composerHint={composerHint}
imageUploadHandler={imageUploadHandler}
onAttachImage={onAttachImage}
onAdd={handleAdd}
onStopRun={onStopRun}
onVote={onVote}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction}
/>
</div>
)}
</div>
);
}
export interface SelectedAgentChatProps {
issueId: string;
companyId: string;
projectId?: string | null;
/** Pre-loaded agents (e.g. from the page). Fetched if omitted. */
agents?: readonly Agent[];
/** Preferred initial target; defaults to the company CEO. */
export type SelectedAgentChatProps = Omit<AssistantChatProps, "targetAgentId"> & {
defaultTargetAgentId?: string | null;
showAgentSwitcher?: boolean;
/** @deprecated AssistantChat always uses the assistant preset. */
conferenceRoomMode?: boolean;
companyName?: string | null;
currentUserId?: string | null;
emptyMessage?: string;
onMessageSent?: () => void | Promise<void>;
className?: string;
}
};
export type SelectedAgentChatViewProps = AssistantChatViewProps & {
/** @deprecated AssistantChatView always uses the assistant preset. */
conferenceRoomMode?: boolean;
};
/**
* Connected selected-agent chat. Issue-backed: durable history is the issue's
* comments, live output is the target agent's active run, and next-step
* choices are real issue-thread interactions. Sending wakes the target agent
* (default CEO) via `selected-agent-chat/comments`.
*/
export function SelectedAgentChat({
issueId,
companyId,
projectId,
agents: providedAgents,
defaultTargetAgentId,
showAgentSwitcher = true,
conferenceRoomMode = false,
companyName = null,
currentUserId,
emptyMessage,
onMessageSent,
className,
conferenceRoomMode: _conferenceRoomMode,
...props
}: SelectedAgentChatProps) {
const queryClient = useQueryClient();
const { data: fetchedAgents } = useQuery({
queryKey: queryKeys.agents.list(companyId),
queryFn: () => import("../api/agents").then((m) => m.agentsApi.list(companyId)),
enabled: !providedAgents,
});
const agents = providedAgents ?? fetchedAgents ?? [];
const [targetAgentId, setTargetAgentId] = useState<string | null>(
defaultTargetAgentId ?? null,
);
// Lock the default target onto the resolved CEO once agents load.
useEffect(() => {
if (targetAgentId) return;
const fallback = resolveDefaultChatTarget(agents, defaultTargetAgentId);
if (fallback) setTargetAgentId(fallback.id);
}, [agents, defaultTargetAgentId, targetAgentId]);
const [errorText, setErrorText] = useState<string | null>(null);
const commentsQuery = useQuery({
queryKey: queryKeys.issues.comments(issueId),
queryFn: () => issuesApi.listComments(issueId),
refetchInterval: SELECTED_AGENT_CHAT_POLL_MS,
});
const interactionsQuery = useQuery({
queryKey: queryKeys.issues.interactions(issueId),
queryFn: () => issuesApi.listInteractions(issueId),
refetchInterval: SELECTED_AGENT_CHAT_POLL_MS,
});
const issueQuery = useQuery({
queryKey: queryKeys.issues.detail(issueId),
queryFn: () => issuesApi.get(issueId),
refetchInterval: SELECTED_AGENT_CHAT_POLL_MS,
enabled: conferenceRoomMode,
});
const liveRunsQuery = useQuery({
queryKey: targetAgentId
? queryKeys.issues.selectedAgentChatLiveRuns(issueId, targetAgentId)
: queryKeys.issues.liveRuns(issueId),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId, targetAgentId),
refetchInterval: SELECTED_AGENT_CHAT_POLL_MS,
enabled: !!targetAgentId,
});
const activeRunQuery = useQuery({
queryKey: targetAgentId
? queryKeys.issues.selectedAgentChatActiveRun(issueId, targetAgentId)
: queryKeys.issues.activeRun(issueId),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId, targetAgentId),
refetchInterval: SELECTED_AGENT_CHAT_POLL_MS,
enabled: !!targetAgentId,
});
const feedbackVotesQuery = useQuery({
queryKey: queryKeys.issues.feedbackVotes(issueId),
queryFn: () => issuesApi.listFeedbackVotes(issueId),
});
const backgroundWorkChildren = useMemo(() => {
if (!conferenceRoomMode) return [];
const seen = new Set<string>();
const children: IssueRelationIssueSummary[] = [];
for (const child of [
...(issueQuery.data?.blockedBy ?? []),
...(issueQuery.data?.blocks ?? []),
]) {
if (seen.has(child.id)) continue;
seen.add(child.id);
children.push(child);
}
return children;
}, [conferenceRoomMode, issueQuery.data?.blockedBy, issueQuery.data?.blocks]);
const hasOpenBackgroundWork = backgroundWorkChildren.some(
(child) => child.status !== "done" && child.status !== "cancelled",
);
const invalidateRuns = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId) });
if (targetAgentId) {
queryClient.invalidateQueries({
queryKey: queryKeys.issues.selectedAgentChatLiveRuns(issueId, targetAgentId),
});
queryClient.invalidateQueries({
queryKey: queryKeys.issues.selectedAgentChatActiveRun(issueId, targetAgentId),
});
}
}, [issueId, queryClient, targetAgentId]);
const handleSend = useCallback(
async (body: string) => {
setErrorText(null);
try {
await issuesApi.addSelectedAgentChatComment(issueId, body, { targetAgentId });
await onMessageSent?.();
invalidateRuns();
} catch (err) {
setErrorText(
err instanceof Error
? err.message
: "The message couldn't be delivered. Please try again.",
);
// Rethrow so the composer keeps the typed message as a draft (CR8).
throw err;
}
},
[issueId, targetAgentId, onMessageSent, invalidateRuns],
);
const handleStopRun = useCallback(
async (runId: string) => {
await heartbeatsApi.cancel(runId);
invalidateRuns();
},
[invalidateRuns],
);
const handleVote = useCallback(
async (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => {
await issuesApi.upsertFeedbackVote(issueId, {
targetType: "issue_comment",
targetId: commentId,
vote,
reason: options?.reason,
allowSharing: options?.allowSharing,
});
queryClient.invalidateQueries({ queryKey: queryKeys.issues.feedbackVotes(issueId) });
},
[issueId, queryClient],
);
const handleAcceptInteraction = useCallback(
async (interaction: InteractionAccept, selectedClientKeys?: string[], selectedOptionIds?: string[]) => {
await issuesApi.acceptInteraction(issueId, interaction.id, {
selectedClientKeys,
selectedOptionIds,
});
invalidateRuns();
},
[issueId, invalidateRuns],
);
const handleRejectInteraction = useCallback(
async (interaction: InteractionAccept, reason?: string) => {
await issuesApi.rejectInteraction(issueId, interaction.id, reason);
invalidateRuns();
},
[issueId, invalidateRuns],
);
const handleSubmitInteractionAnswers = useCallback(
async (interaction: AskUserQuestionsInteraction, answers: AskUserQuestionsAnswer[]) => {
await issuesApi.respondToInteraction(issueId, interaction.id, { answers });
invalidateRuns();
},
[issueId, invalidateRuns],
);
const handleCancelInteraction = useCallback(
async (interaction: AskUserQuestionsInteraction) => {
await issuesApi.cancelInteraction(issueId, interaction.id);
invalidateRuns();
},
[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}
targetAgentId={targetAgentId}
onTargetAgentChange={setTargetAgentId}
showAgentSwitcher={showAgentSwitcher}
comments={(commentsQuery.data ?? []) as IssueChatComment[]}
interactions={interactionsQuery.data}
liveRuns={liveRunsQuery.data}
activeRun={activeRunQuery.data ?? null}
feedbackVotes={feedbackVotesQuery.data}
issueId={issueId}
companyId={companyId}
projectId={projectId}
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}
onSend={handleSend}
onStopRun={handleStopRun}
onVote={handleVote}
onAcceptInteraction={handleAcceptInteraction}
onRejectInteraction={handleRejectInteraction}
onSubmitInteractionAnswers={handleSubmitInteractionAnswers}
onCancelInteraction={handleCancelInteraction}
emptyMessage={emptyMessage}
className={className}
/>
);
return <AssistantChat {...props} targetAgentId={defaultTargetAgentId} />;
}
export function SelectedAgentChatView({
conferenceRoomMode: _conferenceRoomMode,
...props
}: SelectedAgentChatViewProps) {
return <AssistantChatView {...props} />;
}
+11 -11
View File
@@ -10,7 +10,7 @@ import { BoardChat } from "./BoardChat";
/**
* Conference Room transport coverage (PAP-11123). The room is backed by a
* `board_chat` issue and the real-agent conversation runs over
* SelectedAgentChat — so the page resolves/mints the backing issue, surfaces
* AssistantChat — so the page resolves/mints the backing issue, surfaces
* history, and hands the resolved issue + CEO default target to the chat
* surface. The legacy SSE-stream transport assertions are gone.
*/
@@ -19,7 +19,7 @@ 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 mockSelectedAgentChatProps = vi.hoisted(() => [] as Array<Record<string, unknown>>);
const mockAssistantChatProps = vi.hoisted(() => [] as Array<Record<string, unknown>>);
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
@@ -41,14 +41,14 @@ vi.mock("../context/BreadcrumbContext", () => ({
vi.mock("../components/ActivityFeed", () => ({
ActivityFeed: () => <div data-testid="activity-feed" />,
}));
vi.mock("../components/SelectedAgentChat", () => ({
SelectedAgentChat: (props: Record<string, unknown>) => {
mockSelectedAgentChatProps.push(props);
vi.mock("../components/AssistantChat", () => ({
AssistantChat: (props: Record<string, unknown>) => {
mockAssistantChatProps.push(props);
return (
<div
data-testid="selected-agent-chat"
data-issue-id={String(props.issueId)}
data-default-target={String(props.defaultTargetAgentId)}
data-target-agent-id={String(props.targetAgentId)}
/>
);
},
@@ -140,7 +140,7 @@ describe("BoardChat Conference Room transport", () => {
user: { id: "user-1", name: "Board" },
});
mockBoardChatApi.resolveConversation.mockResolvedValue({ issue: BOARD_ISSUE });
mockSelectedAgentChatProps.length = 0;
mockAssistantChatProps.length = 0;
});
afterEach(async () => {
@@ -191,7 +191,7 @@ describe("BoardChat Conference Room transport", () => {
);
});
it("renders SelectedAgentChat over the resolved board issue with the CEO as default target", async () => {
it("renders AssistantChat over the resolved board issue with the CEO as default target", async () => {
await render();
const surface = container.querySelector(
@@ -199,12 +199,12 @@ describe("BoardChat Conference Room transport", () => {
) as HTMLDivElement | null;
expect(surface).not.toBeNull();
expect(surface?.getAttribute("data-issue-id")).toBe(BOARD_ISSUE.id);
expect(surface?.getAttribute("data-default-target")).toBe(CEO_AGENT.id);
expect(surface?.getAttribute("data-target-agent-id")).toBe(CEO_AGENT.id);
const lastProps = mockSelectedAgentChatProps.at(-1);
const lastProps = mockAssistantChatProps.at(-1);
expect(lastProps?.companyId).toBe("company-1");
expect(lastProps?.currentUserId).toBe("user-1");
expect(lastProps?.conferenceRoomMode).toBe(true);
expect(lastProps?.targetAgentId).toBe(CEO_AGENT.id);
expect(lastProps?.showAgentSwitcher).toBe(false);
expect(lastProps?.companyName).toBe("Acme Robotics");
expect(lastProps?.emptyMessage).toBeUndefined();
+6 -7
View File
@@ -22,7 +22,7 @@ import {
} from "@/components/ui/tooltip";
import { Activity, AlertCircle, History, Loader2, MessageSquarePlus } from "lucide-react";
import { ActivityFeed } from "../components/ActivityFeed";
import { SelectedAgentChat } from "../components/SelectedAgentChat";
import { AssistantChat } from "../components/AssistantChat";
import { cn, relativeTime } from "../lib/utils";
import { AGENT_ROLE_LABELS, type Agent, type Issue } from "@paperclipai/shared";
import {
@@ -38,7 +38,7 @@ import {
* Conference Room — the board user's live conversation with the real company
* CEO (PAP-11099). The room is backed by an ordinary company-scoped
* `board_chat` issue (so history survives reloads) and the conversation runs
* over the reusable {@link SelectedAgentChat} surface: durable history is the
* over the reusable {@link AssistantChat} surface: durable history is the
* issue's comments, live output is the target agent's active run, and
* next-step choices are real issue-thread interactions. Sending wakes the real
* agent (default CEO) — no board-concierge persona.
@@ -347,7 +347,7 @@ export function BoardChat() {
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 SelectedAgentChat
(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">
@@ -454,18 +454,17 @@ export function BoardChat() {
</SheetContent>
</Sheet>
{/* Center column — the real-agent conversation. SelectedAgentChat
{/* Center column — the real-agent conversation. AssistantChat
owns the identity header, message stream, interaction cards,
live/active-run row, and composer. */}
{boardIssueId ? (
<SelectedAgentChat
<AssistantChat
key={boardIssueId}
issueId={boardIssueId}
companyId={selectedCompanyId}
agents={agents}
defaultTargetAgentId={ceoAgent?.id ?? null}
targetAgentId={ceoAgent?.id ?? null}
showAgentSwitcher={false}
conferenceRoomMode
companyName={selectedCompany?.name ?? null}
currentUserId={currentUserId}
onMessageSent={refreshBoardChatHistory}