diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 3be0b86b45a..fed7a954370 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,11 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type Ref } from "react"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router"; -import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { approvalsApi } from "../api/approvals"; import { activityApi, type RunForIssue } from "../api/activity"; -import { heartbeatsApi } from "../api/heartbeats"; +import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; @@ -27,7 +27,6 @@ import { readIssueDetailHeaderSeed, rememberIssueDetailLocationState, } from "../lib/issueDetailBreadcrumb"; -import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun"; import { fetchIssueDetail, getCachedIssueDetail } from "../lib/issueDetailCache"; import { hasBlockingShortcutDialog, @@ -120,6 +119,29 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & { const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos"; const ISSUE_COMMENT_PAGE_SIZE = 50; +function resolveRunningIssueRun( + activeRun: ActiveRunForIssue | null | undefined, + liveRuns: readonly LiveRunForIssue[] | undefined, +) { + return activeRun?.status === "running" + ? activeRun + : (liveRuns ?? []).find((run) => run.status === "running") ?? null; +} + +function readIssueRunStateFromCache(queryClient: QueryClient, issueId: string) { + const liveRuns = queryClient.getQueryData( + queryKeys.issues.liveRuns(issueId), + ); + const activeRun = queryClient.getQueryData( + queryKeys.issues.activeRun(issueId), + ); + return { + liveRuns, + activeRun, + runningIssueRun: resolveRunningIssueRun(activeRun, liveRuns), + }; +} + function keepPreviousData(previousData: T | undefined) { return previousData; } @@ -466,6 +488,364 @@ function InboxMobileToolbar({ ); } +type IssueDetailChatTabProps = { + issueId: string; + issue: Issue; + comments: IssueDetailComment[]; + hasOlderComments: boolean; + commentsLoadingOlder: boolean; + onLoadOlderComments: () => void; + composerRef: Ref; + feedbackVotes?: FeedbackVote[]; + feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt"; + feedbackTermsUrl: string | null; + agentMap: Map; + currentUserId: string | null; + draftKey: string; + reassignOptions: Array<{ id: string; label: string; searchText?: string }>; + currentAssigneeValue: string; + suggestedAssigneeValue: string; + mentions: MentionOption[]; + composerDisabledReason: string | null; + onVote: ( + commentId: string, + vote: "up" | "down", + options?: { allowSharing?: boolean; reason?: string }, + ) => Promise; + onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; + onImageUpload: (file: File) => Promise; + onAttachImage: (file: File) => Promise; + onInterruptQueued: (runId: string) => Promise; + onCancelQueued: (commentId: string) => void; + interruptingQueuedRunId: string | null; + onImageClick: (src: string) => void; +}; + +function IssueDetailChatTab({ + issueId, + issue, + comments, + hasOlderComments, + commentsLoadingOlder, + onLoadOlderComments, + composerRef, + feedbackVotes, + feedbackDataSharingPreference, + feedbackTermsUrl, + agentMap, + currentUserId, + draftKey, + reassignOptions, + currentAssigneeValue, + suggestedAssigneeValue, + mentions, + composerDisabledReason, + onVote, + onAdd, + onImageUpload, + onAttachImage, + onInterruptQueued, + onCancelQueued, + interruptingQueuedRunId, + onImageClick, +}: IssueDetailChatTabProps) { + const { data: activity, isLoading: activityLoading } = useQuery({ + queryKey: queryKeys.issues.activity(issueId), + queryFn: () => activityApi.forIssue(issueId), + placeholderData: keepPreviousData, + }); + const { data: liveRuns, isLoading: liveRunsLoading } = useQuery({ + queryKey: queryKeys.issues.liveRuns(issueId), + queryFn: () => heartbeatsApi.liveRunsForIssue(issueId), + refetchInterval: 3000, + placeholderData: keepPreviousData, + }); + const liveRunCount = liveRuns?.length ?? 0; + const { data: activeRun, isLoading: activeRunLoading } = useQuery({ + queryKey: queryKeys.issues.activeRun(issueId), + queryFn: () => heartbeatsApi.activeRunForIssue(issueId), + enabled: !!issue.executionRunId || issue.status === "in_progress", + refetchInterval: liveRunCount > 0 ? false : 3000, + placeholderData: keepPreviousData, + }); + const hasLiveRuns = liveRunCount > 0 || !!activeRun; + const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({ + queryKey: queryKeys.issues.runs(issueId), + queryFn: () => activityApi.runsForIssue(issueId), + refetchInterval: hasLiveRuns ? 5000 : false, + placeholderData: keepPreviousData, + }); + + const runningIssueRun = useMemo( + () => resolveRunningIssueRun(activeRun, liveRuns), + [activeRun, liveRuns], + ); + const timelineRuns = useMemo(() => { + const liveIds = new Set(); + for (const run of liveRuns ?? []) liveIds.add(run.id); + if (activeRun) liveIds.add(activeRun.id); + const historicalRuns = liveIds.size === 0 + ? (linkedRuns ?? []) + : (linkedRuns ?? []).filter((run) => !liveIds.has(run.runId)); + return historicalRuns.map((run) => ({ + ...run, + adapterType: run.adapterType, + hasStoredOutput: (run.logBytes ?? 0) > 0, + })); + }, [activeRun, linkedRuns, liveRuns]); + const commentsWithRunMeta = useMemo(() => { + const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null; + const runMetaByCommentId = new Map(); + const agentIdByRunId = new Map(); + + for (const run of linkedRuns ?? []) { + agentIdByRunId.set(run.runId, run.agentId); + } + for (const evt of activity ?? []) { + if (evt.action !== "issue.comment_added" || !evt.runId) continue; + const details = evt.details ?? {}; + const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null; + if (!commentId || runMetaByCommentId.has(commentId)) continue; + const interruptedRunId = + typeof details["interruptedRunId"] === "string" ? details["interruptedRunId"] : null; + runMetaByCommentId.set(commentId, { + runId: evt.runId, + runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null, + interruptedRunId, + }); + } + + return comments.map((comment) => { + const meta = runMetaByCommentId.get(comment.id); + const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment }; + if ( + isQueuedIssueComment({ + comment: nextComment, + activeRunStartedAt, + activeRunAgentId: runningIssueRun?.agentId ?? null, + runId: meta?.runId ?? nextComment.runId ?? null, + interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null, + }) + ) { + return { + ...nextComment, + queueState: "queued" as const, + queueTargetRunId: runningIssueRun?.id ?? nextComment.queueTargetRunId ?? null, + }; + } + return nextComment; + }); + }, [activity, comments, linkedRuns, runningIssueRun]); + const timelineEvents = useMemo( + () => extractIssueTimelineEvents(activity), + [activity], + ); + const initialLoading = + (activityLoading && activity === undefined) + || (linkedRunsLoading && linkedRuns === undefined) + || (liveRunsLoading && liveRuns === undefined) + || (activeRunLoading && activeRun === undefined); + + if (initialLoading) { + return ; + } + + return ( +
+ {hasOlderComments ? ( +
+ +
+ ) : null} + { + await onInterruptQueued(runningIssueRun.id); + } + : undefined} + onImageClick={onImageClick} + /> +
+ ); +} + +type IssueDetailActivityTabProps = { + issueId: string; + agentMap: Map; + currentUserId: string | null; + pendingApprovalAction: { approvalId: string; action: "approve" | "reject" } | null; + onApprovalAction: (approvalId: string, action: "approve" | "reject") => void; +}; + +function IssueDetailActivityTab({ + issueId, + agentMap, + currentUserId, + pendingApprovalAction, + onApprovalAction, +}: IssueDetailActivityTabProps) { + const { data: activity, isLoading: activityLoading } = useQuery({ + queryKey: queryKeys.issues.activity(issueId), + queryFn: () => activityApi.forIssue(issueId), + placeholderData: keepPreviousData, + }); + const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({ + queryKey: queryKeys.issues.runs(issueId), + queryFn: () => activityApi.runsForIssue(issueId), + placeholderData: keepPreviousData, + }); + const { data: linkedApprovals } = useQuery({ + queryKey: queryKeys.issues.approvals(issueId), + queryFn: () => issuesApi.listApprovals(issueId), + placeholderData: keepPreviousData, + }); + const initialLoading = + (activityLoading && activity === undefined) + || (linkedRunsLoading && linkedRuns === undefined); + const issueCostSummary = useMemo(() => { + let input = 0; + let output = 0; + let cached = 0; + let cost = 0; + let hasCost = false; + let hasTokens = false; + + for (const run of linkedRuns ?? []) { + const usage = asRecord(run.usageJson); + const result = asRecord(run.resultJson); + const runInput = usageNumber(usage, "inputTokens", "input_tokens"); + const runOutput = usageNumber(usage, "outputTokens", "output_tokens"); + const runCached = usageNumber( + usage, + "cachedInputTokens", + "cached_input_tokens", + "cache_read_input_tokens", + ); + const runCost = visibleRunCostUsd(usage, result); + if (runCost > 0) hasCost = true; + if (runInput + runOutput + runCached > 0) hasTokens = true; + input += runInput; + output += runOutput; + cached += runCached; + cost += runCost; + } + + return { + input, + output, + cached, + cost, + totalTokens: input + output, + hasCost, + hasTokens, + }; + }, [linkedRuns]); + + if (initialLoading) { + return ; + } + + return ( + <> + {linkedApprovals && linkedApprovals.length > 0 && ( +
+ {linkedApprovals.map((approval) => ( + onApprovalAction(approval.id, "approve")} + onReject={() => onApprovalAction(approval.id, "reject")} + detailLink={`/approvals/${approval.id}`} + isPending={pendingApprovalAction?.approvalId === approval.id} + pendingAction={ + pendingApprovalAction?.approvalId === approval.id + ? pendingApprovalAction.action + : null + } + /> + ))} +
+ )} + {linkedRuns && linkedRuns.length > 0 && ( +
+
Cost Summary
+ {!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? ( +
No cost data yet.
+ ) : ( +
+ {issueCostSummary.hasCost && ( + + ${issueCostSummary.cost.toFixed(4)} + + )} + {issueCostSummary.hasTokens && ( + + Tokens {formatTokens(issueCostSummary.totalTokens)} + {issueCostSummary.cached > 0 + ? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})` + : ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`} + + )} +
+ )} +
+ )} + {!activity || activity.length === 0 ? ( +

No activity yet.

+ ) : ( +
+ {activity.slice(0, 20).map((evt) => ( +
+ + {formatIssueActivityAction(evt.action, evt.details, { agentMap, currentUserId })} + {relativeTime(evt.createdAt)} +
+ ))} +
+ )} + + ); +} + export function IssueDetail() { const { issueId } = useParams<{ issueId: string }>(); const { selectedCompanyId } = useCompany(); @@ -555,20 +935,6 @@ export function IssueDetail() { [commentPages?.pages], ); - const { data: activity, isLoading: activityLoading } = useQuery({ - queryKey: queryKeys.issues.activity(issueId!), - queryFn: () => activityApi.forIssue(issueId!), - enabled: !!issueId, - placeholderData: keepPreviousData, - }); - - const { data: linkedApprovals } = useQuery({ - queryKey: queryKeys.issues.approvals(issueId!), - queryFn: () => issuesApi.listApprovals(issueId!), - enabled: !!issueId, - placeholderData: keepPreviousData, - }); - const { data: attachments, isLoading: attachmentsLoading } = useQuery({ queryKey: queryKeys.issues.attachments(issueId!), queryFn: () => issuesApi.listAttachments(issueId!), @@ -576,60 +942,29 @@ export function IssueDetail() { placeholderData: keepPreviousData, }); - const { data: liveRuns, isLoading: liveRunsLoading } = useQuery({ + const { data: liveRunCount = 0 } = useQuery({ queryKey: queryKeys.issues.liveRuns(issueId!), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!), enabled: !!issueId, refetchInterval: 3000, + select: (runs) => runs.length, placeholderData: keepPreviousData, }); - const shouldPollActiveRun = shouldTrackIssueActiveRun(issue); - const { data: rawActiveRun, isLoading: activeRunLoading } = useQuery({ + const { data: hasActiveRun = false } = useQuery({ queryKey: queryKeys.issues.activeRun(issueId!), queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), - enabled: !!issueId && shouldPollActiveRun, - refetchInterval: (liveRuns?.length ?? 0) > 0 ? false : 3000, + enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"), + refetchInterval: liveRunCount > 0 ? false : 3000, + select: (run) => !!run, placeholderData: keepPreviousData, }); - const activeRun = resolveIssueActiveRun(issue, rawActiveRun); - - const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun; - const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({ - queryKey: queryKeys.issues.runs(issueId!), - queryFn: () => activityApi.runsForIssue(issueId!), - enabled: !!issueId, - refetchInterval: hasLiveRuns ? 5000 : false, - placeholderData: keepPreviousData, - }); - const runningIssueRun = useMemo( - () => ( - activeRun?.status === "running" - ? activeRun - : (liveRuns ?? []).find((run) => run.status === "running") ?? null - ), - [activeRun, liveRuns], - ); + const hasLiveRuns = liveRunCount > 0 || hasActiveRun; const sourceBreadcrumb = useMemo( () => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" }, [issueId, location.state, location.search], ); - // Filter out runs already shown by the live widget to avoid duplication - const timelineRuns = useMemo(() => { - const liveIds = new Set(); - for (const r of liveRuns ?? []) liveIds.add(r.id); - if (activeRun) liveIds.add(activeRun.id); - const historicalRuns = liveIds.size === 0 - ? (linkedRuns ?? []) - : (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId)); - return historicalRuns.map((run) => ({ - ...run, - adapterType: run.adapterType, - hasStoredOutput: (run.logBytes ?? 0) > 0, - })); - }, [linkedRuns, liveRuns, activeRun]); - const { data: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({ queryKey: issue?.id && resolvedCompanyId @@ -778,92 +1113,6 @@ export function IssueDetail() { [comments, optimisticComments], ); - const commentsWithRunMeta = useMemo(() => { - const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null; - const runMetaByCommentId = new Map(); - const agentIdByRunId = new Map(); - for (const run of linkedRuns ?? []) { - agentIdByRunId.set(run.runId, run.agentId); - } - for (const evt of activity ?? []) { - if (evt.action !== "issue.comment_added" || !evt.runId) continue; - const details = evt.details ?? {}; - const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null; - if (!commentId || runMetaByCommentId.has(commentId)) continue; - const interruptedRunId = - typeof details["interruptedRunId"] === "string" ? details["interruptedRunId"] : null; - runMetaByCommentId.set(commentId, { - runId: evt.runId, - runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null, - interruptedRunId, - }); - } - return threadComments.map((comment) => { - const meta = runMetaByCommentId.get(comment.id); - const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment }; - if ( - isQueuedIssueComment({ - comment: nextComment, - activeRunStartedAt, - activeRunAgentId: runningIssueRun?.agentId ?? null, - runId: meta?.runId ?? nextComment.runId ?? null, - interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null, - }) - ) { - return { - ...nextComment, - queueState: "queued" as const, - queueTargetRunId: runningIssueRun?.id ?? nextComment.queueTargetRunId ?? null, - }; - } - return nextComment; - }); - }, [activity, threadComments, linkedRuns, runningIssueRun]); - - const timelineEvents = useMemo( - () => extractIssueTimelineEvents(activity), - [activity], - ); - - const issueCostSummary = useMemo(() => { - let input = 0; - let output = 0; - let cached = 0; - let cost = 0; - let hasCost = false; - let hasTokens = false; - - for (const run of linkedRuns ?? []) { - const usage = asRecord(run.usageJson); - const result = asRecord(run.resultJson); - const runInput = usageNumber(usage, "inputTokens", "input_tokens"); - const runOutput = usageNumber(usage, "outputTokens", "output_tokens"); - const runCached = usageNumber( - usage, - "cachedInputTokens", - "cached_input_tokens", - "cache_read_input_tokens", - ); - const runCost = visibleRunCostUsd(usage, result); - if (runCost > 0) hasCost = true; - if (runInput + runOutput + runCached > 0) hasTokens = true; - input += runInput; - output += runOutput; - cached += runCached; - cost += runCost; - } - - return { - input, - output, - cached, - cost, - totalTokens: input + output, - hasCost, - hasTokens, - }; - }, [linkedRuns]); - const invalidateIssueDetail = useCallback(() => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); @@ -1062,7 +1311,7 @@ export function IssueDetail() { await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); const previousIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId!)); - const queuedComment = !interrupt && runningIssueRun; + const queuedComment = !interrupt ? readIssueRunStateFromCache(queryClient, issueId!).runningIssueRun : null; const optimisticComment = issue ? createOptimisticIssueComment({ companyId: issue.companyId, @@ -1070,7 +1319,7 @@ export function IssueDetail() { body, authorUserId: currentUserId, clientStatus: queuedComment ? "queued" : "pending", - queueTargetRunId: queuedComment ? runningIssueRun.id : null, + queueTargetRunId: queuedComment?.id ?? null, }) : null; @@ -1172,7 +1421,7 @@ export function IssueDetail() { await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); const previousIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId!)); - const queuedComment = !interrupt && runningIssueRun; + const queuedComment = !interrupt ? readIssueRunStateFromCache(queryClient, issueId!).runningIssueRun : null; const optimisticComment = issue ? createOptimisticIssueComment({ companyId: issue.companyId, @@ -1180,7 +1429,7 @@ export function IssueDetail() { body, authorUserId: currentUserId, clientStatus: queuedComment ? "queued" : "pending", - queueTargetRunId: queuedComment ? runningIssueRun.id : null, + queueTargetRunId: queuedComment?.id ?? null, }) : null; @@ -1269,10 +1518,11 @@ export function IssueDetail() { await queryClient.cancelQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }); const previousRuns = queryClient.getQueryData(queryKeys.issues.runs(issueId!)); - const previousLiveRuns = queryClient.getQueryData(queryKeys.issues.liveRuns(issueId!)); - const previousActiveRun = queryClient.getQueryData(queryKeys.issues.activeRun(issueId!)); - const liveRunList = previousLiveRuns ?? liveRuns ?? []; - const cachedActiveRun = previousActiveRun ?? activeRun; + const previousLiveRuns = queryClient.getQueryData(queryKeys.issues.liveRuns(issueId!)); + const previousActiveRun = queryClient.getQueryData(queryKeys.issues.activeRun(issueId!)); + const liveRunList = previousLiveRuns ?? []; + const cachedActiveRun = previousActiveRun ?? null; + const runningIssueRun = resolveRunningIssueRun(cachedActiveRun, liveRunList); const targetRun = cachedActiveRun?.id === runId ? cachedActiveRun @@ -1288,11 +1538,11 @@ export function IssueDetail() { queryClient.setQueryData( queryKeys.issues.liveRuns(issueId!), - (current: typeof liveRuns) => removeLiveRunById(current, runId), + (current: LiveRunForIssue[] | undefined) => removeLiveRunById(current, runId), ); queryClient.setQueryData( queryKeys.issues.activeRun(issueId!), - (current: typeof activeRun) => (current?.id === runId ? null : current), + (current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current), ); return { @@ -1790,16 +2040,6 @@ export function IssueDetail() { return () => setMobileToolbar(null); }, [showInboxToolbar, backHref, issue?.id, issueHidden, archivePending, setMobileToolbar]); - const issueChatCoreInitialLoading = - (commentsLoading && commentPages === undefined) - || (activityLoading && activity === undefined) - || (linkedRunsLoading && linkedRuns === undefined) - || (liveRunsLoading && liveRuns === undefined) - || (activeRunLoading && activeRun === undefined); - const issueChatInitialLoading = issueChatCoreInitialLoading; - const activityInitialLoading = - (activityLoading && activity === undefined) - || (linkedRunsLoading && linkedRuns === undefined); const attachmentsInitialLoading = attachmentsLoading && attachments === undefined; if (isLoading) return ; @@ -2323,155 +2563,74 @@ export function IssueDetail() { - {issueChatInitialLoading ? ( - - ) : ( -
- {hasOlderComments ? ( -
- -
- ) : null} - { - await feedbackVoteMutation.mutateAsync({ - targetType: "issue_comment", - targetId: commentId, - vote, - reason: options?.reason, - allowSharing: options?.allowSharing, - sharingPreferenceAtSubmit: feedbackDataSharingPreference, - }); - }} - onAdd={async (body, reopen, reassignment) => { - if (reassignment) { - await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); - return; - } - await addComment.mutateAsync({ body, reopen }); - }} - imageUploadHandler={async (file) => { - const attachment = await uploadAttachment.mutateAsync(file); - return attachment.contentPath; - }} - onAttachImage={async (file) => { - await uploadAttachment.mutateAsync(file); - }} - onInterruptQueued={async (runId) => { - await interruptQueuedComment.mutateAsync(runId); - }} - onCancelQueued={handleCancelQueuedComment} - interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} - stoppingRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} - onStopRun={async (runId) => { - await interruptQueuedComment.mutateAsync(runId); - }} - onCancelRun={runningIssueRun - ? async () => { - await interruptQueuedComment.mutateAsync(runningIssueRun.id); - } - : undefined} - onImageClick={handleChatImageClick} - /> -
- )} + {detailTab === "chat" ? ( + { + void fetchOlderComments(); + }} + composerRef={commentComposerRef} + feedbackVotes={feedbackVotes} + feedbackDataSharingPreference={feedbackDataSharingPreference} + feedbackTermsUrl={FEEDBACK_TERMS_URL} + agentMap={agentMap} + currentUserId={currentUserId} + draftKey={`paperclip:issue-comment-draft:${issue.id}`} + reassignOptions={commentReassignOptions} + currentAssigneeValue={actualAssigneeValue} + suggestedAssigneeValue={suggestedAssigneeValue} + mentions={mentionOptions} + composerDisabledReason={commentComposerDisabledReason} + onVote={async (commentId, vote, options) => { + await feedbackVoteMutation.mutateAsync({ + targetType: "issue_comment", + targetId: commentId, + vote, + reason: options?.reason, + allowSharing: options?.allowSharing, + sharingPreferenceAtSubmit: feedbackDataSharingPreference, + }); + }} + onAdd={async (body, reopen, reassignment) => { + if (reassignment) { + await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); + return; + } + await addComment.mutateAsync({ body, reopen }); + }} + onImageUpload={async (file) => { + const attachment = await uploadAttachment.mutateAsync(file); + return attachment.contentPath; + }} + onAttachImage={async (file) => { + await uploadAttachment.mutateAsync(file); + }} + onInterruptQueued={async (runId) => { + await interruptQueuedComment.mutateAsync(runId); + }} + onCancelQueued={handleCancelQueuedComment} + interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} + onImageClick={handleChatImageClick} + /> + ) : null}
- {activityInitialLoading ? ( - - ) : ( - <> - {linkedApprovals && linkedApprovals.length > 0 && ( -
- {linkedApprovals.map((approval) => ( - approvalDecision.mutate({ approvalId: approval.id, action: "approve" })} - onReject={() => approvalDecision.mutate({ approvalId: approval.id, action: "reject" })} - detailLink={`/approvals/${approval.id}`} - isPending={pendingApprovalAction?.approvalId === approval.id} - pendingAction={ - pendingApprovalAction?.approvalId === approval.id - ? pendingApprovalAction.action - : null - } - /> - ))} -
- )} - {linkedRuns && linkedRuns.length > 0 && ( -
-
Cost Summary
- {!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? ( -
No cost data yet.
- ) : ( -
- {issueCostSummary.hasCost && ( - - ${issueCostSummary.cost.toFixed(4)} - - )} - {issueCostSummary.hasTokens && ( - - Tokens {formatTokens(issueCostSummary.totalTokens)} - {issueCostSummary.cached > 0 - ? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})` - : ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`} - - )} -
- )} -
- )} - {!activity || activity.length === 0 ? ( -

No activity yet.

- ) : ( -
- {activity.slice(0, 20).map((evt) => ( -
- - {formatIssueActivityAction(evt.action, evt.details, { agentMap, currentUserId })} - {relativeTime(evt.createdAt)} -
- ))} -
- )} - - )} + {detailTab === "activity" ? ( + { + approvalDecision.mutate({ approvalId, action }); + }} + /> + ) : null}
{activePluginTab && (