diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index acff055987c..10eeb2eafa1 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { Router, type Request, type Response } from "express"; import multer from "multer"; import { z } from "zod"; -import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { and, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, issueExecutionDecisions } from "@paperclipai/db"; import { @@ -189,25 +189,27 @@ async function listSuccessfulRunHandoffStates( issueIds: string[], ): Promise> { if (issueIds.length === 0) return new Map(); - const result = await db.execute(sql` - SELECT DISTINCT ON (${activityLog.entityId}) - ${activityLog.entityId} AS "entityId", - ${activityLog.action} AS "action", - ${activityLog.agentId} AS "agentId", - ${activityLog.runId} AS "runId", - ${activityLog.details} AS "details", - ${activityLog.createdAt} AS "createdAt" - FROM ${activityLog} - WHERE ${activityLog.companyId} = ${companyId} - AND ${activityLog.entityType} = 'issue' - AND ${activityLog.entityId} IN (${sql.join(issueIds.map((id) => sql`${id}`), sql`, `)}) - AND ${activityLog.action} IN (${sql.join(SUCCESSFUL_RUN_HANDOFF_ACTIONS.map((action) => sql`${action}`), sql`, `)}) - ORDER BY ${activityLog.entityId}, ${activityLog.createdAt} DESC, ${activityLog.id} DESC - `); - const rows = Array.from(result as Iterable); + const rows = await db + .select({ + entityId: activityLog.entityId, + action: activityLog.action, + agentId: activityLog.agentId, + runId: activityLog.runId, + details: activityLog.details, + createdAt: activityLog.createdAt, + }) + .from(activityLog) + .where(and( + eq(activityLog.companyId, companyId), + eq(activityLog.entityType, "issue"), + inArray(activityLog.entityId, issueIds), + inArray(activityLog.action, [...SUCCESSFUL_RUN_HANDOFF_ACTIONS]), + )) + .orderBy(activityLog.entityId, desc(activityLog.createdAt), desc(activityLog.id)) as SuccessfulRunHandoffActivityRow[]; const states = new Map(); for (const row of rows) { + if (states.has(row.entityId)) continue; const state = successfulRunHandoffStateFromActivity(row); if (state) states.set(row.entityId, state); } diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts index d59d0635843..05008d8222d 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -193,7 +193,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { ) `; - const issueTreeIdsTextCte = sql` + const runSummarySql = sql` WITH RECURSIVE issue_tree(id) AS ( ${cteSeedText} UNION ALL @@ -203,7 +203,23 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { WHERE ${childIssues.companyId} = ${companyId} AND ${childIssues.hiddenAt} IS NULL ) - SELECT id FROM issue_tree + SELECT + count(distinct ${heartbeatRuns.id})::int AS "runCount", + coalesce(sum(extract(epoch from (coalesce(${heartbeatRuns.finishedAt}, now()) - ${heartbeatRuns.startedAt})) * 1000), 0)::double precision AS "runtimeMs" + FROM ${heartbeatRuns} + WHERE ${heartbeatRuns.companyId} = ${companyId} + AND ${heartbeatRuns.startedAt} IS NOT NULL + AND ( + ${heartbeatRuns.contextSnapshot} ->> 'issueId' IN (SELECT id FROM issue_tree) + OR EXISTS ( + SELECT 1 + FROM ${activityLog} + JOIN issue_tree ON ${activityLog.entityId} = issue_tree.id + WHERE ${activityLog.companyId} = ${companyId} + AND ${activityLog.entityType} = 'issue' + AND ${activityLog.runId} = ${heartbeatRuns.id} + ) + ) `; // Run cost-event aggregation and run-duration aggregation in parallel. @@ -233,33 +249,13 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { issueTreeCondition, ), ), - db - .select({ - runCount: sql`count(distinct ${heartbeatRuns.id})::int`, - runtimeMs: sql`coalesce(sum(extract(epoch from (coalesce(${heartbeatRuns.finishedAt}, now()) - ${heartbeatRuns.startedAt})) * 1000), 0)::double precision`, - }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, companyId), - isNotNull(heartbeatRuns.startedAt), - sql`( - ${heartbeatRuns.contextSnapshot} ->> 'issueId' IN (${issueTreeIdsTextCte}) - OR EXISTS ( - SELECT 1 - FROM ${activityLog} - WHERE ${activityLog.companyId} = ${companyId} - AND ${activityLog.entityType} = 'issue' - AND ${activityLog.runId} = ${heartbeatRuns.id} - AND ${activityLog.entityId} IN (${issueTreeIdsTextCte}) - ) - )`, - ), - ), + db.execute(runSummarySql), ]); const costRow = costRowResult[0]; - const runRow = runRowResult[0]; + const runRow = Array.isArray(runRowResult) + ? (runRowResult[0] as { runCount?: number | string | null; runtimeMs?: number | string | null } | undefined) + : undefined; return { issueId, diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 133e505d97a..fa54fc3924b 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -478,7 +478,6 @@ describe("IssueProperties", () => { it("removes a blocked-by issue from the chip remove action after confirmation", async () => { const onUpdate = vi.fn(); - const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); const root = renderProperties(container, { issue: createIssue({ blockedBy: [ @@ -514,11 +513,19 @@ describe("IssueProperties", () => { await act(async () => { removeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + await flush(); + + expect(document.body.textContent).toContain("Remove PAP-2: Existing blocker as a blocker for this issue."); + const confirmButton = Array.from(document.body.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Remove blocker")); + expect(confirmButton).not.toBeUndefined(); + + await act(async () => { + confirmButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); - expect(confirmSpy).toHaveBeenCalledWith("Remove PAP-2: Existing blocker as a blocker?"); expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-4"] }); - confirmSpy.mockRestore(); act(() => root.unmount()); }); diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 2d898aec7bf..9039ce7ca9c 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -31,6 +31,16 @@ import { Identity } from "./Identity"; import { IssueReferencePill } from "./IssueReferencePill"; import { formatDate, cn, projectUrl } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock } from "lucide-react"; @@ -148,6 +158,7 @@ function RemovableIssueReferencePill({ issue: NonNullable[number]; onRemove: (issueId: string) => void; }) { + const [isConfirmOpen, setIsConfirmOpen] = useState(false); const issueLabel = issue.identifier ?? issue.title; const confirmLabel = issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title; const content = ( @@ -160,41 +171,64 @@ function RemovableIssueReferencePill({ const handleRemove = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); - if (!window.confirm(`Remove ${confirmLabel} as a blocker?`)) return; + setIsConfirmOpen(true); + }; + const confirmRemove = () => { onRemove(issue.id); + setIsConfirmOpen(false); }; return ( - - - {issue.identifier ? ( - - {content} - - ) : ( - {content} - )} - + + + {issue.identifier ? ( + + {content} + + ) : ( + {content} + )} + + + + + Remove blocker? + + Remove {confirmLabel} as a blocker for this issue. + + + + + + + + + + + ); } diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index ad40cd332ac..f541616c07b 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -74,6 +74,16 @@ function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceT return `/execution-workspaces/${workspaceId}/${segment}`; } +function LegacyWorkspaceTabRedirect({ workspaceId }: { workspaceId: string }) { + useEffect(() => { + try { + localStorage.removeItem(`paperclip:execution-workspace-tab:${workspaceId}`); + } catch {} + }, [workspaceId]); + + return ; +} + function isSafeExternalUrl(value: string | null | undefined) { if (!value) return false; try { @@ -675,13 +685,10 @@ export function ExecutionWorkspaceDetail() { const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null; if (workspaceId && activeTab === null) { - return ; + return ; } const handleTabChange = (tab: ExecutionWorkspaceTab) => { - try { - localStorage.setItem(`paperclip:execution-workspace-tab:${workspace.id}`, tab); - } catch {} navigate(executionWorkspaceTabPath(workspace.id, tab)); };