mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
Address operator QoL PR feedback
This commit is contained in:
1 parent
5901b13a1b
commit
6610a15435
5 files changed
+125
-79
No files matched your search
+19
-17
@@ -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<Map<string, SuccessfulRunHandoffState>> {
|
||||
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<SuccessfulRunHandoffActivityRow>);
|
||||
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<string, SuccessfulRunHandoffState>();
|
||||
for (const row of rows) {
|
||||
if (states.has(row.entityId)) continue;
|
||||
const state = successfulRunHandoffStateFromActivity(row);
|
||||
if (state) states.set(row.entityId, state);
|
||||
}
|
||||
|
||||
@@ -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<number>`count(distinct ${heartbeatRuns.id})::int`,
|
||||
runtimeMs: sql<number>`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,
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Issue["blockedBy"]>[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<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!window.confirm(`Remove ${confirmLabel} as a blocker?`)) return;
|
||||
setIsConfirmOpen(true);
|
||||
};
|
||||
const confirmRemove = () => {
|
||||
onRemove(issue.id);
|
||||
setIsConfirmOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
data-mention-kind="issue"
|
||||
className={cn(
|
||||
"paperclip-mention-chip paperclip-mention-chip--issue group",
|
||||
"inline-flex items-center gap-1 rounded-full border border-border py-0.5 pl-1 pr-2 text-xs",
|
||||
)}
|
||||
title={issue.title}
|
||||
aria-label={`Issue ${issueLabel}: ${issue.title}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground opacity-0 transition-colors transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-ring group-hover:opacity-100"
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
onClick={handleRemove}
|
||||
<>
|
||||
<span
|
||||
data-mention-kind="issue"
|
||||
className={cn(
|
||||
"paperclip-mention-chip paperclip-mention-chip--issue group",
|
||||
"inline-flex items-center gap-1 rounded-full border border-border py-0.5 pl-1 pr-2 text-xs",
|
||||
)}
|
||||
title={issue.title}
|
||||
aria-label={`Issue ${issueLabel}: ${issue.title}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
{issue.identifier ? (
|
||||
<Link
|
||||
to={`/issues/${issueLabel}`}
|
||||
className="inline-flex min-w-0 items-center gap-1 no-underline hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
||||
aria-label={`Issue ${issueLabel}: ${issue.title}`}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground opacity-0 transition-colors transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-ring group-hover:opacity-100"
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex min-w-0 items-center gap-1">{content}</span>
|
||||
)}
|
||||
</span>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
{issue.identifier ? (
|
||||
<Link
|
||||
to={`/issues/${issueLabel}`}
|
||||
className="inline-flex min-w-0 items-center gap-1 no-underline hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
||||
aria-label={`Issue ${issueLabel}: ${issue.title}`}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex min-w-0 items-center gap-1">{content}</span>
|
||||
)}
|
||||
</span>
|
||||
<Dialog open={isConfirmOpen} onOpenChange={setIsConfirmOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove blocker?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Remove {confirmLabel} as a blocker for this issue.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="button" variant="destructive" onClick={confirmRemove}>
|
||||
Remove blocker
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <Navigate to={executionWorkspaceTabPath(workspaceId, "issues")} replace />;
|
||||
}
|
||||
|
||||
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 <Navigate to={executionWorkspaceTabPath(workspaceId, "issues")} replace />;
|
||||
return <LegacyWorkspaceTabRedirect workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
const handleTabChange = (tab: ExecutionWorkspaceTab) => {
|
||||
try {
|
||||
localStorage.setItem(`paperclip:execution-workspace-tab:${workspace.id}`, tab);
|
||||
} catch {}
|
||||
navigate(executionWorkspaceTabPath(workspace.id, tab));
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user