Files
PaperClipAI/ui/src/components/AssistantChat.tsx
T
DottaandClaude Fable 5 048e79f094 Fix conference room chat scroll, identity, feed links (PAP-13345)
- Give the assistant chat body its own scroll container so loading or
  scrolling a chat never moves the page or the sidebar.
- Render the conference room with issue-chat density: the board user's
  messages are right-aligned blue bubbles, and the company user directory
  now resolves real names/avatars instead of "You".
- Report heartbeat scheduling suppression from the selected-agent-chat
  comment route and surface it as an inline notice, so a send on a
  worktree instance no longer looks like it silently did nothing.
- Deep-link Agent Feed events on board_chat issues to /board-chat/{ref}
  (they are hidden from the issues list, so the old link was a broken
  /issues/<uuid>), and stop showing the board user's own comment events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 00:36:19 +00:00

866 lines
30 KiB
TypeScript

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 { accessApi } from "../api/access";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { heartbeatsApi } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import {
buildCompanyUserLabelMap,
buildCompanyUserProfileMap,
type CompanyUserProfile,
} from "../lib/company-members";
import type { IssueChatComment, IssueChatTranscriptEntry } 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;
transcriptsByRunId?: ReadonlyMap<string, readonly IssueChatTranscriptEntry[]>;
feedbackVotes?: FeedbackVote[];
issueId?: string | null;
companyId?: string | null;
projectId?: string | null;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | 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;
/** Non-error inline notice (e.g. delivered but the agent can't run here). */
noticeText?: 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,
transcriptsByRunId,
feedbackVotes,
issueId,
companyId,
projectId,
currentUserId,
userLabelMap,
userProfileMap,
backgroundWorkChildren = [],
suppressIssueStatusNotices,
companyName = null,
header,
starterPrompts: providedStarterPrompts,
emptyState: providedEmptyState,
composerHint,
imageUploadHandler,
onAttachImage,
loading = false,
errorText,
noticeText,
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}
{noticeText ? (
<div
role="status"
data-testid="assistant-chat-notice"
className="mx-4 mt-3 flex items-start gap-2 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2 text-sm text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<p className="min-w-0 flex-1 break-words">{noticeText}</p>
</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"
// Own scroll container: chat scrolling (including the initial
// scroll-to-latest) must never move the page or the sidebar.
className="min-h-0 min-w-0 flex-1 overflow-y-auto px-4 pb-4 pt-3"
>
<IssueChatThread
preset="assistant"
variant="full"
composerRef={composerRef}
comments={comments}
interactions={interactions}
liveRuns={liveRuns}
activeRun={activeRun}
transcriptsByRunId={transcriptsByRunId}
feedbackVotes={feedbackVotes}
issueId={issueId}
companyId={companyId}
projectId={projectId}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
userProfileMap={userProfileMap}
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 [noticeText, setNoticeText] = useState<string | null>(null);
const [pendingComments, setPendingComments] = useState<PendingAssistantChatComment[]>([]);
// Resolve human author names/avatars (issue-chat parity: "Dotta", not "You").
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(companyId),
queryFn: () => accessApi.listUserDirectory(companyId),
});
const userLabelMap = useMemo(
() => buildCompanyUserLabelMap(companyMembers?.users),
[companyMembers?.users],
);
const userProfileMap = useMemo(
() => buildCompanyUserProfileMap(companyMembers?.users),
[companyMembers?.users],
);
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);
setNoticeText(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,
),
);
if (response.wake?.suppressed) {
const agentName = response.targetAgent?.name ?? "The agent";
setNoticeText(
response.wake.reason === "worktree_instance"
? `Message saved, but agent runs are disabled on this worktree instance — ${agentName} won't reply here.`
: `Message saved, but agent runs are paused on this instance right now — ${agentName} can't reply yet.`,
);
}
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}
userLabelMap={userLabelMap}
userProfileMap={userProfileMap}
backgroundWorkChildren={backgroundWorkChildren}
suppressIssueStatusNotices
companyName={companyName}
header={header}
starterPrompts={starterPrompts}
emptyState={emptyState}
composerHint={hasOpenBackgroundWork ? "Ask me anything while I work on this." : undefined}
imageUploadHandler={handleImageUpload}
onAttachImage={handleAttachFile}
loading={commentsQuery.isLoading}
errorText={errorText}
noticeText={noticeText}
onRetry={errorText ? () => setErrorText(null) : undefined}
onSend={handleSend}
onStopRun={handleStopRun}
onVote={handleVote}
onAcceptInteraction={handleAcceptInteraction}
onRejectInteraction={handleRejectInteraction}
onSubmitInteractionAnswers={handleSubmitInteractionAnswers}
onCancelInteraction={handleCancelInteraction}
emptyMessage={emptyMessage}
className={className}
/>
);
}