From b95c05a242f0bf8f759f06339be81362db369c30 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Tue, 17 Feb 2026 20:46:12 -0600 Subject: [PATCH] Improve agent detail, issue creation, and approvals pages Expand AgentDetail with heartbeat history and manual trigger controls. Enhance NewIssueDialog with richer field options. Add agent connection string retrieval API. Improve issue routes with parent chain resolution. Clean up Approvals page layout. Update query keys and validators. Co-Authored-By: Claude Opus 4.6 --- packages/shared/src/validators/issue.ts | 4 +- server/src/routes/agents.ts | 18 +++ server/src/routes/issues.ts | 41 +++++- server/src/services/agents.ts | 20 +++ server/src/services/issues.ts | 10 +- ui/src/api/agents.ts | 9 ++ ui/src/components/NewIssueDialog.tsx | 116 ++++++++++++--- ui/src/lib/queryKeys.ts | 1 + ui/src/pages/AgentDetail.tsx | 185 +++++++++++++++++++++++- ui/src/pages/Approvals.tsx | 37 ++--- 10 files changed, 396 insertions(+), 45 deletions(-) diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index ff6fdcf..b3a8702 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -16,7 +16,9 @@ export const createIssueSchema = z.object({ export type CreateIssue = z.infer; -export const updateIssueSchema = createIssueSchema.partial(); +export const updateIssueSchema = createIssueSchema.partial().extend({ + comment: z.string().min(1).optional(), +}); export type UpdateIssue = z.infer; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index ec54285..d41b609 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -247,6 +247,13 @@ export function agentRoutes(db: Db) { res.json(agent); }); + router.get("/agents/:id/keys", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const keys = await svc.listKeys(id); + res.json(keys); + }); + router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; @@ -268,6 +275,17 @@ export function agentRoutes(db: Db) { res.status(201).json(key); }); + router.delete("/agents/:id/keys/:keyId", async (req, res) => { + assertBoard(req); + const keyId = req.params.keyId as string; + const revoked = await svc.revokeKey(keyId); + if (!revoked) { + res.status(404).json({ error: "Key not found" }); + return; + } + res.json({ ok: true }); + }); + router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => { const id = req.params.id as string; const agent = await svc.getById(id); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 22ae4f3..ba24125 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -87,7 +87,8 @@ export function issueRoutes(db: Db) { } assertCompanyAccess(req, existing.companyId); - const issue = await svc.update(id, req.body); + const { comment: commentBody, ...updateFields } = req.body; + const issue = await svc.update(id, updateFields); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; @@ -102,9 +103,43 @@ export function issueRoutes(db: Db) { action: "issue.updated", entityType: "issue", entityId: issue.id, - details: req.body, + details: updateFields, }); + let comment = null; + if (commentBody) { + comment = await svc.addComment(id, commentBody, { + agentId: actor.agentId ?? undefined, + userId: actor.actorType === "user" ? actor.actorId : undefined, + }); + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + action: "issue.comment_added", + entityType: "issue", + entityId: issue.id, + details: { commentId: comment.id }, + }); + + // @-mention wakeups + svc.findMentionedAgents(issue.companyId, commentBody).then((ids) => { + for (const mentionedId of ids) { + heartbeat.wakeup(mentionedId, { + source: "automation", + triggerDetail: "system", + reason: `Mentioned in comment on issue ${id}`, + payload: { issueId: id, commentId: comment!.id }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { issueId: id, commentId: comment!.id, source: "comment.mention" }, + }).catch((err) => logger.warn({ err, agentId: mentionedId }, "failed to wake mentioned agent")); + } + }).catch((err) => logger.warn({ err, issueId: id }, "failed to resolve @-mentions")); + } + const assigneeChanged = req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId; if (assigneeChanged && issue.assigneeAgentId) { @@ -121,7 +156,7 @@ export function issueRoutes(db: Db) { .catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue update")); } - res.json(issue); + res.json({ ...issue, comment }); }); router.delete("/issues/:id", async (req, res) => { diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 02c877e..196b3b9 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -153,6 +153,26 @@ export function agentService(db: Db) { }; }, + listKeys: (id: string) => + db + .select({ + id: agentApiKeys.id, + name: agentApiKeys.name, + createdAt: agentApiKeys.createdAt, + revokedAt: agentApiKeys.revokedAt, + }) + .from(agentApiKeys) + .where(eq(agentApiKeys.agentId, id)), + + revokeKey: async (keyId: string) => { + const rows = await db + .update(agentApiKeys) + .set({ revokedAt: new Date() }) + .where(eq(agentApiKeys.id, keyId)) + .returning(); + return rows[0] ?? null; + }, + orgForCompany: async (companyId: string) => { const rows = await db.select().from(agents).where(eq(agents.companyId, companyId)); const byManager = new Map(); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 5b74202..df22637 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclip/db"; import { agents, issues, issueComments } from "@paperclip/db"; import { conflict, notFound, unprocessable } from "../errors.js"; @@ -55,7 +55,8 @@ export function issueService(db: Db) { } if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); - return db.select().from(issues).where(and(...conditions)).orderBy(desc(issues.updatedAt)); + const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`; + return db.select().from(issues).where(and(...conditions)).orderBy(asc(priorityOrder), desc(issues.updatedAt)); }, getById: (id: string) => @@ -156,6 +157,11 @@ export function issueService(db: Db) { if (!current) throw notFound("Issue not found"); + // If this agent already owns it and it's in_progress, return it (no self-409) + if (current.assigneeAgentId === agentId && current.status === "in_progress") { + return db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0]!); + } + throw conflict("Issue checkout conflict", { issueId: current.id, status: current.status, diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 5a348a3..725346f 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -1,6 +1,13 @@ import type { Agent, AgentKeyCreated, AgentRuntimeState, HeartbeatRun } from "@paperclip/shared"; import { api } from "./client"; +export interface AgentKey { + id: string; + name: string; + createdAt: Date; + revokedAt: Date | null; +} + export interface AdapterModel { id: string; label: string; @@ -24,7 +31,9 @@ export const agentsApi = { pause: (id: string) => api.post(`/agents/${id}/pause`, {}), resume: (id: string) => api.post(`/agents/${id}/resume`, {}), terminate: (id: string) => api.post(`/agents/${id}/terminate`, {}), + listKeys: (id: string) => api.get(`/agents/${id}/keys`), createKey: (id: string, name: string) => api.post(`/agents/${id}/keys`, { name }), + revokeKey: (agentId: string, keyId: string) => api.delete<{ ok: true }>(`/agents/${agentId}/keys/${keyId}`), runtimeState: (id: string) => api.get(`/agents/${id}/runtime-state`), resetSession: (id: string) => api.post(`/agents/${id}/runtime-state/reset-session`, {}), adapterModels: (type: string) => api.get(`/adapters/${type}/models`), diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 770b15f..dbef7ab 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; @@ -33,6 +33,36 @@ import { import { cn } from "../lib/utils"; import type { Project, Agent } from "@paperclip/shared"; +const DRAFT_KEY = "paperclip:issue-draft"; +const DEBOUNCE_MS = 800; + +interface IssueDraft { + title: string; + description: string; + status: string; + priority: string; + assigneeId: string; + projectId: string; +} + +function loadDraft(): IssueDraft | null { + try { + const raw = localStorage.getItem(DRAFT_KEY); + if (!raw) return null; + return JSON.parse(raw) as IssueDraft; + } catch { + return null; + } +} + +function saveDraft(draft: IssueDraft) { + localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); +} + +function clearDraft() { + localStorage.removeItem(DRAFT_KEY); +} + const statuses = [ { value: "backlog", label: "Backlog", color: "text-muted-foreground" }, { value: "todo", label: "Todo", color: "text-blue-400" }, @@ -59,6 +89,7 @@ export function NewIssueDialog() { const [assigneeId, setAssigneeId] = useState(""); const [projectId, setProjectId] = useState(""); const [expanded, setExpanded] = useState(false); + const draftTimer = useRef | null>(null); // Popover states const [statusOpen, setStatusOpen] = useState(false); @@ -84,13 +115,42 @@ export function NewIssueDialog() { issuesApi.create(selectedCompanyId!, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) }); + clearDraft(); reset(); closeNewIssue(); }, }); + // Debounced draft saving + const scheduleSave = useCallback( + (draft: IssueDraft) => { + if (draftTimer.current) clearTimeout(draftTimer.current); + draftTimer.current = setTimeout(() => { + if (draft.title.trim()) saveDraft(draft); + }, DEBOUNCE_MS); + }, + [], + ); + + // Save draft on meaningful changes useEffect(() => { - if (newIssueOpen) { + if (!newIssueOpen) return; + scheduleSave({ title, description, status, priority, assigneeId, projectId }); + }, [title, description, status, priority, assigneeId, projectId, newIssueOpen, scheduleSave]); + + // Restore draft or apply defaults when dialog opens + useEffect(() => { + if (!newIssueOpen) return; + + const draft = loadDraft(); + if (draft && draft.title.trim()) { + setTitle(draft.title); + setDescription(draft.description); + setStatus(draft.status || "todo"); + setPriority(draft.priority); + setAssigneeId(newIssueDefaults.assigneeAgentId ?? draft.assigneeId); + setProjectId(newIssueDefaults.projectId ?? draft.projectId); + } else { setStatus(newIssueDefaults.status ?? "todo"); setPriority(newIssueDefaults.priority ?? ""); setProjectId(newIssueDefaults.projectId ?? ""); @@ -98,6 +158,13 @@ export function NewIssueDialog() { } }, [newIssueOpen, newIssueDefaults]); + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (draftTimer.current) clearTimeout(draftTimer.current); + }; + }, []); + function reset() { setTitle(""); setDescription(""); @@ -108,6 +175,12 @@ export function NewIssueDialog() { setExpanded(false); } + function discardDraft() { + clearDraft(); + reset(); + closeNewIssue(); + } + function handleSubmit() { if (!selectedCompanyId || !title.trim()) return; createIssue.mutate({ @@ -127,6 +200,7 @@ export function NewIssueDialog() { } } + const hasDraft = title.trim().length > 0 || description.trim().length > 0; const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!; const currentPriority = priorities.find((p) => p.value === priority); const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId); @@ -136,22 +210,21 @@ export function NewIssueDialog() { { - if (!open) { - reset(); - closeNewIssue(); - } + if (!open) closeNewIssue(); }} > {/* Header bar */} -
+
{selectedCompany && ( @@ -174,7 +247,7 @@ export function NewIssueDialog() { variant="ghost" size="icon-xs" className="text-muted-foreground" - onClick={() => { reset(); closeNewIssue(); }} + onClick={() => closeNewIssue()} > × @@ -182,9 +255,9 @@ export function NewIssueDialog() {
{/* Title */} -
+
setTitle(e.target.value)} @@ -193,11 +266,11 @@ export function NewIssueDialog() {
{/* Description */} -
+