mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-20 04:20:38 +09:00
Refine issue workflow surfaces and live updates
This commit is contained in:
parent
b4a58ba8a6
commit
03dff1a29a
48 changed files with 2800 additions and 1163 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue