Refine issue workflow surfaces and live updates

This commit is contained in:
dotta 2026-04-09 10:26:17 -05:00
parent b4a58ba8a6
commit 03dff1a29a
48 changed files with 2800 additions and 1163 deletions

View file

@ -23,6 +23,7 @@ import {
createIssueDetailPath,
readIssueDetailLocationState,
readIssueDetailBreadcrumb,
readIssueDetailHeaderSeed,
rememberIssueDetailLocationState,
} from "../lib/issueDetailBreadcrumb";
import {
@ -50,10 +51,10 @@ import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"
import { ApprovalCard } from "../components/ApprovalCard";
import { InlineEditor } from "../components/InlineEditor";
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
import { useLiveRunTranscripts } from "../components/transcript/useLiveRunTranscripts";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
import { IssueProperties } from "../components/IssueProperties";
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
import { PageSkeleton } from "../components/PageSkeleton";
import type { MentionOption } from "../components/MarkdownEditor";
import { ImageGalleryModal } from "../components/ImageGalleryModal";
import { ScrollToBottom } from "../components/ScrollToBottom";
@ -70,6 +71,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatIssueActivityAction } from "@/lib/activity-format";
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
import {
Activity as ActivityIcon,
Check,
@ -91,7 +93,6 @@ import {
type ActivityEvent,
type Agent,
type FeedbackVote,
type FeedbackVoteValue,
type Issue,
type IssueAttachment,
type IssueComment,
@ -107,10 +108,6 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
};
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
const ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS = 3000;
const IDLE_ISSUE_RUN_POLL_INTERVAL_MS = 30000;
const ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 5000;
const IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 30000;
const ISSUE_COMMENT_PAGE_SIZE = 50;
function keepPreviousData<T>(previousData: T | undefined) {
@ -284,6 +281,87 @@ function IssueChatSkeleton() {
);
}
function IssueDetailLoadingState({
headerSeed,
}: {
headerSeed: ReturnType<typeof readIssueDetailHeaderSeed>;
}) {
const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null;
return (
<div className="max-w-2xl space-y-6">
<div className="space-y-3">
<Skeleton className="h-3 w-40" />
<div className="flex items-center gap-2 min-w-0 flex-wrap">
{headerSeed ? (
<>
<StatusIcon status={headerSeed.status} />
<PriorityIcon priority={headerSeed.priority} />
{identifier ? (
<span className="text-sm font-mono text-muted-foreground shrink-0">{identifier}</span>
) : null}
{headerSeed.originKind === "routine_execution" && headerSeed.originId ? (
<span className="inline-flex items-center gap-1 rounded-full border border-violet-500/30 bg-violet-500/10 px-2 py-0.5 text-[10px] font-medium text-violet-600 dark:text-violet-400 shrink-0">
<Repeat className="h-3 w-3" />
Routine
</span>
) : null}
{headerSeed.projectId ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground rounded px-1 -mx-1 py-0.5 min-w-0">
<Hexagon className="h-3 w-3 shrink-0" />
<span className="truncate">
{headerSeed.projectName ?? headerSeed.projectId.slice(0, 8)}
</span>
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5">
<Hexagon className="h-3 w-3 shrink-0" />
No project
</span>
)}
</>
) : (
<>
<Skeleton className="h-6 w-6" />
<Skeleton className="h-6 w-6" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-28" />
</>
)}
</div>
{headerSeed ? (
<>
<h2 className="text-xl font-bold leading-tight">{headerSeed.title}</h2>
<div className="space-y-2">
<Skeleton className="h-4 w-full max-w-xl" />
<Skeleton className="h-4 w-[72%]" />
</div>
</>
) : (
<>
<Skeleton className="h-8 w-[min(100%,22rem)]" />
<Skeleton className="h-16 w-full" />
</>
)}
</div>
<Skeleton className="h-28 w-full rounded-lg border border-border" />
<div className="space-y-3">
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
<IssueChatSkeleton />
</div>
<IssueSectionSkeleton titleWidth="w-24" rows={3} />
</div>
);
}
export function IssueDetail() {
const { issueId } = useParams<{ issueId: string }>();
const { selectedCompanyId } = useCompany();
@ -309,10 +387,15 @@ export function IssueDetail() {
const [galleryIndex, setGalleryIndex] = useState(0);
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0);
const [issueChatInitialTranscriptReady, setIssueChatInitialTranscriptReady] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
const commentComposerRef = useRef<IssueChatComposerHandle | null>(null);
useEffect(() => {
setIssueChatInitialTranscriptReady(false);
}, [issueId]);
const { data: issue, isLoading, error } = useQuery({
queryKey: queryKeys.issues.detail(issueId!),
queryFn: () => issuesApi.get(issueId!),
@ -358,6 +441,14 @@ export function IssueDetail() {
placeholderData: keepPreviousData,
});
const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({
queryKey: queryKeys.issues.runs(issueId!),
queryFn: () => activityApi.runsForIssue(issueId!),
enabled: !!issueId,
refetchInterval: 5000,
placeholderData: keepPreviousData,
});
const { data: linkedApprovals } = useQuery({
queryKey: queryKeys.issues.approvals(issueId!),
queryFn: () => issuesApi.listApprovals(issueId!),
@ -376,12 +467,7 @@ export function IssueDetail() {
queryKey: queryKeys.issues.liveRuns(issueId!),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!),
enabled: !!issueId,
refetchInterval: (query) => {
const data = query.state.data as Array<unknown> | undefined;
return data && data.length > 0
? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS
: IDLE_ISSUE_RUN_POLL_INTERVAL_MS;
},
refetchInterval: 3000,
placeholderData: keepPreviousData,
});
@ -389,25 +475,11 @@ export function IssueDetail() {
queryKey: queryKeys.issues.activeRun(issueId!),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId!),
enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"),
refetchInterval: (query) =>
(liveRuns?.length ?? 0) > 0
? false
: query.state.data
? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS
: IDLE_ISSUE_RUN_POLL_INTERVAL_MS,
refetchInterval: (liveRuns?.length ?? 0) > 0 ? false : 3000,
placeholderData: keepPreviousData,
});
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
? ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS
: IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS,
placeholderData: keepPreviousData,
});
const runningIssueRun = useMemo(
() => (
activeRun?.status === "running"
@ -420,6 +492,10 @@ export function IssueDetail() {
() => readIssueDetailLocationState(issueId, location.state, location.search),
[issueId, location.state, location.search],
);
const issueHeaderSeed = useMemo(
() => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState),
[location.state, resolvedIssueDetailState],
);
const sourceBreadcrumb = useMemo(
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
[issueId, location.state, location.search],
@ -430,8 +506,14 @@ export function IssueDetail() {
const liveIds = new Set<string>();
for (const r of liveRuns ?? []) liveIds.add(r.id);
if (activeRun) liveIds.add(activeRun.id);
if (liveIds.size === 0) return linkedRuns ?? [];
return (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId));
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({
@ -500,6 +582,23 @@ export function IssueDetail() {
for (const a of agents ?? []) map.set(a.id, a);
return map;
}, [agents]);
const transcriptRuns = useMemo(
() =>
resolveIssueChatTranscriptRuns({
linkedRuns: timelineRuns,
liveRuns: liveRuns ?? [],
activeRun,
}),
[activeRun, liveRuns, timelineRuns],
);
const {
transcriptByRun: issueChatTranscriptByRun,
hasOutputForRun: issueChatHasOutputForRun,
isInitialHydrating: issueChatTranscriptHydrating,
} = useLiveRunTranscripts({
runs: transcriptRuns,
companyId: issue?.companyId ?? selectedCompanyId,
});
const mentionOptions = useMemo<MentionOption[]>(() => {
const options: MentionOption[] = [];
@ -699,6 +798,10 @@ export function IssueDetail() {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
}, [issueId, queryClient]);
const invalidateIssueThreadLazily = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!), refetchType: "inactive" });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!), refetchType: "inactive" });
}, [issueId, queryClient]);
const invalidateIssueRunState = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
@ -885,6 +988,10 @@ export function IssueDetail() {
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
);
}
queryClient.setQueryData<Issue | undefined>(
queryKeys.issues.detail(issueId!),
(current) => current ? { ...current, updatedAt: comment.createdAt } : current,
);
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
queryKeys.issues.comments(issueId!),
(current) => current ? {
@ -912,7 +1019,7 @@ export function IssueDetail() {
});
},
onSettled: (_result, _error, variables) => {
invalidateIssueDetail();
invalidateIssueThreadLazily();
if (variables.interrupt) {
invalidateIssueRunState();
}
@ -1011,7 +1118,7 @@ export function IssueDetail() {
});
},
onSettled: (_result, _error, variables) => {
invalidateIssueDetail();
invalidateIssueThreadLazily();
if (variables.interrupt) {
invalidateIssueRunState();
}
@ -1213,53 +1320,6 @@ export function IssueDetail() {
},
});
const handleInterruptQueued = useCallback(
async (runId: string) => {
await interruptQueuedComment.mutateAsync(runId);
},
[interruptQueuedComment.mutateAsync],
);
const handleCommentImageUpload = useCallback(
async (file: File) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
},
[uploadAttachment.mutateAsync],
);
const handleCommentAttachImage = useCallback(
async (file: File) => {
await uploadAttachment.mutateAsync(file);
},
[uploadAttachment.mutateAsync],
);
const handleCommentAdd = useCallback(
async (body: string, reopen?: boolean, reassignment?: CommentReassignment) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
return;
}
await addComment.mutateAsync({ body, reopen });
},
[addComment.mutateAsync, addCommentAndReassign.mutateAsync],
);
const handleCommentVote = useCallback(
async (commentId: string, vote: FeedbackVoteValue, options?: { reason?: string; allowSharing?: boolean }) => {
await feedbackVoteMutation.mutateAsync({
targetType: "issue_comment",
targetId: commentId,
vote,
reason: options?.reason,
allowSharing: options?.allowSharing,
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
});
},
[feedbackVoteMutation.mutateAsync, feedbackDataSharingPreference],
);
useEffect(() => {
const titleLabel = issue?.title ?? issueId ?? "Issue";
setBreadcrumbs([
@ -1480,18 +1540,26 @@ export function IssueDetail() {
setTimeout(() => setCopied(false), 2000);
};
const issueChatInitialLoading =
const issueChatCoreInitialLoading =
(commentsLoading && commentPages === undefined)
|| (activityLoading && activity === undefined)
|| (linkedRunsLoading && linkedRuns === undefined)
|| (liveRunsLoading && liveRuns === undefined)
|| (activeRunLoading && activeRun === undefined);
useEffect(() => {
if (issueChatInitialTranscriptReady) return;
if (issueChatCoreInitialLoading || issueChatTranscriptHydrating) return;
setIssueChatInitialTranscriptReady(true);
}, [issueChatCoreInitialLoading, issueChatInitialTranscriptReady, issueChatTranscriptHydrating]);
const issueChatInitialLoading =
issueChatCoreInitialLoading
|| (!issueChatInitialTranscriptReady && issueChatTranscriptHydrating);
const activityInitialLoading =
(activityLoading && activity === undefined)
|| (linkedRunsLoading && linkedRuns === undefined);
const attachmentsInitialLoading = attachmentsLoading && attachments === undefined;
if (isLoading) return <PageSkeleton variant="detail" />;
if (isLoading) return <IssueDetailLoadingState headerSeed={issueHeaderSeed} />;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!issue) return null;
@ -2075,19 +2143,44 @@ export function IssueDetail() {
issueStatus={issue.status}
agentMap={agentMap}
currentUserId={currentUserId}
enableLiveTranscriptPolling={false}
transcriptsByRunId={issueChatTranscriptByRun}
hasOutputForRun={issueChatHasOutputForRun}
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
enableReassign
reassignOptions={commentReassignOptions}
currentAssigneeValue={actualAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions}
onInterruptQueued={handleInterruptQueued}
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
composerDisabledReason={commentComposerDisabledReason}
onVote={handleCommentVote}
onAdd={handleCommentAdd}
imageUploadHandler={handleCommentImageUpload}
onAttachImage={handleCommentAttachImage}
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);
}}
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
onCancelRun={runningIssueRun
? async () => {
await interruptQueuedComment.mutateAsync(runningIssueRun.id);