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 ff6fdcf5..b3a8702a 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 ec542851..d41b609d 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 22ae4f3f..ba241254 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 02c877e9..196b3b96 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 5b742025..df226372 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 5a348a33..725346ff 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 770b15f3..dbef7ab4 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 */} -
+