mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
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:
1 parent
201bc4b52f
commit
8eda59d9a9
6 files changed
+904
-4
No files matched your search
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
Reference in new issue
Block a user