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