diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts new file mode 100644 index 00000000..d0b3fab0 --- /dev/null +++ b/ui/src/api/issues.test.ts @@ -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", + ); + }); +}); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 30bacbb9..a628bde4 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -24,6 +24,7 @@ export const issuesApi = { filters?: { status?: string; projectId?: string; + parentId?: string; assigneeAgentId?: string; participantAgentId?: string; assigneeUserId?: string; @@ -42,6 +43,7 @@ export const issuesApi = { const params = new URLSearchParams(); if (filters?.status) params.set("status", filters.status); 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?.participantAgentId) params.set("participantAgentId", filters.participantAgentId); if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId); diff --git a/ui/src/components/IssueWorkspaceCard.test.tsx b/ui/src/components/IssueWorkspaceCard.test.tsx new file mode 100644 index 00000000..eef9a88f --- /dev/null +++ b/ui/src/components/IssueWorkspaceCard.test.tsx @@ -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 }) => {children}, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createIssue(overrides: Partial = {}): 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( + + {}} /> + , + ); + }); + 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(); + }); + }); +}); diff --git a/ui/src/components/IssueWorkspaceCard.tsx b/ui/src/components/IssueWorkspaceCard.tsx index 6637c9c3..982f444b 100644 --- a/ui/src/components/IssueWorkspaceCard.tsx +++ b/ui/src/components/IssueWorkspaceCard.tsx @@ -8,6 +8,7 @@ import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { cn, projectWorkspaceUrl } from "../lib/utils"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react"; /* -------------------------------------------------------------------------- */ @@ -156,6 +157,25 @@ function statusBadge(status: string) { ); } +function IssueWorkspaceCardSkeleton() { + return ( +
+
+
+ + + +
+ +
+
+ + +
+
+ ); +} + /* -------------------------------------------------------------------------- */ /* Main component */ /* -------------------------------------------------------------------------- */ @@ -195,14 +215,15 @@ export function IssueWorkspaceCard({ const companyId = issue.companyId ?? selectedCompanyId; const [editing, setEditing] = useState(initialEditing); - const { data: experimentalSettings } = useQuery({ + const { data: experimentalSettings, isLoading: experimentalSettingsLoading } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), retry: false, }); + const projectWorkspacePolicyEnabled = Boolean(project?.executionWorkspacePolicy?.enabled); const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true - && Boolean(project?.executionWorkspacePolicy?.enabled); + && projectWorkspacePolicyEnabled; const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined; @@ -314,6 +335,10 @@ export function IssueWorkspaceCard({ setEditing(false); }, [currentSelection, issue.executionWorkspaceId]); + if (project && projectWorkspacePolicyEnabled && experimentalSettingsLoading) { + return ; + } + if (!policyEnabled || !project) return null; const showEditingControls = livePreview || editing; diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index c6c8aaed..78b052f8 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -39,6 +39,8 @@ export const queryKeys = { labels: (companyId: string) => ["issues", companyId, "labels"] as const, listByProject: (companyId: string, projectId: string) => ["issues", companyId, "project", projectId] as const, + listByParent: (companyId: string, parentId: string) => + ["issues", companyId, "parent", parentId] as const, listByExecutionWorkspace: (companyId: string, executionWorkspaceId: string) => ["issues", companyId, "execution-workspace", executionWorkspaceId] as const, detail: (id: string) => ["issues", "detail", id] as const, diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index dc31159d..98c8be4c 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -51,6 +51,7 @@ import { IssueChatThread, type IssueChatComposerHandle } from "../components/Iss 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"; @@ -63,6 +64,7 @@ import { Separator } from "@/components/ui/separator"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { @@ -299,9 +301,59 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map; } +function IssueSectionSkeleton({ + titleWidth = "w-28", + rows = 3, +}: { + titleWidth?: string; + rows?: number; +}) { + return ( +
+ +
+ {Array.from({ length: rows }).map((_, index) => ( + + ))} +
+
+ ); +} + +function IssueChatSkeleton() { + return ( +
+
+
+ +
+ + +
+
+ +
+
+
+
+ + +
+ +
+ +
+
+ + +
+
+ ); +} + export function IssueDetail() { const { issueId } = useParams<{ issueId: string }>(); - const { selectedCompanyId, selectedCompany } = useCompany(); + const { selectedCompanyId } = useCompany(); const { openNewIssue } = useDialog(); const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -341,13 +393,13 @@ export function IssueDetail() { return getClosedIsolatedExecutionWorkspaceMessage(issue.currentExecutionWorkspace); }, [issue?.currentExecutionWorkspace]); - const { data: comments } = useQuery({ + const { data: comments, isLoading: commentsLoading } = useQuery({ queryKey: queryKeys.issues.comments(issueId!), queryFn: () => issuesApi.listComments(issueId!), enabled: !!issueId, }); - const { data: activity } = useQuery({ + const { data: activity, isLoading: activityLoading } = useQuery({ queryKey: queryKeys.issues.activity(issueId!), queryFn: () => activityApi.forIssue(issueId!), enabled: !!issueId, @@ -359,13 +411,13 @@ export function IssueDetail() { enabled: !!issueId, }); - const { data: attachments } = useQuery({ + const { data: attachments, isLoading: attachmentsLoading } = useQuery({ queryKey: queryKeys.issues.attachments(issueId!), queryFn: () => issuesApi.listAttachments(issueId!), enabled: !!issueId, }); - const { data: liveRuns } = useQuery({ + const { data: liveRuns, isLoading: liveRunsLoading } = useQuery({ queryKey: queryKeys.issues.liveRuns(issueId!), queryFn: () => heartbeatsApi.liveRunsForIssue(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!), queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), enabled: !!issueId, @@ -422,10 +474,13 @@ export function IssueDetail() { return (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId)); }, [linkedRuns, liveRuns, activeRun]); - const { data: allIssues } = useQuery({ - queryKey: queryKeys.issues.list(selectedCompanyId!), - queryFn: () => issuesApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, + const { data: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({ + queryKey: + issue?.id && resolvedCompanyId + ? queryKeys.issues.listByParent(resolvedCompanyId, issue.id) + : ["issues", "parent", "pending"], + queryFn: () => issuesApi.list(resolvedCompanyId!, { parentId: issue!.id }), + enabled: !!resolvedCompanyId && !!issue?.id, }); const { data: agents } = useQuery({ @@ -511,12 +566,14 @@ export function IssueDetail() { return options; }, [agents, orderedProjects]); - const childIssues = useMemo(() => { - if (!allIssues || !issue) return []; - return allIssues - .filter((i) => i.parentId === issue.id) - .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); - }, [allIssues, issue]); + const resolvedProject = useMemo( + () => (issue?.projectId ? orderedProjects.find((project) => project.id === issue.projectId) ?? issue.project ?? null : null), + [issue?.project, issue?.projectId, orderedProjects], + ); + const childIssues = useMemo( + () => [...rawChildIssues].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()), + [rawChildIssues], + ); const childIssuesPanelKey = useMemo( () => childIssues.map((child) => `${child.id}:${String(child.updatedAt)}`).join("|"), [childIssues], @@ -1446,7 +1503,18 @@ export function IssueDetail() { setTimeout(() => setCopied(false), 2000); }; - if (isLoading) return

Loading...

; + 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 ; if (error) return

{error.message}

; 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" > - {(projects ?? []).find((p) => p.id === issue.projectId)?.name ?? issue.projectId.slice(0, 8)} + {resolvedProject?.name ?? issue.project?.name ?? issue.projectId.slice(0, 8)} ) : ( @@ -1748,7 +1816,7 @@ export function IssueDetail() { missingBehavior="placeholder" /> - {childIssues.length > 0 && ( + {(childIssuesLoading || childIssues.length > 0) && (

Sub-issues

@@ -1758,37 +1826,41 @@ export function IssueDetail() { Sub-issue
-
- {childIssues.map((child) => ( - - rememberIssueDetailLocationState( - child.identifier ?? child.id, - resolvedIssueDetailState ?? location.state, - location.search, - )} - className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors" - > -
- - - - {child.identifier ?? child.id.slice(0, 8)} - - {child.title} -
- {child.assigneeAgentId && (() => { - const name = agentMap.get(child.assigneeAgentId)?.name; - return name - ? - : {child.assigneeAgentId.slice(0, 8)}; - })()} - - ))} -
+ {childIssuesLoading ? ( + + ) : ( +
+ {childIssues.map((child) => ( + + rememberIssueDetailLocationState( + child.identifier ?? child.id, + resolvedIssueDetailState ?? location.state, + location.search, + )} + className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors" + > +
+ + + + {child.identifier ?? child.id.slice(0, 8)} + + {child.title} +
+ {child.assigneeAgentId && (() => { + const name = agentMap.get(child.assigneeAgentId)?.name; + return name + ? + : {child.assigneeAgentId.slice(0, 8)}; + })()} + + ))} +
+ )}
)} @@ -1827,7 +1899,9 @@ export function IssueDetail() { } /> - {hasAttachments ? ( + {attachmentsInitialLoading ? ( + + ) : hasAttachments ? (
p.id === issue.projectId) ?? null} + project={resolvedProject} onUpdate={(data) => updateIssue.mutate(data)} /> @@ -1990,100 +2064,110 @@ export function IssueDetail() { - { - await interruptQueuedComment.mutateAsync(runningIssueRun.id); - } - : undefined} - onImageClick={handleChatImageClick} - /> + {issueChatInitialLoading ? ( + + ) : ( + { + await interruptQueuedComment.mutateAsync(runningIssueRun.id); + } + : undefined} + onImageClick={handleChatImageClick} + /> + )} - {linkedApprovals && linkedApprovals.length > 0 && ( -
- {linkedApprovals.map((approval) => ( - approvalDecision.mutate({ approvalId: approval.id, action: "approve" })} - onReject={() => approvalDecision.mutate({ approvalId: approval.id, action: "reject" })} - detailLink={`/approvals/${approval.id}`} - isPending={pendingApprovalAction?.approvalId === approval.id} - pendingAction={ - pendingApprovalAction?.approvalId === approval.id - ? pendingApprovalAction.action - : null - } - /> - ))} -
- )} - {linkedRuns && linkedRuns.length > 0 && ( -
-
Cost Summary
- {!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? ( -
No cost data yet.
- ) : ( -
- {issueCostSummary.hasCost && ( - - ${issueCostSummary.cost.toFixed(4)} - - )} - {issueCostSummary.hasTokens && ( - - Tokens {formatTokens(issueCostSummary.totalTokens)} - {issueCostSummary.cached > 0 - ? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})` - : ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`} - + {activityInitialLoading ? ( + + ) : ( + <> + {linkedApprovals && linkedApprovals.length > 0 && ( +
+ {linkedApprovals.map((approval) => ( + approvalDecision.mutate({ approvalId: approval.id, action: "approve" })} + onReject={() => approvalDecision.mutate({ approvalId: approval.id, action: "reject" })} + detailLink={`/approvals/${approval.id}`} + isPending={pendingApprovalAction?.approvalId === approval.id} + pendingAction={ + pendingApprovalAction?.approvalId === approval.id + ? pendingApprovalAction.action + : null + } + /> + ))} +
+ )} + {linkedRuns && linkedRuns.length > 0 && ( +
+
Cost Summary
+ {!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? ( +
No cost data yet.
+ ) : ( +
+ {issueCostSummary.hasCost && ( + + ${issueCostSummary.cost.toFixed(4)} + + )} + {issueCostSummary.hasTokens && ( + + Tokens {formatTokens(issueCostSummary.totalTokens)} + {issueCostSummary.cached > 0 + ? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})` + : ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`} + + )} +
)}
)} -
- )} - {!activity || activity.length === 0 ? ( -

No activity yet.

- ) : ( -
- {activity.slice(0, 20).map((evt) => ( -
- - {formatAction(evt.action, evt.details)} - {relativeTime(evt.createdAt)} + {!activity || activity.length === 0 ? ( +

No activity yet.

+ ) : ( +
+ {activity.slice(0, 20).map((evt) => ( +
+ + {formatAction(evt.action, evt.details)} + {relativeTime(evt.createdAt)} +
+ ))}
- ))} -
+ )} + )}