diff --git a/ui/src/api/boardChat.ts b/ui/src/api/boardChat.ts index 90a67ba89ea..a706ef5ee15 100644 --- a/ui/src/api/boardChat.ts +++ b/ui/src/api/boardChat.ts @@ -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 = { /** diff --git a/ui/src/components/AssistantChat.test.tsx b/ui/src/components/AssistantChat.test.tsx new file mode 100644 index 00000000000..7f463f4f440 --- /dev/null +++ b/ui/src/components/AssistantChat.test.tsx @@ -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("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; + backgroundWorkChildren?: unknown[]; + suppressIssueStatusNotices?: boolean; + composerHint?: string | null; + imageUploadHandler?: (file: File) => Promise; + onAttachImage?: (file: File) => Promise; + }) => { + const [draft, setDraft] = React.useState(mockComposerDraft.value || "hello"); + React.useImperativeHandle(props.composerRef, () => ({ + focus: () => {}, + restoreDraft: (body: string) => setDraft(body), + setDraft: (body: string) => setDraft(body), + }), []); + return ( +
+ {props.preset} + {props.emptyState ? "" : props.emptyMessage} +
{props.emptyState}
+ + {String(props.showJumpToLatest ?? (props.preset === "assistant" ? false : true))} + + {props.backgroundWorkChildren?.length ?? 0} + + {(props.suppressIssueStatusNotices ?? props.preset === "assistant") ? "suppressed" : "visible"} + + {props.composerHint} + {String(Boolean(props.imageUploadHandler))} + {String(Boolean(props.onAttachImage))} + {draft} +
+ {props.comments?.map((comment) => ( + {comment.body} + ))} +
+ +
+ ); + }, + }; +}); + +function makeAgent(overrides: Partial): 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 { + 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() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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( + + + , + ); + }); + } + + 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( + {}} + />, + ); + 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( + {}} + 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( + {}} + />, + ); + + 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( + {}} />, + ); + 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( + {}} + />, + ); + 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( + , + ); + 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( + {}} + />, + ); + + 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( + {}} + />, + ); + + 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( + "/api/attachments/image/content"} + onAttachImage={async () => undefined} + onSend={async () => {}} + />, + ); + + expect(container.querySelector('[data-testid="image-upload-enabled"]')?.textContent).toBe("true"); + expect(container.querySelector('[data-testid="attach-enabled"]')?.textContent).toBe("true"); + }); + + it("offers the switcher only when more than one agent is invokable", () => { + render( + {}} onTargetAgentChange={() => {}} />, + ); + expect(container.querySelector('[aria-label="Choose chat agent"]')).toBeNull(); + + render( + {}} + onTargetAgentChange={() => {}} + />, + ); + expect(container.querySelector('[aria-label="Choose chat agent"]')).not.toBeNull(); + }); +}); diff --git a/ui/src/components/AssistantChat.tsx b/ui/src/components/AssistantChat.tsx new file mode 100644 index 00000000000..47cb989b848 --- /dev/null +++ b/ui/src/components/AssistantChat.tsx @@ -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; + +/** 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; + onAttachImage?: (file: File) => Promise; + /** True while the first comments fetch is in flight (no data yet). */ + loading?: boolean; + /** Surface a delivery/transport failure inline (CR8). */ + errorText?: string | null; + onRetry?: () => void; + onSend: (body: string) => Promise; + onStopRun?: (runId: string) => Promise; + onVote?: ( + commentId: string, + vote: FeedbackVoteValue, + options?: { allowSharing?: boolean; reason?: string }, + ) => Promise; + onAcceptInteraction?: ( + interaction: InteractionAccept, + selectedClientKeys?: string[], + selectedOptionIds?: string[], + ) => Promise | void; + onRejectInteraction?: (interaction: InteractionAccept, reason?: string) => Promise | void; + onSubmitInteractionAnswers?: ( + interaction: AskUserQuestionsInteraction, + answers: AskUserQuestionsAnswer[], + ) => Promise | void; + onCancelInteraction?: (interaction: AskUserQuestionsInteraction) => Promise | 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(null); + const targetAgent = targetAgentId ? agentMap.get(targetAgentId) ?? null : null; + const invokableAgents = useMemo(() => orderInvokableAgents(agents), [agents]); + + const mentions = useMemo( + () => + 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 ? ( +
+
+ {starterPrompts.map((prompt) => ( + + ))} +
+
+ ) : undefined; + const emptyState = providedEmptyState ?? starterPromptState; + + return ( +
+ {header ?? ( +
+
+ {canSwitch ? ( + + ) : ( +
+ + + {targetAgent?.icon ? ( + + ) : ( + agentInitials(targetName) + )} + + +
+
{targetName}
+ {targetRole ? ( +
{targetRole}
+ ) : null} +
+
+ )} +
+ )} + + {errorText ? ( +
+ +
+

{errorText}

+

+ Your message was kept in the composer so you can try again. +

+
+ {onRetry ? ( + + ) : null} +
+ ) : null} + + {loading && comments.length === 0 ? ( +
+
+
+
+
+ ) : ( +
+ +
+ )} +
+ ); +} + +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; + 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( + 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(null); + const [pendingComments, setPendingComments] = useState([]); + + 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(); + 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 ( + setErrorText(null) : undefined} + onSend={handleSend} + onStopRun={handleStopRun} + onVote={handleVote} + onAcceptInteraction={handleAcceptInteraction} + onRejectInteraction={handleRejectInteraction} + onSubmitInteractionAnswers={handleSubmitInteractionAnswers} + onCancelInteraction={handleCancelInteraction} + emptyMessage={emptyMessage} + className={className} + /> + ); +} diff --git a/ui/src/components/SelectedAgentChat.test.tsx b/ui/src/components/SelectedAgentChat.test.tsx deleted file mode 100644 index 9f5a838fa6a..00000000000 --- a/ui/src/components/SelectedAgentChat.test.tsx +++ /dev/null @@ -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; - backgroundWorkChildren?: unknown[]; - suppressIssueStatusNotices?: boolean; - composerHint?: string | null; - imageUploadHandler?: (file: File) => Promise; - onAttachImage?: (file: File) => Promise; - }) => ( -
- {props.preset} - {props.emptyState ? "" : props.emptyMessage} -
{props.emptyState}
- - {String(props.showJumpToLatest ?? (props.preset === "assistant" ? false : true))} - - {props.backgroundWorkChildren?.length ?? 0} - - {(props.suppressIssueStatusNotices ?? props.preset === "assistant") ? "suppressed" : "visible"} - - {props.composerHint} - {String(Boolean(props.imageUploadHandler))} - {String(Boolean(props.onAttachImage))} - -
- ), -})); - -function makeAgent(overrides: Partial): 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( - {}} - />, - ); - 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( - {}} - 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( - {}} - />, - ); - - 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( - {}} />, - ); - 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( - {}} - />, - ); - 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( - , - ); - 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( - {}} - />, - ); - - 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( - {}} - />, - ); - - 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( - "/api/attachments/image/content"} - onAttachImage={async () => undefined} - onSend={async () => {}} - />, - ); - - expect(container.querySelector('[data-testid="image-upload-enabled"]')?.textContent).toBe("true"); - expect(container.querySelector('[data-testid="attach-enabled"]')?.textContent).toBe("true"); - }); - - it("offers the switcher only when more than one agent is invokable", () => { - render( - {}} onTargetAgentChange={() => {}} />, - ); - expect(container.querySelector('[aria-label="Choose chat agent"]')).toBeNull(); - - render( - {}} - onTargetAgentChange={() => {}} - />, - ); - expect(container.querySelector('[aria-label="Choose chat agent"]')).not.toBeNull(); - }); -}); diff --git a/ui/src/components/SelectedAgentChat.tsx b/ui/src/components/SelectedAgentChat.tsx index eed7ca54366..8d76a4e2a98 100644 --- a/ui/src/components/SelectedAgentChat.tsx +++ b/ui/src/components/SelectedAgentChat.tsx @@ -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; +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; - onAttachImage?: (file: File) => Promise; - /** True while the first comments fetch is in flight (no data yet). */ - loading?: boolean; - /** Surface a delivery/transport failure inline (CR8). */ - errorText?: string | null; - onRetry?: () => void; - onSend: (body: string) => Promise; - onStopRun?: (runId: string) => Promise; - onVote?: ( - commentId: string, - vote: FeedbackVoteValue, - options?: { allowSharing?: boolean; reason?: string }, - ) => Promise; - onAcceptInteraction?: ( - interaction: InteractionAccept, - selectedClientKeys?: string[], - selectedOptionIds?: string[], - ) => Promise | void; - onRejectInteraction?: (interaction: InteractionAccept, reason?: string) => Promise | void; - onSubmitInteractionAnswers?: ( - interaction: AskUserQuestionsInteraction, - answers: AskUserQuestionsAnswer[], - ) => Promise | void; - onCancelInteraction?: (interaction: AskUserQuestionsInteraction) => Promise | 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(null); - const targetAgent = targetAgentId ? agentMap.get(targetAgentId) ?? null : null; - const invokableAgents = useMemo(() => orderInvokableAgents(agents), [agents]); - - const mentions = useMemo( - () => - 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 ? ( -
-
- {starterPrompts.map((prompt) => ( - - ))} -
-
- ) : undefined; - - return ( -
- {/* Identity header — real selected agent (no board-concierge persona). */} -
-
- {canSwitch ? ( - - ) : ( -
- - - {targetAgent?.icon ? ( - - ) : ( - agentInitials(targetName) - )} - - -
-
{targetName}
- {targetRole ? ( -
{targetRole}
- ) : null} -
-
- )} -
- - {errorText ? ( -
- -
-

{errorText}

-

- Your message was kept in the composer so you can try again. -

-
- {onRetry ? ( - - ) : null} -
- ) : null} - - {loading && comments.length === 0 ? ( -
- -
- ) : ( -
- -
- )} -
- ); -} - -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 & { 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; - 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( - 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(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(); - 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 ( - setErrorText(null) : undefined} - onSend={handleSend} - onStopRun={handleStopRun} - onVote={handleVote} - onAcceptInteraction={handleAcceptInteraction} - onRejectInteraction={handleRejectInteraction} - onSubmitInteractionAnswers={handleSubmitInteractionAnswers} - onCancelInteraction={handleCancelInteraction} - emptyMessage={emptyMessage} - className={className} - /> - ); + return ; +} + +export function SelectedAgentChatView({ + conferenceRoomMode: _conferenceRoomMode, + ...props +}: SelectedAgentChatViewProps) { + return ; } diff --git a/ui/src/pages/BoardChat.test.tsx b/ui/src/pages/BoardChat.test.tsx index 98aa6a5c4b8..d40ec94822d 100644 --- a/ui/src/pages/BoardChat.test.tsx +++ b/ui/src/pages/BoardChat.test.tsx @@ -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>); +const mockAssistantChatProps = vi.hoisted(() => [] as Array>); 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: () =>
, })); -vi.mock("../components/SelectedAgentChat", () => ({ - SelectedAgentChat: (props: Record) => { - mockSelectedAgentChatProps.push(props); +vi.mock("../components/AssistantChat", () => ({ + AssistantChat: (props: Record) => { + mockAssistantChatProps.push(props); return (
); }, @@ -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(); diff --git a/ui/src/pages/BoardChat.tsx b/ui/src/pages/BoardChat.tsx index 0f656ac113d..9d767c37d6d 100644 --- a/ui/src/pages/BoardChat.tsx +++ b/ui/src/pages/BoardChat.tsx @@ -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. */}

@@ -454,18 +454,17 @@ export function BoardChat() { - {/* 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 ? ( -