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 */} -
+