mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Improve issue detail load stability
This commit is contained in:
parent
d82468d6e5
commit
efc1e336b0
6 changed files with 449 additions and 141 deletions
26
ui/src/api/issues.test.ts
Normal file
26
ui/src/api/issues.test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
169
ui/src/components/IssueWorkspaceCard.test.tsx
Normal file
169
ui/src/components/IssueWorkspaceCard.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue