fix(ui): localize issue detail live polling

This commit is contained in:
Dotta committed 2026-04-14 11:47:48 -05:00
1 parent 15a24aeb2c
commit 79e3b72ea1
1 file changed
+467 -308
+467 -308
View File
@@ -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<LiveRunForIssue[]>(
queryKeys.issues.liveRuns(issueId),
);
const activeRun = queryClient.getQueryData<ActiveRunForIssue | null>(
queryKeys.issues.activeRun(issueId),
);
return {
liveRuns,
activeRun,
runningIssueRun: resolveRunningIssueRun(activeRun, liveRuns),
};
}
function keepPreviousData<T>(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<IssueChatComposerHandle>;
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
feedbackTermsUrl: string | null;
agentMap: Map<string, Agent>;
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<void>;
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
onImageUpload: (file: File) => Promise<string>;
onAttachImage: (file: File) => Promise<void>;
onInterruptQueued: (runId: string) => Promise<void>;
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<string>();
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<IssueDetailComment[]>(() => {
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
const agentIdByRunId = new Map<string, string>();
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 <IssueChatSkeleton />;
}
return (
<div className="space-y-3">
{hasOlderComments ? (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
size="sm"
disabled={commentsLoadingOlder}
onClick={onLoadOlderComments}
>
{commentsLoadingOlder ? "Loading earlier comments..." : "Load earlier comments"}
</Button>
</div>
) : null}
<IssueChatThread
composerRef={composerRef}
comments={commentsWithRunMeta}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={feedbackTermsUrl}
linkedRuns={timelineRuns}
timelineEvents={timelineEvents}
liveRuns={liveRuns}
activeRun={activeRun}
companyId={issue.companyId}
projectId={issue.projectId}
issueStatus={issue.status}
agentMap={agentMap}
currentUserId={currentUserId}
draftKey={draftKey}
enableReassign
reassignOptions={reassignOptions}
currentAssigneeValue={currentAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentions}
composerDisabledReason={composerDisabledReason}
onVote={onVote}
onAdd={onAdd}
imageUploadHandler={onImageUpload}
onAttachImage={onAttachImage}
onInterruptQueued={onInterruptQueued}
onCancelQueued={onCancelQueued}
interruptingQueuedRunId={interruptingQueuedRunId}
stoppingRunId={interruptingQueuedRunId}
onStopRun={onInterruptQueued}
onCancelRun={runningIssueRun
? async () => {
await onInterruptQueued(runningIssueRun.id);
}
: undefined}
onImageClick={onImageClick}
/>
</div>
);
}
type IssueDetailActivityTabProps = {
issueId: string;
agentMap: Map<string, Agent>;
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 <IssueSectionSkeleton titleWidth="w-20" rows={4} />;
}
return (
<>
{linkedApprovals && linkedApprovals.length > 0 && (
<div className="mb-3 space-y-3">
{linkedApprovals.map((approval) => (
<ApprovalCard
key={approval.id}
approval={approval}
requesterAgent={approval.requestedByAgentId ? agentMap.get(approval.requestedByAgentId) ?? null : null}
onApprove={() => 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
}
/>
))}
</div>
)}
{linkedRuns && linkedRuns.length > 0 && (
<div className="mb-3 px-3 py-2 rounded-lg border border-border">
<div className="text-sm font-medium text-muted-foreground mb-1">Cost Summary</div>
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
<div className="text-xs text-muted-foreground">No cost data yet.</div>
) : (
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground tabular-nums">
{issueCostSummary.hasCost && (
<span className="font-medium text-foreground">
${issueCostSummary.cost.toFixed(4)}
</span>
)}
{issueCostSummary.hasTokens && (
<span>
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)})`}
</span>
)}
</div>
)}
</div>
)}
{!activity || activity.length === 0 ? (
<p className="text-xs text-muted-foreground">No activity yet.</p>
) : (
<div className="space-y-1.5">
{activity.slice(0, 20).map((evt) => (
<div key={evt.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ActorIdentity evt={evt} agentMap={agentMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, currentUserId })}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
</div>
))}
</div>
)}
</>
);
}
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<string>();
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<IssueDetailComment[]>(() => {
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
const agentIdByRunId = new Map<string, string>();
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<Issue>(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<Issue>(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<RunForIssue[]>(queryKeys.issues.runs(issueId!));
const previousLiveRuns = queryClient.getQueryData<typeof liveRuns>(queryKeys.issues.liveRuns(issueId!));
const previousActiveRun = queryClient.getQueryData<typeof activeRun>(queryKeys.issues.activeRun(issueId!));
const liveRunList = previousLiveRuns ?? liveRuns ?? [];
const cachedActiveRun = previousActiveRun ?? activeRun;
const previousLiveRuns = queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(issueId!));
const previousActiveRun = queryClient.getQueryData<ActiveRunForIssue | null>(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 <IssueDetailLoadingState headerSeed={issueHeaderSeed} />;
@@ -2323,155 +2563,74 @@ export function IssueDetail() {
</TabsList>
<TabsContent value="chat">
{issueChatInitialLoading ? (
<IssueChatSkeleton />
) : (
<div className="space-y-3">
{hasOlderComments ? (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
size="sm"
disabled={commentsLoadingOlder}
onClick={() => {
void fetchOlderComments();
}}
>
{commentsLoadingOlder ? "Loading earlier comments..." : "Load earlier comments"}
</Button>
</div>
) : null}
<IssueChatThread
composerRef={commentComposerRef}
comments={commentsWithRunMeta}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={FEEDBACK_TERMS_URL}
linkedRuns={timelineRuns}
timelineEvents={timelineEvents}
liveRuns={liveRuns}
activeRun={activeRun}
companyId={issue.companyId}
projectId={issue.projectId}
issueStatus={issue.status}
agentMap={agentMap}
currentUserId={currentUserId}
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
enableReassign
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 });
}}
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}
/>
</div>
)}
{detailTab === "chat" ? (
<IssueDetailChatTab
issueId={issue.id}
issue={issue}
comments={threadComments}
hasOlderComments={hasOlderComments}
commentsLoadingOlder={commentsLoadingOlder}
onLoadOlderComments={() => {
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}
</TabsContent>
<TabsContent value="activity">
{activityInitialLoading ? (
<IssueSectionSkeleton titleWidth="w-20" rows={4} />
) : (
<>
{linkedApprovals && linkedApprovals.length > 0 && (
<div className="mb-3 space-y-3">
{linkedApprovals.map((approval) => (
<ApprovalCard
key={approval.id}
approval={approval}
requesterAgent={approval.requestedByAgentId ? agentMap.get(approval.requestedByAgentId) ?? null : null}
onApprove={() => 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
}
/>
))}
</div>
)}
{linkedRuns && linkedRuns.length > 0 && (
<div className="mb-3 px-3 py-2 rounded-lg border border-border">
<div className="text-sm font-medium text-muted-foreground mb-1">Cost Summary</div>
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
<div className="text-xs text-muted-foreground">No cost data yet.</div>
) : (
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground tabular-nums">
{issueCostSummary.hasCost && (
<span className="font-medium text-foreground">
${issueCostSummary.cost.toFixed(4)}
</span>
)}
{issueCostSummary.hasTokens && (
<span>
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)})`}
</span>
)}
</div>
)}
</div>
)}
{!activity || activity.length === 0 ? (
<p className="text-xs text-muted-foreground">No activity yet.</p>
) : (
<div className="space-y-1.5">
{activity.slice(0, 20).map((evt) => (
<div key={evt.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ActorIdentity evt={evt} agentMap={agentMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, currentUserId })}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
</div>
))}
</div>
)}
</>
)}
{detailTab === "activity" ? (
<IssueDetailActivityTab
issueId={issue.id}
agentMap={agentMap}
currentUserId={currentUserId}
pendingApprovalAction={pendingApprovalAction}
onApprovalAction={(approvalId, action) => {
approvalDecision.mutate({ approvalId, action });
}}
/>
) : null}
</TabsContent>
{activePluginTab && (