Add selected-agent chat UI surface

Implements the Phase 3 selected-agent chat experience (PAP-11117) as a
reusable, issue-backed surface that reuses the existing IssueChatThread
renderer.

- SelectedAgentChatView (presentational) + SelectedAgentChat (connected):
  real-agent identity header, CEO-default agent switcher (hideable),
  durable comment history, target-scoped live/active-run output, inline
  delivery error with draft preservation, loading + empty states.
- API: issuesApi.addSelectedAgentChatComment + targetAgentId on the
  live-runs/active-run heartbeat endpoints; new query keys.
- Storybook stories for every state + component/unit tests (10 passing).

No board-concierge persona; assistant comments are authored by the real
agent id. Next-step choices render as real suggest_tasks/request_confirmation
interaction cards via IssueChatThread.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-16 10:02:04 +00:00
1 parent 201bc4b52f
commit 8eda59d9a9
6 files changed
+904 -4

No files matched your search

+8 -4
View File
@@ -101,10 +101,14 @@ export const heartbeatsApi = {
reason: input.reason ?? null,
snoozedUntil: input.snoozedUntil ?? null,
}),
liveRunsForIssue: (issueId: string) =>
api.get<LiveRunForIssue[]>(`/issues/${issueId}/live-runs`),
activeRunForIssue: (issueId: string) =>
api.get<ActiveRunForIssue | null>(`/issues/${issueId}/active-run`),
liveRunsForIssue: (issueId: string, targetAgentId?: string | null) =>
api.get<LiveRunForIssue[]>(
`/issues/${issueId}/live-runs${targetAgentId ? `?targetAgentId=${encodeURIComponent(targetAgentId)}` : ""}`,
),
activeRunForIssue: (issueId: string, targetAgentId?: string | null) =>
api.get<ActiveRunForIssue | null>(
`/issues/${issueId}/active-run${targetAgentId ? `?targetAgentId=${encodeURIComponent(targetAgentId)}` : ""}`,
),
liveRunsForCompany: (
companyId: string,
options?: number | { minCount?: number; limit?: number },
+29
View File
@@ -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<SelectedAgentChatCommentResponse>(
`/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<IssueComment>(`/issues/${id}/comments/${commentId}?mode=cancel`),
deleteComment: (id: string, commentId: string) =>
@@ -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<void> }) => (
<div data-testid="issue-chat-thread">
<span data-testid="empty-message">{props.emptyMessage}</span>
<button type="button" data-testid="send" onClick={() => void props.onAdd("hello")}>
send
</button>
</div>
),
}));
function makeAgent(overrides: Partial<Agent>): Agent {
return {
id: "agent-x",
companyId: "company-1",
name: "Agent X",
urlKey: "agent-x",
role: "engineer",
title: null,
icon: null,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "claude_code",
adapterConfig: {},
runtimeConfig: {} as Agent["runtimeConfig"],
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: {} as Agent["permissions"],
lastHeartbeatAt: null,
...overrides,
} as Agent;
}
const ceo = makeAgent({ id: "agent-ceo", name: "Sarah", role: "ceo" });
const eng = makeAgent({ id: "agent-eng", name: "Dev", role: "engineer" });
const terminated = makeAgent({ id: "agent-dead", name: "Zed", status: "terminated" });
describe("resolveDefaultChatTarget", () => {
it("defaults to the CEO", () => {
expect(resolveDefaultChatTarget([eng, ceo])?.id).toBe("agent-ceo");
});
it("honors a preferred agent when present", () => {
expect(resolveDefaultChatTarget([eng, ceo], "agent-eng")?.id).toBe("agent-eng");
});
it("skips terminated preferred and falls back to CEO", () => {
expect(resolveDefaultChatTarget([ceo, eng, terminated], "agent-dead")?.id).toBe("agent-ceo");
});
it("falls back to first active agent when there is no CEO", () => {
expect(resolveDefaultChatTarget([terminated, eng])?.id).toBe("agent-eng");
});
it("returns null for an empty roster", () => {
expect(resolveDefaultChatTarget([])).toBeNull();
});
});
describe("SelectedAgentChatView", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
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(
<SelectedAgentChatView
agents={[ceo, eng]}
targetAgentId={ceo.id}
comments={[]}
onSend={async () => {}}
/>,
);
expect(container.textContent).toContain("Sarah");
expect(container.textContent).toContain("CEO");
// No board-concierge persona leaks into the surface.
expect(container.textContent?.toLowerCase()).not.toContain("concierge");
});
it("shows a loading indicator while the first fetch is in flight", () => {
render(
<SelectedAgentChatView agents={[ceo]} targetAgentId={ceo.id} comments={[]} loading onSend={async () => {}} />,
);
expect(container.querySelector('[aria-label="Loading conversation"]')).not.toBeNull();
expect(container.querySelector('[data-testid="issue-chat-thread"]')).toBeNull();
});
it("surfaces a delivery error with a Try again affordance", () => {
const onRetry = vi.fn();
render(
<SelectedAgentChatView
agents={[ceo]}
targetAgentId={ceo.id}
comments={[]}
errorText="Could not deliver."
onRetry={onRetry}
onSend={async () => {}}
/>,
);
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain("Could not deliver.");
const retry = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent === "Try again",
);
expect(retry).toBeTruthy();
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(
<SelectedAgentChatView agents={[ceo]} targetAgentId={ceo.id} comments={[]} onSend={onSend} />,
);
expect(container.querySelector('[data-testid="empty-message"]')?.textContent).toContain(
"Sarah",
);
const send = container.querySelector('[data-testid="send"]') as HTMLButtonElement;
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(
<SelectedAgentChatView agents={[ceo]} targetAgentId={ceo.id} comments={[]} onSend={async () => {}} onTargetAgentChange={() => {}} />,
);
expect(container.querySelector('[aria-label="Choose chat agent"]')).toBeNull();
render(
<SelectedAgentChatView
agents={[ceo, eng]}
targetAgentId={ceo.id}
comments={[] as IssueChatComment[]}
onSend={async () => {}}
onTargetAgentChange={() => {}}
/>,
);
expect(container.querySelector('[aria-label="Choose chat agent"]')).not.toBeNull();
});
});
+507
View File
@@ -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<string, string>;
/** 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<void>;
onStopRun?: (runId: string) => Promise<void>;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
onAcceptInteraction?: (
interaction: InteractionAccept,
selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => Promise<void> | void;
onRejectInteraction?: (interaction: InteractionAccept, reason?: string) => Promise<void> | void;
onSubmitInteractionAnswers?: (
interaction: AskUserQuestionsInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void> | void;
onCancelInteraction?: (interaction: AskUserQuestionsInteraction) => Promise<void> | void;
emptyMessage?: string;
className?: string;
}
/**
* Presentational selected-agent chat surface. Renders the real-agent identity
* header + switcher and delegates the message stream, interaction cards, and
* live/active-run rows to the shared {@link IssueChatThread}. Data and handlers
* are injected so the same view powers both the connected app surface and
* Storybook state fixtures (idle / loading / active-run / error / history).
*/
export function SelectedAgentChatView({
agents,
targetAgentId,
onTargetAgentChange,
showAgentSwitcher = true,
comments,
interactions,
liveRuns,
activeRun,
feedbackVotes,
issueId,
companyId,
projectId,
currentUserId,
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<MentionOption[]>(
() =>
invokableAgents.map((a) => ({
id: `agent:${a.id}`,
name: a.name,
kind: "agent",
agentId: a.id,
agentIcon: a.icon,
})),
[invokableAgents],
);
const handleAdd = useCallback(
(body: string) => onSend(body),
[onSend],
);
const targetName = targetAgent?.name ?? "Assistant";
const targetRole = targetAgent ? roleLabels[targetAgent.role] ?? targetAgent.role : null;
const canSwitch = showAgentSwitcher && invokableAgents.length > 1 && !!onTargetAgentChange;
return (
<div className={cn("flex min-h-0 min-w-0 flex-1 flex-col", className)}>
{/* Identity header — real selected agent (no board-concierge persona). */}
<div className="relative flex shrink-0 items-center justify-between gap-2 px-4 py-3">
<div
className="pointer-events-none absolute bottom-0 left-0 right-0 h-px bg-border"
aria-hidden
/>
<div className="flex min-w-0 items-center gap-2">
<Avatar size="sm" className="shrink-0">
<AvatarFallback>
{targetAgent?.icon ? (
<AgentIcon icon={targetAgent.icon} className="h-3.5 w-3.5" />
) : (
agentInitials(targetName)
)}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-foreground">{targetName}</div>
{targetRole ? (
<div className="truncate text-xs text-muted-foreground">{targetRole}</div>
) : null}
</div>
</div>
{canSwitch ? (
<Select
value={targetAgentId ?? undefined}
onValueChange={(value) => onTargetAgentChange?.(value)}
>
<SelectTrigger
size="sm"
className="w-auto min-w-[9rem] gap-2"
aria-label="Choose chat agent"
>
<SelectValue placeholder="Choose agent" />
</SelectTrigger>
<SelectContent align="end">
{invokableAgents.map((a) => (
<SelectItem key={a.id} value={a.id}>
<span className="flex items-center gap-2">
<AgentIcon icon={a.icon} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{a.name}</span>
<span className="text-xs text-muted-foreground">
{roleLabels[a.role] ?? a.role}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
{errorText ? (
<div
role="alert"
className="mx-4 mt-3 flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="break-words">{errorText}</p>
<p className="mt-0.5 text-xs text-destructive/80">
Your message was kept in the composer so you can try again.
</p>
</div>
{onRetry ? (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 border-destructive/40 text-destructive hover:bg-destructive/10"
onClick={onRetry}
>
Try again
</Button>
) : null}
</div>
) : null}
{loading && comments.length === 0 ? (
<div className="flex flex-1 items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading conversation" />
</div>
) : (
<IssueChatThread
variant="full"
comments={comments}
interactions={interactions}
liveRuns={liveRuns}
activeRun={activeRun}
feedbackVotes={feedbackVotes}
issueId={issueId}
companyId={companyId}
projectId={projectId}
agentMap={agentMap}
currentUserId={currentUserId}
mentions={mentions}
emptyMessage={
emptyMessage ?? `Send ${targetName} a message to start the conversation.`
}
onAdd={handleAdd}
onStopRun={onStopRun}
onVote={onVote}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction}
/>
)}
</div>
);
}
export interface SelectedAgentChatProps {
issueId: string;
companyId: string;
projectId?: string | null;
/** Pre-loaded agents (e.g. from the page). Fetched if omitted. */
agents?: readonly Agent[];
/** Preferred initial target; defaults to the company CEO. */
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<string | null>(
defaultTargetAgentId ?? null,
);
// Lock the default target onto the resolved CEO once agents load.
useEffect(() => {
if (targetAgentId) return;
const fallback = resolveDefaultChatTarget(agents, defaultTargetAgentId);
if (fallback) setTargetAgentId(fallback.id);
}, [agents, defaultTargetAgentId, targetAgentId]);
const [errorText, setErrorText] = useState<string | null>(null);
const commentsQuery = useQuery({
queryKey: queryKeys.issues.comments(issueId),
queryFn: () => issuesApi.listComments(issueId),
refetchInterval: SELECTED_AGENT_CHAT_POLL_MS,
});
const interactionsQuery = useQuery({
queryKey: queryKeys.issues.interactions(issueId),
queryFn: () => issuesApi.listInteractions(issueId),
refetchInterval: SELECTED_AGENT_CHAT_POLL_MS,
});
const 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 (
<SelectedAgentChatView
agents={agents}
targetAgentId={targetAgentId}
onTargetAgentChange={setTargetAgentId}
showAgentSwitcher={showAgentSwitcher}
comments={(commentsQuery.data ?? []) as IssueChatComment[]}
interactions={interactionsQuery.data}
liveRuns={liveRunsQuery.data}
activeRun={activeRunQuery.data ?? null}
feedbackVotes={feedbackVotesQuery.data}
issueId={issueId}
companyId={companyId}
projectId={projectId}
currentUserId={currentUserId}
loading={commentsQuery.isLoading}
errorText={errorText}
onRetry={errorText ? () => setErrorText(null) : undefined}
onSend={handleSend}
onStopRun={handleStopRun}
onVote={handleVote}
onAcceptInteraction={handleAcceptInteraction}
onRejectInteraction={handleRejectInteraction}
onSubmitInteractionAnswers={handleSubmitInteractionAnswers}
onCancelInteraction={handleCancelInteraction}
emptyMessage={emptyMessage}
className={className}
/>
);
}
+4
View File
@@ -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,
@@ -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>): 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 (
<div className="paperclip-story__frame mx-auto flex h-[640px] w-full max-w-3xl flex-col overflow-hidden">
{children}
</div>
);
}
/** Stateful wrapper so the agent switcher is interactive in the canvas. */
function ChatHarness(
props: Omit<React.ComponentProps<typeof SelectedAgentChatView>, "targetAgentId" | "onTargetAgentChange">,
) {
const [targetAgentId, setTargetAgentId] = useState<string>(ceoAgent.id);
return (
<Frame>
<SelectedAgentChatView
{...props}
targetAgentId={targetAgentId}
onTargetAgentChange={setTargetAgentId}
/>
</Frame>
);
}
const baseProps = {
agents: chatAgents,
companyId,
projectId,
issueId,
currentUserId,
onSend: async () => {},
} satisfies Partial<React.ComponentProps<typeof SelectedAgentChatView>>;
const meta: Meta<typeof SelectedAgentChatView> = {
title: "Chat & Comments/Selected-Agent Chat",
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof SelectedAgentChatView>;
/** Empty / idle — composer ready, default CEO target, switcher available. */
export const EmptyIdle: Story = {
render: () => <ChatHarness {...baseProps} comments={[]} />,
};
/** Loading — first comments fetch in flight. */
export const Loading: Story = {
render: () => <ChatHarness {...baseProps} comments={[]} loading />,
};
/** Durable history — user message + a finished real-agent report. */
export const History: Story = {
render: () => <ChatHarness {...baseProps} comments={conversationComments} />,
};
/** Active run — the CEO is working; live-run row + "view live run" surface. */
export const ActiveRun: Story = {
render: () => (
<ChatHarness
{...baseProps}
comments={[conversationComments[0]!]}
activeRun={runningActiveRun}
liveRuns={[runningLiveRun]}
/>
),
};
/** Status report + next-step options rendered as a real suggest_tasks card. */
export const StatusReportWithOptions: Story = {
render: () => (
<ChatHarness
{...baseProps}
comments={conversationComments}
interactions={[pendingSuggestedTasksInteraction]}
/>
),
};
/** Error — delivery failed; message preserved as draft with Try again (CR8). */
export const DeliveryError: Story = {
render: () => (
<ChatHarness
{...baseProps}
comments={conversationComments}
errorText="The message couldn't be delivered to Sarah Okafor (network error)."
onRetry={() => {}}
/>
),
};