Improve issue detail load stability

This commit is contained in:
dotta 2026-04-08 16:56:59 -05:00
parent d82468d6e5
commit efc1e336b0
6 changed files with 449 additions and 141 deletions

26
ui/src/api/issues.test.ts Normal file
View file

@ -0,0 +1,26 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockApi = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock("./client", () => ({
api: mockApi,
}));
import { issuesApi } from "./issues";
describe("issuesApi.list", () => {
beforeEach(() => {
mockApi.get.mockReset();
mockApi.get.mockResolvedValue([]);
});
it("passes parentId through to the company issues endpoint", async () => {
await issuesApi.list("company-1", { parentId: "issue-parent-1", limit: 25 });
expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/issues?parentId=issue-parent-1&limit=25",
);
});
});

View file

@ -24,6 +24,7 @@ export const issuesApi = {
filters?: { filters?: {
status?: string; status?: string;
projectId?: string; projectId?: string;
parentId?: string;
assigneeAgentId?: string; assigneeAgentId?: string;
participantAgentId?: string; participantAgentId?: string;
assigneeUserId?: string; assigneeUserId?: string;
@ -42,6 +43,7 @@ export const issuesApi = {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filters?.status) params.set("status", filters.status); if (filters?.status) params.set("status", filters.status);
if (filters?.projectId) params.set("projectId", filters.projectId); if (filters?.projectId) params.set("projectId", filters.projectId);
if (filters?.parentId) params.set("parentId", filters.parentId);
if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId); if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId);
if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId); if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId);
if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId); if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);

View file

@ -0,0 +1,169 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, Project } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
list: vi.fn(),
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("../api/execution-workspaces", () => ({
executionWorkspacesApi: mockExecutionWorkspacesApi,
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
}),
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: ComponentProps<"a"> & { to: string }) => <a href={to} {...props}>{children}</a>,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Issue workspace",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
identifier: "PAP-1",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: "shared_workspace",
executionWorkspaceSettings: { mode: "shared_workspace" },
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-04-08T00:00:00.000Z"),
updatedAt: new Date("2026-04-08T00:00:00.000Z"),
...overrides,
};
}
function createProject(): Project {
return {
id: "project-1",
companyId: "company-1",
urlKey: "project-1",
goalId: null,
goalIds: [],
goals: [],
name: "Project 1",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#22c55e",
env: null,
pauseReason: null,
pausedAt: null,
archivedAt: null,
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
allowIssueOverride: true,
},
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/project-1",
effectiveLocalFolder: "/tmp/project-1",
origin: "managed_checkout",
},
workspaces: [],
primaryWorkspace: null,
createdAt: new Date("2026-04-08T00:00:00.000Z"),
updatedAt: new Date("2026-04-08T00:00:00.000Z"),
};
}
function renderCard(container: HTMLDivElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueWorkspaceCard issue={createIssue()} project={createProject()} onUpdate={() => {}} />
</QueryClientProvider>,
);
});
return root;
}
async function flush() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("IssueWorkspaceCard", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockExecutionWorkspacesApi.list.mockReset();
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
});
afterEach(() => {
document.body.innerHTML = "";
});
it("renders a stable skeleton while workspace settings are still loading", async () => {
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
const root = renderCard(container);
await flush();
expect(container.querySelector('[data-testid="issue-workspace-card-skeleton"]')).not.toBeNull();
await act(async () => {
root.unmount();
});
});
});

View file

@ -8,6 +8,7 @@ import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { cn, projectWorkspaceUrl } from "../lib/utils"; import { cn, projectWorkspaceUrl } from "../lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react"; import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react";
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@ -156,6 +157,25 @@ function statusBadge(status: string) {
); );
} }
function IssueWorkspaceCardSkeleton() {
return (
<div className="rounded-lg border border-border p-3 space-y-3" data-testid="issue-workspace-card-skeleton">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 w-36" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-6 w-14" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-40" />
<Skeleton className="h-3 w-full" />
</div>
</div>
);
}
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Main component */ /* Main component */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@ -195,14 +215,15 @@ export function IssueWorkspaceCard({
const companyId = issue.companyId ?? selectedCompanyId; const companyId = issue.companyId ?? selectedCompanyId;
const [editing, setEditing] = useState(initialEditing); const [editing, setEditing] = useState(initialEditing);
const { data: experimentalSettings } = useQuery({ const { data: experimentalSettings, isLoading: experimentalSettingsLoading } = useQuery({
queryKey: queryKeys.instance.experimentalSettings, queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(), queryFn: () => instanceSettingsApi.getExperimental(),
retry: false, retry: false,
}); });
const projectWorkspacePolicyEnabled = Boolean(project?.executionWorkspacePolicy?.enabled);
const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true
&& Boolean(project?.executionWorkspacePolicy?.enabled); && projectWorkspacePolicyEnabled;
const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined; const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined;
@ -314,6 +335,10 @@ export function IssueWorkspaceCard({
setEditing(false); setEditing(false);
}, [currentSelection, issue.executionWorkspaceId]); }, [currentSelection, issue.executionWorkspaceId]);
if (project && projectWorkspacePolicyEnabled && experimentalSettingsLoading) {
return <IssueWorkspaceCardSkeleton />;
}
if (!policyEnabled || !project) return null; if (!policyEnabled || !project) return null;
const showEditingControls = livePreview || editing; const showEditingControls = livePreview || editing;

View file

@ -39,6 +39,8 @@ export const queryKeys = {
labels: (companyId: string) => ["issues", companyId, "labels"] as const, labels: (companyId: string) => ["issues", companyId, "labels"] as const,
listByProject: (companyId: string, projectId: string) => listByProject: (companyId: string, projectId: string) =>
["issues", companyId, "project", projectId] as const, ["issues", companyId, "project", projectId] as const,
listByParent: (companyId: string, parentId: string) =>
["issues", companyId, "parent", parentId] as const,
listByExecutionWorkspace: (companyId: string, executionWorkspaceId: string) => listByExecutionWorkspace: (companyId: string, executionWorkspaceId: string) =>
["issues", companyId, "execution-workspace", executionWorkspaceId] as const, ["issues", companyId, "execution-workspace", executionWorkspaceId] as const,
detail: (id: string) => ["issues", "detail", id] as const, detail: (id: string) => ["issues", "detail", id] as const,

View file

@ -51,6 +51,7 @@ import { IssueChatThread, type IssueChatComposerHandle } from "../components/Iss
import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
import { IssueProperties } from "../components/IssueProperties"; import { IssueProperties } from "../components/IssueProperties";
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard"; import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
import { PageSkeleton } from "../components/PageSkeleton";
import type { MentionOption } from "../components/MarkdownEditor"; import type { MentionOption } from "../components/MarkdownEditor";
import { ImageGalleryModal } from "../components/ImageGalleryModal"; import { ImageGalleryModal } from "../components/ImageGalleryModal";
import { ScrollToBottom } from "../components/ScrollToBottom"; import { ScrollToBottom } from "../components/ScrollToBottom";
@ -63,6 +64,7 @@ import { Separator } from "@/components/ui/separator";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
@ -299,9 +301,59 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<st
return <Identity name={id || "Unknown"} size="sm" />; return <Identity name={id || "Unknown"} size="sm" />;
} }
function IssueSectionSkeleton({
titleWidth = "w-28",
rows = 3,
}: {
titleWidth?: string;
rows?: number;
}) {
return (
<div className="space-y-3 rounded-lg border border-border p-3">
<Skeleton className={cn("h-4", titleWidth)} />
<div className="space-y-2">
{Array.from({ length: rows }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full rounded-md" />
))}
</div>
</div>
);
}
function IssueChatSkeleton() {
return (
<div className="space-y-3 rounded-lg border border-border p-3">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-16" />
</div>
</div>
<Skeleton className="h-20 w-full rounded-xl" />
</div>
<div className="space-y-2">
<div className="flex items-center justify-end gap-2">
<div className="space-y-2 text-right">
<Skeleton className="ml-auto h-3 w-20" />
<Skeleton className="ml-auto h-3 w-14" />
</div>
<Skeleton className="h-8 w-8 rounded-full" />
</div>
<Skeleton className="ml-auto h-16 w-[85%] rounded-xl" />
</div>
<div className="space-y-2 border-t border-border pt-3">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-24 w-full rounded-xl" />
</div>
</div>
);
}
export function IssueDetail() { export function IssueDetail() {
const { issueId } = useParams<{ issueId: string }>(); const { issueId } = useParams<{ issueId: string }>();
const { selectedCompanyId, selectedCompany } = useCompany(); const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialog(); const { openNewIssue } = useDialog();
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel(); const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
@ -341,13 +393,13 @@ export function IssueDetail() {
return getClosedIsolatedExecutionWorkspaceMessage(issue.currentExecutionWorkspace); return getClosedIsolatedExecutionWorkspaceMessage(issue.currentExecutionWorkspace);
}, [issue?.currentExecutionWorkspace]); }, [issue?.currentExecutionWorkspace]);
const { data: comments } = useQuery({ const { data: comments, isLoading: commentsLoading } = useQuery({
queryKey: queryKeys.issues.comments(issueId!), queryKey: queryKeys.issues.comments(issueId!),
queryFn: () => issuesApi.listComments(issueId!), queryFn: () => issuesApi.listComments(issueId!),
enabled: !!issueId, enabled: !!issueId,
}); });
const { data: activity } = useQuery({ const { data: activity, isLoading: activityLoading } = useQuery({
queryKey: queryKeys.issues.activity(issueId!), queryKey: queryKeys.issues.activity(issueId!),
queryFn: () => activityApi.forIssue(issueId!), queryFn: () => activityApi.forIssue(issueId!),
enabled: !!issueId, enabled: !!issueId,
@ -359,13 +411,13 @@ export function IssueDetail() {
enabled: !!issueId, enabled: !!issueId,
}); });
const { data: attachments } = useQuery({ const { data: attachments, isLoading: attachmentsLoading } = useQuery({
queryKey: queryKeys.issues.attachments(issueId!), queryKey: queryKeys.issues.attachments(issueId!),
queryFn: () => issuesApi.listAttachments(issueId!), queryFn: () => issuesApi.listAttachments(issueId!),
enabled: !!issueId, enabled: !!issueId,
}); });
const { data: liveRuns } = useQuery({ const { data: liveRuns, isLoading: liveRunsLoading } = useQuery({
queryKey: queryKeys.issues.liveRuns(issueId!), queryKey: queryKeys.issues.liveRuns(issueId!),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!),
enabled: !!issueId, enabled: !!issueId,
@ -377,7 +429,7 @@ export function IssueDetail() {
}, },
}); });
const { data: activeRun } = useQuery({ const { data: activeRun, isLoading: activeRunLoading } = useQuery({
queryKey: queryKeys.issues.activeRun(issueId!), queryKey: queryKeys.issues.activeRun(issueId!),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), queryFn: () => heartbeatsApi.activeRunForIssue(issueId!),
enabled: !!issueId, enabled: !!issueId,
@ -422,10 +474,13 @@ export function IssueDetail() {
return (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId)); return (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId));
}, [linkedRuns, liveRuns, activeRun]); }, [linkedRuns, liveRuns, activeRun]);
const { data: allIssues } = useQuery({ const { data: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!), queryKey:
queryFn: () => issuesApi.list(selectedCompanyId!), issue?.id && resolvedCompanyId
enabled: !!selectedCompanyId, ? queryKeys.issues.listByParent(resolvedCompanyId, issue.id)
: ["issues", "parent", "pending"],
queryFn: () => issuesApi.list(resolvedCompanyId!, { parentId: issue!.id }),
enabled: !!resolvedCompanyId && !!issue?.id,
}); });
const { data: agents } = useQuery({ const { data: agents } = useQuery({
@ -511,12 +566,14 @@ export function IssueDetail() {
return options; return options;
}, [agents, orderedProjects]); }, [agents, orderedProjects]);
const childIssues = useMemo(() => { const resolvedProject = useMemo(
if (!allIssues || !issue) return []; () => (issue?.projectId ? orderedProjects.find((project) => project.id === issue.projectId) ?? issue.project ?? null : null),
return allIssues [issue?.project, issue?.projectId, orderedProjects],
.filter((i) => i.parentId === issue.id) );
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); const childIssues = useMemo(
}, [allIssues, issue]); () => [...rawChildIssues].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
[rawChildIssues],
);
const childIssuesPanelKey = useMemo( const childIssuesPanelKey = useMemo(
() => childIssues.map((child) => `${child.id}:${String(child.updatedAt)}`).join("|"), () => childIssues.map((child) => `${child.id}:${String(child.updatedAt)}`).join("|"),
[childIssues], [childIssues],
@ -1446,7 +1503,18 @@ export function IssueDetail() {
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}; };
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>; const issueChatInitialLoading =
(commentsLoading && comments === undefined)
|| (activityLoading && activity === undefined)
|| (linkedRunsLoading && linkedRuns === undefined)
|| (liveRunsLoading && liveRuns === undefined)
|| (activeRunLoading && activeRun === undefined);
const activityInitialLoading =
(activityLoading && activity === undefined)
|| (linkedRunsLoading && linkedRuns === undefined);
const attachmentsInitialLoading = attachmentsLoading && attachments === undefined;
if (isLoading) return <PageSkeleton variant="detail" />;
if (error) return <p className="text-sm text-destructive">{error.message}</p>; if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!issue) return null; if (!issue) return null;
@ -1586,7 +1654,7 @@ export function IssueDetail() {
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1 -mx-1 py-0.5 min-w-0" className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1 -mx-1 py-0.5 min-w-0"
> >
<Hexagon className="h-3 w-3 shrink-0" /> <Hexagon className="h-3 w-3 shrink-0" />
<span className="truncate">{(projects ?? []).find((p) => p.id === issue.projectId)?.name ?? issue.projectId.slice(0, 8)}</span> <span className="truncate">{resolvedProject?.name ?? issue.project?.name ?? issue.projectId.slice(0, 8)}</span>
</Link> </Link>
) : ( ) : (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5"> <span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5">
@ -1748,7 +1816,7 @@ export function IssueDetail() {
missingBehavior="placeholder" missingBehavior="placeholder"
/> />
{childIssues.length > 0 && ( {(childIssuesLoading || childIssues.length > 0) && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Sub-issues</h3> <h3 className="text-sm font-medium text-muted-foreground">Sub-issues</h3>
@ -1758,37 +1826,41 @@ export function IssueDetail() {
<span className="sm:hidden">Sub-issue</span> <span className="sm:hidden">Sub-issue</span>
</Button> </Button>
</div> </div>
<div className="border border-border rounded-lg divide-y divide-border"> {childIssuesLoading ? (
{childIssues.map((child) => ( <IssueSectionSkeleton titleWidth="w-24" rows={2} />
<Link ) : (
key={child.id} <div className="border border-border rounded-lg divide-y divide-border">
to={createIssueDetailPath(child.identifier ?? child.id)} {childIssues.map((child) => (
state={resolvedIssueDetailState ?? location.state} <Link
onClickCapture={() => key={child.id}
rememberIssueDetailLocationState( to={createIssueDetailPath(child.identifier ?? child.id)}
child.identifier ?? child.id, state={resolvedIssueDetailState ?? location.state}
resolvedIssueDetailState ?? location.state, onClickCapture={() =>
location.search, rememberIssueDetailLocationState(
)} child.identifier ?? child.id,
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors" resolvedIssueDetailState ?? location.state,
> location.search,
<div className="flex items-center gap-2 min-w-0"> )}
<StatusIcon status={child.status} /> className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
<PriorityIcon priority={child.priority} /> >
<span className="font-mono text-muted-foreground shrink-0"> <div className="flex items-center gap-2 min-w-0">
{child.identifier ?? child.id.slice(0, 8)} <StatusIcon status={child.status} />
</span> <PriorityIcon priority={child.priority} />
<span className="truncate">{child.title}</span> <span className="font-mono text-muted-foreground shrink-0">
</div> {child.identifier ?? child.id.slice(0, 8)}
{child.assigneeAgentId && (() => { </span>
const name = agentMap.get(child.assigneeAgentId)?.name; <span className="truncate">{child.title}</span>
return name </div>
? <Identity name={name} size="sm" /> {child.assigneeAgentId && (() => {
: <span className="text-muted-foreground font-mono">{child.assigneeAgentId.slice(0, 8)}</span>; const name = agentMap.get(child.assigneeAgentId)?.name;
})()} return name
</Link> ? <Identity name={name} size="sm" />
))} : <span className="text-muted-foreground font-mono">{child.assigneeAgentId.slice(0, 8)}</span>;
</div> })()}
</Link>
))}
</div>
)}
</div> </div>
)} )}
@ -1827,7 +1899,9 @@ export function IssueDetail() {
} }
/> />
{hasAttachments ? ( {attachmentsInitialLoading ? (
<IssueSectionSkeleton titleWidth="w-24" rows={2} />
) : hasAttachments ? (
<div <div
className={cn( className={cn(
"space-y-3 rounded-lg transition-colors", "space-y-3 rounded-lg transition-colors",
@ -1966,7 +2040,7 @@ export function IssueDetail() {
<IssueWorkspaceCard <IssueWorkspaceCard
issue={issue} issue={issue}
project={orderedProjects.find((p) => p.id === issue.projectId) ?? null} project={resolvedProject}
onUpdate={(data) => updateIssue.mutate(data)} onUpdate={(data) => updateIssue.mutate(data)}
/> />
@ -1990,100 +2064,110 @@ export function IssueDetail() {
</TabsList> </TabsList>
<TabsContent value="chat"> <TabsContent value="chat">
<IssueChatThread {issueChatInitialLoading ? (
composerRef={commentComposerRef} <IssueChatSkeleton />
comments={commentsWithRunMeta} ) : (
feedbackVotes={feedbackVotes} <IssueChatThread
feedbackDataSharingPreference={feedbackDataSharingPreference} composerRef={commentComposerRef}
feedbackTermsUrl={FEEDBACK_TERMS_URL} comments={commentsWithRunMeta}
linkedRuns={timelineRuns} feedbackVotes={feedbackVotes}
timelineEvents={timelineEvents} feedbackDataSharingPreference={feedbackDataSharingPreference}
liveRuns={liveRuns} feedbackTermsUrl={FEEDBACK_TERMS_URL}
activeRun={activeRun} linkedRuns={timelineRuns}
companyId={issue.companyId} timelineEvents={timelineEvents}
projectId={issue.projectId} liveRuns={liveRuns}
issueStatus={issue.status} activeRun={activeRun}
agentMap={agentMap} companyId={issue.companyId}
currentUserId={currentUserId} projectId={issue.projectId}
draftKey={`paperclip:issue-comment-draft:${issue.id}`} issueStatus={issue.status}
enableReassign agentMap={agentMap}
reassignOptions={commentReassignOptions} currentUserId={currentUserId}
currentAssigneeValue={actualAssigneeValue} draftKey={`paperclip:issue-comment-draft:${issue.id}`}
suggestedAssigneeValue={suggestedAssigneeValue} enableReassign
mentions={mentionOptions} reassignOptions={commentReassignOptions}
onInterruptQueued={handleInterruptQueued} currentAssigneeValue={actualAssigneeValue}
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} suggestedAssigneeValue={suggestedAssigneeValue}
composerDisabledReason={commentComposerDisabledReason} mentions={mentionOptions}
onVote={handleCommentVote} onInterruptQueued={handleInterruptQueued}
onAdd={handleCommentAdd} interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
imageUploadHandler={handleCommentImageUpload} composerDisabledReason={commentComposerDisabledReason}
onAttachImage={handleCommentAttachImage} onVote={handleCommentVote}
onCancelRun={runningIssueRun onAdd={handleCommentAdd}
? async () => { imageUploadHandler={handleCommentImageUpload}
await interruptQueuedComment.mutateAsync(runningIssueRun.id); onAttachImage={handleCommentAttachImage}
} onCancelRun={runningIssueRun
: undefined} ? async () => {
onImageClick={handleChatImageClick} await interruptQueuedComment.mutateAsync(runningIssueRun.id);
/> }
: undefined}
onImageClick={handleChatImageClick}
/>
)}
</TabsContent> </TabsContent>
<TabsContent value="activity"> <TabsContent value="activity">
{linkedApprovals && linkedApprovals.length > 0 && ( {activityInitialLoading ? (
<div className="mb-3 space-y-3"> <IssueSectionSkeleton titleWidth="w-20" rows={4} />
{linkedApprovals.map((approval) => ( ) : (
<ApprovalCard <>
key={approval.id} {linkedApprovals && linkedApprovals.length > 0 && (
approval={approval} <div className="mb-3 space-y-3">
requesterAgent={approval.requestedByAgentId ? agentMap.get(approval.requestedByAgentId) ?? null : null} {linkedApprovals.map((approval) => (
onApprove={() => approvalDecision.mutate({ approvalId: approval.id, action: "approve" })} <ApprovalCard
onReject={() => approvalDecision.mutate({ approvalId: approval.id, action: "reject" })} key={approval.id}
detailLink={`/approvals/${approval.id}`} approval={approval}
isPending={pendingApprovalAction?.approvalId === approval.id} requesterAgent={approval.requestedByAgentId ? agentMap.get(approval.requestedByAgentId) ?? null : null}
pendingAction={ onApprove={() => approvalDecision.mutate({ approvalId: approval.id, action: "approve" })}
pendingApprovalAction?.approvalId === approval.id onReject={() => approvalDecision.mutate({ approvalId: approval.id, action: "reject" })}
? pendingApprovalAction.action detailLink={`/approvals/${approval.id}`}
: null isPending={pendingApprovalAction?.approvalId === approval.id}
} pendingAction={
/> pendingApprovalAction?.approvalId === approval.id
))} ? pendingApprovalAction.action
</div> : null
)} }
{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> </div>
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? ( )}
<div className="text-xs text-muted-foreground">No cost data yet.</div> {linkedRuns && linkedRuns.length > 0 && (
) : ( <div className="mb-3 px-3 py-2 rounded-lg border border-border">
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground tabular-nums"> <div className="text-sm font-medium text-muted-foreground mb-1">Cost Summary</div>
{issueCostSummary.hasCost && ( {!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
<span className="font-medium text-foreground"> <div className="text-xs text-muted-foreground">No cost data yet.</div>
${issueCostSummary.cost.toFixed(4)} ) : (
</span> <div className="flex flex-wrap gap-3 text-xs text-muted-foreground tabular-nums">
)} {issueCostSummary.hasCost && (
{issueCostSummary.hasTokens && ( <span className="font-medium text-foreground">
<span> ${issueCostSummary.cost.toFixed(4)}
Tokens {formatTokens(issueCostSummary.totalTokens)} </span>
{issueCostSummary.cached > 0 )}
? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})` {issueCostSummary.hasTokens && (
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`} <span>
</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> </div>
)} )}
</div> {!activity || activity.length === 0 ? (
)} <p className="text-xs text-muted-foreground">No activity yet.</p>
{!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 className="space-y-1.5"> <div key={evt.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
{activity.slice(0, 20).map((evt) => ( <ActorIdentity evt={evt} agentMap={agentMap} />
<div key={evt.id} className="flex items-center gap-1.5 text-xs text-muted-foreground"> <span>{formatAction(evt.action, evt.details)}</span>
<ActorIdentity evt={evt} agentMap={agentMap} /> <span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
<span>{formatAction(evt.action, evt.details)}</span> </div>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span> ))}
</div> </div>
))} )}
</div> </>
)} )}
</TabsContent> </TabsContent>