diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index fbaad815d7e..3ea6167c9ae 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -101,10 +101,14 @@ export const heartbeatsApi = { reason: input.reason ?? null, snoozedUntil: input.snoozedUntil ?? null, }), - liveRunsForIssue: (issueId: string) => - api.get(`/issues/${issueId}/live-runs`), - activeRunForIssue: (issueId: string) => - api.get(`/issues/${issueId}/active-run`), + liveRunsForIssue: (issueId: string, targetAgentId?: string | null) => + api.get( + `/issues/${issueId}/live-runs${targetAgentId ? `?targetAgentId=${encodeURIComponent(targetAgentId)}` : ""}`, + ), + activeRunForIssue: (issueId: string, targetAgentId?: string | null) => + api.get( + `/issues/${issueId}/active-run${targetAgentId ? `?targetAgentId=${encodeURIComponent(targetAgentId)}` : ""}`, + ), liveRunsForCompany: ( companyId: string, options?: number | { minCount?: number; limit?: number }, diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 6ef21e25514..7ada3c05317 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -29,6 +29,18 @@ export type IssueUpdateResponse = Issue & { comment?: IssueComment | null; }; +export type SelectedAgentChatTarget = { + id: string; + name: string; + role: string; + status: string; +}; + +export type SelectedAgentChatCommentResponse = { + comment: IssueComment; + targetAgent: SelectedAgentChatTarget; +}; + export type ResolveRecoveryActionResponse = { issue: Issue; recoveryAction: IssueRecoveryAction; @@ -263,6 +275,23 @@ export const issuesApi = { ...(interrupt === undefined ? {} : { interrupt }), }, ), + // Selected-agent chat: post a message that wakes a real agent (default: the + // company CEO) to reply on this issue. The reply lands as a normal, + // real-agent-authored comment and the run is visible via live-runs/active-run + // scoped to the same `targetAgentId`. Backend: PAP-11115. + addSelectedAgentChatComment: ( + id: string, + body: string, + options?: { targetAgentId?: string | null; interrupt?: boolean }, + ) => + api.post( + `/issues/${id}/selected-agent-chat/comments`, + { + body, + ...(options?.targetAgentId ? { targetAgentId: options.targetAgentId } : {}), + ...(options?.interrupt === undefined ? {} : { interrupt: options.interrupt }), + }, + ), cancelComment: (id: string, commentId: string) => api.delete(`/issues/${id}/comments/${commentId}?mode=cancel`), deleteComment: (id: string, commentId: string) => diff --git a/ui/src/components/SelectedAgentChat.test.tsx b/ui/src/components/SelectedAgentChat.test.tsx new file mode 100644 index 00000000000..3f7c3a748c9 --- /dev/null +++ b/ui/src/components/SelectedAgentChat.test.tsx @@ -0,0 +1,170 @@ +// @vitest-environment jsdom + +import { act } from "react"; +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: { emptyMessage?: string; onAdd: (b: string) => Promise }) => ( +
+ {props.emptyMessage} + +
+ ), +})); + +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(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + }); + + function render(node: React.ReactNode) { + act(() => root.render(node)); + } + + 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("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(); + act(() => 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; + await act(async () => { + send.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onSend).toHaveBeenCalledWith("hello"); + }); + + 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 new file mode 100644 index 00000000000..360c613a09b --- /dev/null +++ b/ui/src/components/SelectedAgentChat.tsx @@ -0,0 +1,507 @@ +import { useCallback, useEffect, useMemo, 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 IssueThreadInteraction, + type RequestCheckboxConfirmationInteraction, + type RequestConfirmationInteraction, + type SuggestTasksInteraction, +} from "@paperclipai/shared"; +import { AlertCircle, Loader2 } from "lucide-react"; +import { IssueChatThread } 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 conversation while the surface is open. */ +const SELECTED_AGENT_CHAT_POLL_MS = 3000; + +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; + /** 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, + 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 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; + + return ( +
+ {/* Identity header — real selected agent (no board-concierge persona). */} +
+
+
+ + + {targetAgent?.icon ? ( + + ) : ( + agentInitials(targetName) + )} + + +
+
{targetName}
+ {targetRole ? ( +
{targetRole}
+ ) : null} +
+
+ {canSwitch ? ( + + ) : 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. */ + defaultTargetAgentId?: string | null; + showAgentSwitcher?: boolean; + currentUserId?: string | null; + emptyMessage?: string; + className?: string; +} + +/** + * 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, + currentUserId, + emptyMessage, + className, +}: 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 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 invalidateRuns = useCallback(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(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 }); + 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, 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], + ); + + 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/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 528cddb5544..1749ce9c9c3 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -89,6 +89,10 @@ export const queryKeys = { approvals: (issueId: string) => ["issues", "approvals", issueId] as const, liveRuns: (issueId: string) => ["issues", "live-runs", issueId] as const, activeRun: (issueId: string) => ["issues", "active-run", issueId] as const, + selectedAgentChatLiveRuns: (issueId: string, targetAgentId: string) => + ["issues", "live-runs", issueId, "agent", targetAgentId] as const, + selectedAgentChatActiveRun: (issueId: string, targetAgentId: string) => + ["issues", "active-run", issueId, "agent", targetAgentId] as const, workProducts: (issueId: string) => ["issues", "work-products", issueId] as const, fileResources: ( issueId: string, diff --git a/ui/storybook/stories/selected-agent-chat.stories.tsx b/ui/storybook/stories/selected-agent-chat.stories.tsx new file mode 100644 index 00000000000..1b103ea41b9 --- /dev/null +++ b/ui/storybook/stories/selected-agent-chat.stories.tsx @@ -0,0 +1,186 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Agent, IssueComment } from "@paperclipai/shared"; +import type { ActiveRunForIssue, LiveRunForIssue } from "@/api/heartbeats"; +import { SelectedAgentChatView } from "@/components/SelectedAgentChat"; +import type { IssueChatComment } from "@/lib/issue-chat-messages"; +import { pendingSuggestedTasksInteraction } from "@/fixtures/issueThreadInteractionFixtures"; +import { storybookAgents } from "../fixtures/paperclipData"; + +const companyId = "company-storybook"; +const projectId = "project-board-ui"; +const issueId = "issue-selected-agent-chat"; +const currentUserId = "user-board"; + +/** Conference-room CEO target — the real selected agent (no concierge persona). */ +const ceoAgent: Agent = { + ...storybookAgents[0]!, + id: "agent-ceo", + name: "Sarah Okafor", + urlKey: "sarah-okafor", + role: "ceo", + title: "Chief Executive", + icon: "rocket", + status: "active", +}; + +const chatAgents: Agent[] = [ceoAgent, ...storybookAgents]; + +function createComment(overrides: Partial): IssueChatComment { + const createdAt = overrides.createdAt ?? new Date("2026-06-14T15:00:00.000Z"); + const authorAgentId = overrides.authorAgentId ?? null; + return { + id: "comment-default", + companyId, + issueId, + authorAgentId: null, + authorUserId: authorAgentId ? null : currentUserId, + body: "", + authorType: authorAgentId ? "agent" : "user", + presentation: null, + metadata: null, + createdAt, + updatedAt: overrides.updatedAt ?? createdAt, + ...overrides, + } as IssueChatComment; +} + +const conversationComments: IssueChatComment[] = [ + createComment({ + id: "c-user-1", + body: "How is the MCP connector work going? Anything I should worry about?", + createdAt: new Date("2026-06-14T15:00:00.000Z"), + }), + createComment({ + id: "c-ceo-1", + authorAgentId: ceoAgent.id, + body: + "**Report.** The MCP connector work is on track. Two of three connectors are live and " + + "verified; the third (Sheets) is waiting on a Google API key.\n\n" + + "**What I checked**\n" + + "- The connector health dashboard (all green except Sheets)\n" + + "- The open issues under the integrations project\n\n" + + "**Recommendation.** Unblock the Sheets connector by providing the API key, or I can have " + + "the team ship the first two now and follow with Sheets.", + createdAt: new Date("2026-06-14T15:01:30.000Z"), + }), +]; + +const runningActiveRun: ActiveRunForIssue = { + id: "run-ceo-active", + status: "running", + invocationSource: "automation", + triggerDetail: "system", + startedAt: "2026-06-14T15:05:00.000Z", + finishedAt: null, + createdAt: "2026-06-14T15:05:00.000Z", + agentId: ceoAgent.id, + agentName: ceoAgent.name, + adapterType: "claude_code", + issueId, +}; + +const runningLiveRun: LiveRunForIssue = { + id: runningActiveRun.id, + status: "running", + invocationSource: "automation", + triggerDetail: "system", + startedAt: "2026-06-14T15:05:00.000Z", + finishedAt: null, + createdAt: "2026-06-14T15:05:00.000Z", + agentId: ceoAgent.id, + agentName: ceoAgent.name, + adapterType: "claude_code", + issueId, +}; + +function Frame({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/** Stateful wrapper so the agent switcher is interactive in the canvas. */ +function ChatHarness( + props: Omit, "targetAgentId" | "onTargetAgentChange">, +) { + const [targetAgentId, setTargetAgentId] = useState(ceoAgent.id); + return ( + + + + ); +} + +const baseProps = { + agents: chatAgents, + companyId, + projectId, + issueId, + currentUserId, + onSend: async () => {}, +} satisfies Partial>; + +const meta: Meta = { + title: "Chat & Comments/Selected-Agent Chat", + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +/** Empty / idle — composer ready, default CEO target, switcher available. */ +export const EmptyIdle: Story = { + render: () => , +}; + +/** Loading — first comments fetch in flight. */ +export const Loading: Story = { + render: () => , +}; + +/** Durable history — user message + a finished real-agent report. */ +export const History: Story = { + render: () => , +}; + +/** Active run — the CEO is working; live-run row + "view live run" surface. */ +export const ActiveRun: Story = { + render: () => ( + + ), +}; + +/** Status report + next-step options rendered as a real suggest_tasks card. */ +export const StatusReportWithOptions: Story = { + render: () => ( + + ), +}; + +/** Error — delivery failed; message preserved as draft with Try again (CR8). */ +export const DeliveryError: Story = { + render: () => ( + {}} + /> + ), +};