diff --git a/.claude/skills/design-guide/references/component-index.md b/.claude/skills/design-guide/references/component-index.md index 42d888a8..88e75178 100644 --- a/.claude/skills/design-guide/references/component-index.md +++ b/.claude/skills/design-guide/references/component-index.md @@ -139,6 +139,20 @@ Always use in a responsive grid: `grid md:grid-cols-2 xl:grid-cols-4 gap-4`. setFilters([])} /> ``` +### Identity + +**File:** `Identity.tsx` +**Props:** `name: string`, `avatarUrl?: string`, `initials?: string`, `size?: "sm" | "default" | "lg"` +**Usage:** Avatar + name display for users and agents. Derives initials from name automatically. Three sizes matching Avatar sizes. + +```tsx + + + +``` + +Use in property rows, comment headers, assignee displays, and anywhere a user/agent reference is shown. + ### InlineEditor **File:** `InlineEditor.tsx` diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts index 6d6c0ad2..50530e7d 100644 --- a/ui/src/api/activity.ts +++ b/ui/src/api/activity.ts @@ -1,6 +1,26 @@ import type { ActivityEvent } from "@paperclip/shared"; import { api } from "./client"; +export interface RunForIssue { + runId: string; + status: string; + agentId: string; + startedAt: string | null; + finishedAt: string | null; + createdAt: string; + invocationSource: string; +} + +export interface IssueForRun { + issueId: string; + title: string; + status: string; + priority: string; +} + export const activityApi = { list: (companyId: string) => api.get(`/companies/${companyId}/activity`), + forIssue: (issueId: string) => api.get(`/issues/${issueId}/activity`), + runsForIssue: (issueId: string) => api.get(`/issues/${issueId}/runs`), + issuesForRun: (runId: string) => api.get(`/heartbeat-runs/${runId}/issues`), }; diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 28238fce..28195251 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -1,6 +1,25 @@ import type { HeartbeatRun, HeartbeatRunEvent } from "@paperclip/shared"; import { api } from "./client"; +export interface ActiveRunForIssue extends HeartbeatRun { + agentId: string; + agentName: string; + adapterType: string; +} + +export interface LiveRunForIssue { + id: string; + status: string; + invocationSource: string; + triggerDetail: string | null; + startedAt: string | null; + finishedAt: string | null; + createdAt: string; + agentId: string; + agentName: string; + adapterType: string; +} + export const heartbeatsApi = { list: (companyId: string, agentId?: string) => { const params = agentId ? `?agentId=${agentId}` : ""; @@ -15,4 +34,8 @@ export const heartbeatsApi = { `/heartbeat-runs/${runId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`, ), cancel: (runId: string) => api.post(`/heartbeat-runs/${runId}/cancel`, {}), + liveRunsForIssue: (issueId: string) => + api.get(`/issues/${issueId}/live-runs`), + activeRunForIssue: (issueId: string) => + api.get(`/issues/${issueId}/active-run`), }; diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 98c51303..29ee925e 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -15,5 +15,6 @@ export const issuesApi = { }), release: (id: string) => api.post(`/issues/${id}/release`, {}), listComments: (id: string) => api.get(`/issues/${id}/comments`), - addComment: (id: string, body: string) => api.post(`/issues/${id}/comments`, { body }), + addComment: (id: string, body: string, reopen?: boolean) => + api.post(`/issues/${id}/comments`, reopen === undefined ? { body } : { body, reopen }), }; diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index 331c4315..2d3a33e2 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -1,5 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "react-router-dom"; import type { Agent, AgentRuntimeState } from "@paperclip/shared"; +import { agentsApi } from "../api/agents"; +import { useCompany } from "../context/CompanyContext"; +import { queryKeys } from "../lib/queryKeys"; import { StatusBadge } from "./StatusBadge"; +import { Identity } from "./Identity"; import { formatCents, formatDate } from "../lib/utils"; import { Separator } from "@/components/ui/separator"; @@ -25,6 +31,16 @@ function PropertyRow({ label, children }: { label: string; children: React.React } export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) { + const { selectedCompanyId } = useCompany(); + + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && !!agent.reportsTo, + }); + + const reportsToAgent = agent.reportsTo ? agents?.find((a) => a.id === agent.reportsTo) : null; + return (
@@ -82,7 +98,13 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) { )} {agent.reportsTo && ( - {agent.reportsTo.slice(0, 8)} + {reportsToAgent ? ( + + + + ) : ( + {agent.reportsTo.slice(0, 8)} + )} )} diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 1f22417e..59709af3 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -28,6 +28,7 @@ import { SquarePen, Plus, } from "lucide-react"; +import { Identity } from "./Identity"; export function CommandPalette() { const [open, setOpen] = useState(false); @@ -151,14 +152,13 @@ export function CommandPalette() { go(`/issues/${issue.id}`)}> - {issue.id.slice(0, 8)} + {issue.identifier ?? issue.id.slice(0, 8)} {issue.title} - {issue.assigneeAgentId && ( - - {agentName(issue.assigneeAgentId)} - - )} + {issue.assigneeAgentId && (() => { + const name = agentName(issue.assigneeAgentId); + return name ? : null; + })()} ))} diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 2d04285f..f8302b30 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -1,27 +1,48 @@ -import { useState } from "react"; -import type { IssueComment } from "@paperclip/shared"; +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import type { IssueComment, Agent } from "@paperclip/shared"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; +import { Identity } from "./Identity"; import { formatDate } from "../lib/utils"; -interface CommentThreadProps { - comments: IssueComment[]; - onAdd: (body: string) => Promise; +interface CommentWithRunMeta extends IssueComment { + runId?: string | null; + runAgentId?: string | null; } -export function CommentThread({ comments, onAdd }: CommentThreadProps) { +interface CommentThreadProps { + comments: CommentWithRunMeta[]; + onAdd: (body: string, reopen?: boolean) => Promise; + issueStatus?: string; + agentMap?: Map; +} + +const CLOSED_STATUSES = new Set(["done", "cancelled"]); + +export function CommentThread({ comments, onAdd, issueStatus, agentMap }: CommentThreadProps) { const [body, setBody] = useState(""); + const [reopen, setReopen] = useState(true); const [submitting, setSubmitting] = useState(false); - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); + const isClosed = issueStatus ? CLOSED_STATUSES.has(issueStatus) : false; + + // Display oldest-first + const sorted = useMemo( + () => [...comments].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()), + [comments], + ); + + async function handleSubmit(e?: React.FormEvent) { + e?.preventDefault(); const trimmed = body.trim(); if (!trimmed) return; setSubmitting(true); try { - await onAdd(trimmed); + await onAdd(trimmed, isClosed && reopen ? true : undefined); setBody(""); + setReopen(false); } finally { setSubmitting(false); } @@ -36,17 +57,32 @@ export function CommentThread({ comments, onAdd }: CommentThreadProps) { )}
- {comments.map((comment) => ( + {sorted.map((comment) => (
- - {comment.authorAgentId ? "Agent" : "Human"} - + {formatDate(comment.createdAt)}

{comment.body}

+ {comment.runId && comment.runAgentId && ( +
+ + run {comment.runId.slice(0, 8)} + +
+ )}
))}
@@ -56,11 +92,30 @@ export function CommentThread({ comments, onAdd }: CommentThreadProps) { placeholder="Leave a comment..." value={body} onChange={(e) => setBody(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSubmit(); + } + }} rows={3} /> - +
+ {isClosed && ( + + )} + +
); diff --git a/ui/src/components/CopyText.tsx b/ui/src/components/CopyText.tsx index 811f89b9..27812501 100644 --- a/ui/src/components/CopyText.tsx +++ b/ui/src/components/CopyText.tsx @@ -12,7 +12,7 @@ interface CopyTextProps { export function CopyText({ text, children, className, copiedLabel = "Copied!" }: CopyTextProps) { const [visible, setVisible] = useState(false); - const timerRef = useRef>(); + const timerRef = useRef>(undefined); const triggerRef = useRef(null); const handleClick = useCallback(() => { diff --git a/ui/src/components/Identity.tsx b/ui/src/components/Identity.tsx new file mode 100644 index 00000000..65a6dd02 --- /dev/null +++ b/ui/src/components/Identity.tsx @@ -0,0 +1,38 @@ +import { cn } from "@/lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; + +type IdentitySize = "sm" | "default" | "lg"; + +export interface IdentityProps { + name: string; + avatarUrl?: string | null; + initials?: string; + size?: IdentitySize; + className?: string; +} + +function deriveInitials(name: string): string { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + return name.slice(0, 2).toUpperCase(); +} + +const textSize: Record = { + sm: "text-xs", + default: "text-sm", + lg: "text-sm", +}; + +export function Identity({ name, avatarUrl, initials, size = "default", className }: IdentityProps) { + const displayInitials = initials ?? deriveInitials(name); + + return ( + + + {avatarUrl && } + {displayInitials} + + {name} + + ); +} diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index da535c8a..9dc2f4df 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -7,6 +7,7 @@ import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; +import { Identity } from "./Identity"; import { formatDate } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; import { Separator } from "@/components/ui/separator"; @@ -87,9 +88,9 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { {assignee ? ( - {assignee.name} + ) : ( Unassigned diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx new file mode 100644 index 00000000..819c2ad1 --- /dev/null +++ b/ui/src/components/LiveRunWidget.tsx @@ -0,0 +1,381 @@ +import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; +import { Link } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import type { LiveEvent } from "@paperclip/shared"; +import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats"; +import { getUIAdapter } from "../adapters"; +import type { TranscriptEntry } from "../adapters"; +import { queryKeys } from "../lib/queryKeys"; +import { cn, relativeTime } from "../lib/utils"; +import { ExternalLink } from "lucide-react"; +import { Identity } from "./Identity"; + +interface LiveRunWidgetProps { + issueId: string; + companyId?: string | null; +} + +type FeedTone = "info" | "warn" | "error" | "assistant" | "tool"; + +interface FeedItem { + id: string; + ts: string; + runId: string; + agentId: string; + agentName: string; + text: string; + tone: FeedTone; +} + +const MAX_FEED_ITEMS = 80; + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function summarizeEntry(entry: TranscriptEntry): { text: string; tone: FeedTone } | null { + if (entry.kind === "assistant") { + const text = entry.text.trim(); + return text ? { text, tone: "assistant" } : null; + } + if (entry.kind === "thinking") { + const text = entry.text.trim(); + return text ? { text: `[thinking] ${text}`, tone: "info" } : null; + } + if (entry.kind === "tool_call") { + return { text: `tool ${entry.name}`, tone: "tool" }; + } + if (entry.kind === "tool_result") { + const base = entry.content.trim(); + return { + text: entry.isError ? `tool error: ${base}` : `tool result: ${base}`, + tone: entry.isError ? "error" : "tool", + }; + } + if (entry.kind === "stderr") { + const text = entry.text.trim(); + return text ? { text, tone: "error" } : null; + } + if (entry.kind === "system") { + const text = entry.text.trim(); + return text ? { text, tone: "warn" } : null; + } + if (entry.kind === "stdout") { + const text = entry.text.trim(); + return text ? { text, tone: "info" } : null; + } + return null; +} + +function createFeedItem( + run: LiveRunForIssue, + ts: string, + text: string, + tone: FeedTone, + nextId: number, +): FeedItem | null { + const trimmed = text.trim(); + if (!trimmed) return null; + return { + id: `${run.id}:${nextId}`, + ts, + runId: run.id, + agentId: run.agentId, + agentName: run.agentName, + text: trimmed.slice(0, 220), + tone, + }; +} + +function parseStdoutChunk( + run: LiveRunForIssue, + chunk: string, + ts: string, + pendingByRun: Map, + nextIdRef: MutableRefObject, +): FeedItem[] { + const pendingKey = `${run.id}:stdout`; + const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`; + const split = combined.split(/\r?\n/); + pendingByRun.set(pendingKey, split.pop() ?? ""); + const adapter = getUIAdapter(run.adapterType); + + const items: FeedItem[] = []; + for (const line of split.slice(-8)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const parsed = adapter.parseStdoutLine(trimmed, ts); + if (parsed.length === 0) { + const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++); + if (fallback) items.push(fallback); + continue; + } + for (const entry of parsed) { + const summary = summarizeEntry(entry); + if (!summary) continue; + const item = createFeedItem(run, ts, summary.text, summary.tone, nextIdRef.current++); + if (item) items.push(item); + } + } + + return items; +} + +function parseStderrChunk( + run: LiveRunForIssue, + chunk: string, + ts: string, + pendingByRun: Map, + nextIdRef: MutableRefObject, +): FeedItem[] { + const pendingKey = `${run.id}:stderr`; + const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`; + const split = combined.split(/\r?\n/); + pendingByRun.set(pendingKey, split.pop() ?? ""); + + const items: FeedItem[] = []; + for (const line of split.slice(-8)) { + const item = createFeedItem(run, ts, line, "error", nextIdRef.current++); + if (item) items.push(item); + } + return items; +} + +export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) { + const [feed, setFeed] = useState([]); + const seenKeysRef = useRef(new Set()); + const pendingByRunRef = useRef(new Map()); + const runMetaByIdRef = useRef(new Map()); + const nextIdRef = useRef(1); + const bodyRef = useRef(null); + + const { data: liveRuns } = useQuery({ + queryKey: queryKeys.issues.liveRuns(issueId), + queryFn: () => heartbeatsApi.liveRunsForIssue(issueId), + enabled: !!companyId, + refetchInterval: 3000, + }); + + const runs = liveRuns ?? []; + const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]); + const activeRunIds = useMemo(() => new Set(runs.map((run) => run.id)), [runs]); + + useEffect(() => { + const body = bodyRef.current; + if (!body) return; + body.scrollTo({ top: body.scrollHeight, behavior: "smooth" }); + }, [feed.length]); + + useEffect(() => { + for (const run of runs) { + runMetaByIdRef.current.set(run.id, { agentId: run.agentId, agentName: run.agentName }); + } + }, [runs]); + + useEffect(() => { + const stillActive = new Set(); + for (const runId of activeRunIds) { + stillActive.add(`${runId}:stdout`); + stillActive.add(`${runId}:stderr`); + } + for (const key of pendingByRunRef.current.keys()) { + if (!stillActive.has(key)) { + pendingByRunRef.current.delete(key); + } + } + }, [activeRunIds]); + + useEffect(() => { + if (!companyId || activeRunIds.size === 0) return; + + let closed = false; + let reconnectTimer: number | null = null; + let socket: WebSocket | null = null; + + const appendItems = (items: FeedItem[]) => { + if (items.length === 0) return; + setFeed((prev) => [...prev, ...items].slice(-MAX_FEED_ITEMS)); + }; + + const scheduleReconnect = () => { + if (closed) return; + reconnectTimer = window.setTimeout(connect, 1500); + }; + + const connect = () => { + if (closed) return; + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`; + socket = new WebSocket(url); + + socket.onmessage = (message) => { + const raw = typeof message.data === "string" ? message.data : ""; + if (!raw) return; + + let event: LiveEvent; + try { + event = JSON.parse(raw) as LiveEvent; + } catch { + return; + } + + if (event.companyId !== companyId) return; + const payload = event.payload ?? {}; + const runId = readString(payload["runId"]); + if (!runId || !activeRunIds.has(runId)) return; + + const run = runById.get(runId); + if (!run) return; + + if (event.type === "heartbeat.run.event") { + const seq = typeof payload["seq"] === "number" ? payload["seq"] : null; + const eventType = readString(payload["eventType"]) ?? "event"; + const messageText = readString(payload["message"]) ?? eventType; + const dedupeKey = `${runId}:event:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`; + if (seenKeysRef.current.has(dedupeKey)) return; + seenKeysRef.current.add(dedupeKey); + if (seenKeysRef.current.size > 2000) { + seenKeysRef.current.clear(); + } + const tone = eventType === "error" ? "error" : eventType === "lifecycle" ? "warn" : "info"; + const item = createFeedItem(run, event.createdAt, messageText, tone, nextIdRef.current++); + if (item) appendItems([item]); + return; + } + + if (event.type === "heartbeat.run.status") { + const status = readString(payload["status"]) ?? "updated"; + const dedupeKey = `${runId}:status:${status}:${readString(payload["finishedAt"]) ?? ""}`; + if (seenKeysRef.current.has(dedupeKey)) return; + seenKeysRef.current.add(dedupeKey); + if (seenKeysRef.current.size > 2000) { + seenKeysRef.current.clear(); + } + const tone = status === "failed" || status === "timed_out" ? "error" : "warn"; + const item = createFeedItem(run, event.createdAt, `run ${status}`, tone, nextIdRef.current++); + if (item) appendItems([item]); + return; + } + + if (event.type === "heartbeat.run.log") { + const chunk = readString(payload["chunk"]); + if (!chunk) return; + const stream = readString(payload["stream"]) === "stderr" ? "stderr" : "stdout"; + if (stream === "stderr") { + appendItems(parseStderrChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef)); + return; + } + appendItems(parseStdoutChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef)); + } + }; + + socket.onerror = () => { + socket?.close(); + }; + + socket.onclose = () => { + scheduleReconnect(); + }; + }; + + connect(); + + return () => { + closed = true; + if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); + if (socket) { + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + socket.close(1000, "issue_live_widget_unmount"); + } + }; + }, [activeRunIds, companyId, runById]); + + if (runs.length === 0 && feed.length === 0) return null; + + const recent = feed.slice(-25); + const headerRun = + runs[0] ?? + (() => { + const last = recent[recent.length - 1]; + if (!last) return null; + const meta = runMetaByIdRef.current.get(last.runId); + if (!meta) return null; + return { + id: last.runId, + agentId: meta.agentId, + }; + })(); + + return ( +
+
+
+ {runs.length > 0 && ( + + + + + )} + + {runs.length > 0 ? `Live issue runs (${runs.length})` : "Recent run updates"} + +
+ {headerRun && ( + + Open run + + + )} +
+ +
+ {recent.length === 0 && ( +
Waiting for run output...
+ )} + {recent.map((item, index) => ( +
+ {relativeTime(item.ts)} +
+ + [{item.runId.slice(0, 8)}] + {item.text} +
+
+ ))} +
+ + {runs.length > 0 && ( +
+ {runs.map((run) => ( + + {run.id.slice(0, 8)} + + + ))} +
+ )} +
+ ); +} diff --git a/ui/src/components/StatusIcon.tsx b/ui/src/components/StatusIcon.tsx index ddcce215..b79ca940 100644 --- a/ui/src/components/StatusIcon.tsx +++ b/ui/src/components/StatusIcon.tsx @@ -33,14 +33,14 @@ export function StatusIcon({ status, onChange, className }: StatusIconProps) { const circle = ( {isDone && ( - + )} ); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 233254c1..0aeb2622 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -41,6 +41,10 @@ function invalidateActivityQueries( if (entityId) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(entityId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(entityId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(entityId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(entityId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(entityId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(entityId) }); } return; } diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 970286e3..6412179a 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -14,6 +14,10 @@ export const queryKeys = { list: (companyId: string) => ["issues", companyId] as const, detail: (id: string) => ["issues", "detail", id] as const, comments: (issueId: string) => ["issues", "comments", issueId] as const, + activity: (issueId: string) => ["issues", "activity", issueId] as const, + runs: (issueId: string) => ["issues", "runs", issueId] as const, + liveRuns: (issueId: string) => ["issues", "live-runs", issueId] as const, + activeRun: (issueId: string) => ["issues", "active-run", issueId] as const, }, projects: { list: (companyId: string) => ["projects", companyId] as const, @@ -32,5 +36,6 @@ export const queryKeys = { costs: (companyId: string) => ["costs", companyId] as const, heartbeats: (companyId: string, agentId?: string) => ["heartbeats", companyId, agentId] as const, + runIssues: (runId: string) => ["run-issues", runId] as const, org: (companyId: string) => ["org", companyId] as const, }; diff --git a/ui/src/pages/Activity.tsx b/ui/src/pages/Activity.tsx index ae072ffe..4a7a5390 100644 --- a/ui/src/pages/Activity.tsx +++ b/ui/src/pages/Activity.tsx @@ -1,13 +1,17 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { activityApi } from "../api/activity"; +import { agentsApi } from "../api/agents"; +import { issuesApi } from "../api/issues"; +import { projectsApi } from "../api/projects"; +import { goalsApi } from "../api/goals"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { EmptyState } from "../components/EmptyState"; +import { Identity } from "../components/Identity"; import { timeAgo } from "../lib/timeAgo"; -import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, @@ -15,43 +19,46 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { History, Bot, User, Settings } from "lucide-react"; +import { History } from "lucide-react"; +import type { Agent } from "@paperclip/shared"; -function formatAction(action: string, entityType: string, entityId: string): string { - const actionMap: Record = { - "company.created": "Company created", - "agent.created": `Agent created`, - "agent.updated": `Agent updated`, - "agent.paused": `Agent paused`, - "agent.resumed": `Agent resumed`, - "agent.terminated": `Agent terminated`, - "agent.key_created": `API key created for agent`, - "issue.created": `Issue created`, - "issue.updated": `Issue updated`, - "issue.checked_out": `Issue checked out`, - "issue.released": `Issue released`, - "issue.commented": `Comment added to issue`, - "heartbeat.invoked": `Heartbeat invoked`, - "heartbeat.completed": `Heartbeat completed`, - "heartbeat.failed": `Heartbeat failed`, - "approval.created": `Approval requested`, - "approval.approved": `Approval granted`, - "approval.rejected": `Approval rejected`, - "project.created": `Project created`, - "project.updated": `Project updated`, - "goal.created": `Goal created`, - "goal.updated": `Goal updated`, - "cost.recorded": `Cost recorded`, - }; - return actionMap[action] ?? `${action.replace(/[._]/g, " ")}`; -} - -function actorIcon(entityType: string) { - if (entityType === "agent") return ; - if (entityType === "company" || entityType === "approval") - return ; - return ; -} +// Maps action → verb phrase. When the entity name is available it reads as: +// "[Actor] commented on "Fix the bug"" +// When not available, it falls back to just the verb. +const ACTION_VERBS: Record = { + "issue.created": "created", + "issue.updated": "updated", + "issue.checked_out": "checked out", + "issue.released": "released", + "issue.comment_added": "commented on", + "issue.commented": "commented on", + "issue.deleted": "deleted", + "agent.created": "created", + "agent.updated": "updated", + "agent.paused": "paused", + "agent.resumed": "resumed", + "agent.terminated": "terminated", + "agent.key_created": "created API key for", + "agent.budget_updated": "updated budget for", + "agent.runtime_session_reset": "reset session for", + "heartbeat.invoked": "invoked heartbeat for", + "heartbeat.cancelled": "cancelled heartbeat for", + "approval.created": "requested approval", + "approval.approved": "approved", + "approval.rejected": "rejected", + "project.created": "created", + "project.updated": "updated", + "project.deleted": "deleted", + "goal.created": "created", + "goal.updated": "updated", + "goal.deleted": "deleted", + "cost.reported": "reported cost for", + "cost.recorded": "recorded cost for", + "company.created": "created", + "company.updated": "updated", + "company.archived": "archived", + "company.budget_updated": "updated budget for", +}; function entityLink(entityType: string, entityId: string): string | null { switch (entityType) { @@ -70,6 +77,15 @@ function entityLink(entityType: string, entityId: string): string | null { } } +function actorIdentity(actorType: string, actorId: string, agentMap: Map) { + if (actorType === "agent") { + const agent = agentMap.get(actorId); + return ; + } + if (actorType === "system") return ; + return ; +} + export function Activity() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -86,6 +102,46 @@ export function Activity() { enabled: !!selectedCompanyId, }); + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const { data: issues } = useQuery({ + queryKey: queryKeys.issues.list(selectedCompanyId!), + queryFn: () => issuesApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const { data: projects } = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId!), + queryFn: () => projectsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const { data: goals } = useQuery({ + queryKey: queryKeys.goals.list(selectedCompanyId!), + queryFn: () => goalsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const agentMap = useMemo(() => { + const map = new Map(); + for (const a of agents ?? []) map.set(a.id, a); + return map; + }, [agents]); + + // Unified map: "entityType:entityId" → display name + const entityNameMap = useMemo(() => { + const map = new Map(); + for (const i of issues ?? []) map.set(`issue:${i.id}`, i.title); + for (const a of agents ?? []) map.set(`agent:${a.id}`, a.name); + for (const p of projects ?? []) map.set(`project:${p.id}`, p.name); + for (const g of goals ?? []) map.set(`goal:${g.id}`, g.title); + return map; + }, [issues, agents, projects, goals]); + if (!selectedCompanyId) { return ; } @@ -128,25 +184,22 @@ export function Activity() {
{filtered.map((event) => { const link = entityLink(event.entityType, event.entityId); + const verb = ACTION_VERBS[event.action] ?? event.action.replace(/[._]/g, " "); + const name = entityNameMap.get(`${event.entityType}:${event.entityId}`); return (
navigate(link) : undefined} > -
- {actorIcon(event.entityType)} - - {formatAction(event.action, event.entityType, event.entityId)} - - - {event.entityType} - - - {event.entityId.slice(0, 8)} - +
+ {actorIdentity(event.actorType, event.actorId, agentMap)} + {verb} + {name && ( + {name} + )}
{timeAgo(event.createdAt)} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 4cb4a674..d37916a4 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -3,6 +3,7 @@ import { useParams, useNavigate, Link, useBeforeUnload, useSearchParams } from " import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { agentsApi, type AgentKey } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; +import { activityApi } from "../api/activity"; import { issuesApi } from "../api/issues"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; @@ -248,6 +249,7 @@ export function AgentDetail() { ); const setActiveTab = useCallback((nextTab: string) => { + if (configDirty && !window.confirm("You have unsaved changes. Discard them?")) return; const next = parseAgentDetailTab(nextTab); // If we're on a /runs/:runId URL and switching tabs, navigate back to base agent URL if (urlRunId) { @@ -259,7 +261,7 @@ export function AgentDetail() { if (next === "overview") params.delete("tab"); else params.set("tab", next); setSearchParams(params); - }, [searchParams, setSearchParams, urlRunId, agentId, navigate]); + }, [searchParams, setSearchParams, urlRunId, agentId, navigate, configDirty]); if (isLoading) return

Loading...

; if (error) return

{error.message}

; @@ -362,43 +364,45 @@ export function AgentDetail() { {actionError &&

{actionError}

} - -
- -
+
+ - -
+ Cancel + +
+
+ + + {/* OVERVIEW TAB */} @@ -520,7 +524,7 @@ export function AgentDetail() { {assignedIssues.map((issue) => ( navigate(`/issues/${issue.id}`)} trailing={} @@ -698,6 +702,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) { const queryClient = useQueryClient(); + const navigate = useNavigate(); const metrics = runMetrics(run); const [sessionOpen, setSessionOpen] = useState(false); @@ -708,6 +713,11 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin }, }); + const { data: touchedIssues } = useQuery({ + queryKey: queryKeys.runIssues(run.id), + queryFn: () => activityApi.issuesForRun(run.id), + }); + const timeFormat: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }; const startTime = run.startedAt ? new Date(run.startedAt).toLocaleTimeString("en-US", timeFormat) : null; const endTime = run.finishedAt ? new Date(run.finishedAt).toLocaleTimeString("en-US", timeFormat) : null; @@ -827,6 +837,28 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin )}
+ {/* Issues touched by this run */} + {touchedIssues && touchedIssues.length > 0 && ( +
+ Issues Touched ({touchedIssues.length}) +
+ {touchedIssues.map((issue) => ( + + ))} +
+
+ )} + {/* stderr excerpt for failed runs */} {run.stderrExcerpt && (
diff --git a/ui/src/pages/Approvals.tsx b/ui/src/pages/Approvals.tsx index 443512ed..f2b8b12b 100644 --- a/ui/src/pages/Approvals.tsx +++ b/ui/src/pages/Approvals.tsx @@ -10,7 +10,8 @@ import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ShieldCheck, UserPlus, Lightbulb, CheckCircle2, XCircle, Clock } from "lucide-react"; -import type { Approval } from "@paperclip/shared"; +import { Identity } from "../components/Identity"; +import type { Approval, Agent } from "@paperclip/shared"; type StatusFilter = "pending" | "all"; @@ -89,13 +90,13 @@ function CeoStrategyPayload({ payload }: { payload: Record }) { function ApprovalCard({ approval, - requesterName, + requesterAgent, onApprove, onReject, isPending, }: { approval: Approval; - requesterName: string | null; + requesterAgent: Agent | null; onApprove: () => void; onReject: () => void; isPending: boolean; @@ -109,11 +110,11 @@ function ApprovalCard({
-
+
{label} - {requesterName && ( - - requested by {requesterName} + {requesterAgent && ( + + requested by )}
@@ -209,11 +210,6 @@ export function Approvals() { }, }); - const agentName = (id: string | null) => { - if (!id || !agents) return null; - return agents.find((a) => a.id === id)?.name ?? null; - }; - const filtered = (data ?? []).filter( (a) => statusFilter === "all" || a.status === "pending", ); @@ -264,7 +260,7 @@ export function Approvals() { a.id === approval.requestedByAgentId) ?? null : null} onApprove={() => approveMutation.mutate(approval.id)} onReject={() => rejectMutation.mutate(approval.id)} isPending={approveMutation.isPending || rejectMutation.isPending} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 1a571df5..cebc2392 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -1,10 +1,11 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { dashboardApi } from "../api/dashboard"; import { activityApi } from "../api/activity"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; +import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; @@ -13,34 +14,51 @@ import { MetricCard } from "../components/MetricCard"; import { EmptyState } from "../components/EmptyState"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; +import { Identity } from "../components/Identity"; import { timeAgo } from "../lib/timeAgo"; import { formatCents } from "../lib/utils"; import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard, Clock } from "lucide-react"; -import type { Issue } from "@paperclip/shared"; +import type { Agent, Issue } from "@paperclip/shared"; const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; -function formatAction(action: string): string { - const actionMap: Record = { - "company.created": "Company created", - "agent.created": "Agent created", - "agent.updated": "Agent updated", - "agent.key_created": "API key created", - "issue.created": "Issue created", - "issue.updated": "Issue updated", - "issue.checked_out": "Issue checked out", - "issue.released": "Issue released", - "issue.commented": "Comment added", - "heartbeat.invoked": "Heartbeat invoked", - "heartbeat.completed": "Heartbeat completed", - "approval.created": "Approval requested", - "approval.approved": "Approval granted", - "approval.rejected": "Approval rejected", - "project.created": "Project created", - "goal.created": "Goal created", - "cost.recorded": "Cost recorded", - }; - return actionMap[action] ?? action.replace(/[._]/g, " "); +const ACTION_VERBS: Record = { + "issue.created": "created", + "issue.updated": "updated", + "issue.checked_out": "checked out", + "issue.released": "released", + "issue.comment_added": "commented on", + "issue.commented": "commented on", + "issue.deleted": "deleted", + "agent.created": "created", + "agent.updated": "updated", + "agent.paused": "paused", + "agent.resumed": "resumed", + "agent.terminated": "terminated", + "agent.key_created": "created API key for", + "heartbeat.invoked": "invoked heartbeat for", + "heartbeat.cancelled": "cancelled heartbeat for", + "approval.created": "requested approval", + "approval.approved": "approved", + "approval.rejected": "rejected", + "project.created": "created", + "project.updated": "updated", + "goal.created": "created", + "goal.updated": "updated", + "cost.reported": "reported cost for", + "cost.recorded": "recorded cost for", + "company.created": "created company", + "company.updated": "updated company", +}; + +function entityLink(entityType: string, entityId: string): string | null { + switch (entityType) { + case "issue": return `/issues/${entityId}`; + case "agent": return `/agents/${entityId}`; + case "project": return `/projects/${entityId}`; + case "goal": return `/goals/${entityId}`; + default: return null; + } } function getStaleIssues(issues: Issue[]): Issue[] { @@ -88,8 +106,28 @@ export function Dashboard() { enabled: !!selectedCompanyId, }); + const { data: projects } = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId!), + queryFn: () => projectsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const staleIssues = issues ? getStaleIssues(issues) : []; + const agentMap = useMemo(() => { + const map = new Map(); + for (const a of agents ?? []) map.set(a.id, a); + return map; + }, [agents]); + + const entityNameMap = useMemo(() => { + const map = new Map(); + for (const i of issues ?? []) map.set(`issue:${i.id}`, i.title); + for (const a of agents ?? []) map.set(`agent:${a.id}`, a.name); + for (const p of projects ?? []) map.set(`project:${p.id}`, p.name); + return map; + }, [issues, agents, projects]); + const agentName = (id: string | null) => { if (!id || !agents) return null; return agents.find((a) => a.id === id)?.name ?? null; @@ -157,21 +195,33 @@ export function Dashboard() { Recent Activity
- {activity.slice(0, 10).map((event) => ( -
-
- - {formatAction(event.action)} - - - {event.entityId.slice(0, 8)} + {activity.slice(0, 10).map((event) => { + const verb = ACTION_VERBS[event.action] ?? event.action.replace(/[._]/g, " "); + const name = entityNameMap.get(`${event.entityType}:${event.entityId}`); + const link = entityLink(event.entityType, event.entityId); + const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null; + return ( +
navigate(link) : undefined} + > +
+ + {verb} + {name && {name}} +
+ + {timeAgo(event.createdAt)}
- - {timeAgo(event.createdAt)} - -
- ))} + ); + })}
)} @@ -197,11 +247,12 @@ export function Dashboard() { {issue.title} - {issue.assigneeAgentId && ( - - {agentName(issue.assigneeAgentId) ?? issue.assigneeAgentId.slice(0, 8)} - - )} + {issue.assigneeAgentId && (() => { + const name = agentName(issue.assigneeAgentId); + return name + ? + : {issue.assigneeAgentId.slice(0, 8)}; + })()} {timeAgo(issue.updatedAt)} diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index c4229424..4659963c 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -64,6 +64,7 @@ import { MetricCard } from "@/components/MetricCard"; import { FilterBar, type FilterValue } from "@/components/FilterBar"; import { InlineEditor } from "@/components/InlineEditor"; import { PageSkeleton } from "@/components/PageSkeleton"; +import { Identity } from "@/components/Identity"; /* ------------------------------------------------------------------ */ /* Section wrapper */ @@ -624,6 +625,31 @@ export function DesignGuide() { + {/* ============================================================ */} + {/* IDENTITY */} + {/* ============================================================ */} +
+ +
+ + + +
+
+ + +
+ + + +
+
+ + + + +
+ {/* ============================================================ */} {/* TOOLTIPS */} {/* ============================================================ */} diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 1fadadf1..b90eb727 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -21,6 +21,7 @@ import { Clock, ExternalLink, } from "lucide-react"; +import { Identity } from "../components/Identity"; import type { Issue } from "@paperclip/shared"; const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -241,14 +242,15 @@ export function Inbox() { - {issue.id.slice(0, 8)} + {issue.identifier ?? issue.id.slice(0, 8)} {issue.title} - {issue.assigneeAgentId && ( - - {agentName(issue.assigneeAgentId) ?? issue.assigneeAgentId.slice(0, 8)} - - )} + {issue.assigneeAgentId && (() => { + const name = agentName(issue.assigneeAgentId); + return name + ? + : {issue.assigneeAgentId.slice(0, 8)}; + })()} updated {timeAgo(issue.updatedAt)} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 1d1764f8..5c85521a 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,18 +1,59 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useParams, Link } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; +import { activityApi } from "../api/activity"; +import { agentsApi } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; import { usePanel } from "../context/PanelContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; +import { relativeTime } from "../lib/utils"; import { InlineEditor } from "../components/InlineEditor"; import { CommentThread } from "../components/CommentThread"; import { IssueProperties } from "../components/IssueProperties"; +import { LiveRunWidget } from "../components/LiveRunWidget"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; +import { StatusBadge } from "../components/StatusBadge"; +import { Identity } from "../components/Identity"; import { Separator } from "@/components/ui/separator"; import { ChevronRight } from "lucide-react"; +import type { ActivityEvent } from "@paperclip/shared"; +import type { Agent } from "@paperclip/shared"; + +const ACTION_LABELS: Record = { + "issue.created": "created the issue", + "issue.updated": "updated the issue", + "issue.checked_out": "checked out the issue", + "issue.released": "released the issue", + "issue.comment_added": "added a comment", + "issue.deleted": "deleted the issue", + "agent.created": "created an agent", + "agent.updated": "updated the agent", + "agent.paused": "paused the agent", + "agent.resumed": "resumed the agent", + "agent.terminated": "terminated the agent", + "heartbeat.invoked": "invoked a heartbeat", + "heartbeat.cancelled": "cancelled a heartbeat", + "approval.created": "requested approval", + "approval.approved": "approved", + "approval.rejected": "rejected", +}; + +function formatAction(action: string): string { + return ACTION_LABELS[action] ?? action.replace(/[._]/g, " "); +} + +function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map }) { + const id = evt.actorId; + if (evt.actorType === "agent") { + const agent = agentMap.get(id); + return ; + } + if (evt.actorType === "system") return ; + return ; +} export function IssueDetail() { const { issueId } = useParams<{ issueId: string }>(); @@ -33,19 +74,74 @@ export function IssueDetail() { enabled: !!issueId, }); + const { data: activity } = useQuery({ + queryKey: queryKeys.issues.activity(issueId!), + queryFn: () => activityApi.forIssue(issueId!), + enabled: !!issueId, + }); + + const { data: linkedRuns } = useQuery({ + queryKey: queryKeys.issues.runs(issueId!), + queryFn: () => activityApi.runsForIssue(issueId!), + enabled: !!issueId, + refetchInterval: 5000, + }); + + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const agentMap = useMemo(() => { + const map = new Map(); + for (const a of agents ?? []) map.set(a.id, a); + return map; + }, [agents]); + + const commentsWithRunMeta = useMemo(() => { + const runMetaByCommentId = new Map(); + const agentIdByRunId = new Map(); + for (const run of linkedRuns ?? []) { + agentIdByRunId.set(run.runId, run.agentId); + } + for (const evt of activity ?? []) { + if (evt.action !== "issue.comment_added" || !evt.runId) continue; + const details = evt.details ?? {}; + const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null; + if (!commentId || runMetaByCommentId.has(commentId)) continue; + runMetaByCommentId.set(commentId, { + runId: evt.runId, + runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null, + }); + } + return (comments ?? []).map((comment) => { + const meta = runMetaByCommentId.get(comment.id); + return meta ? { ...comment, ...meta } : comment; + }); + }, [activity, comments, linkedRuns]); + + const invalidateIssue = () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }); + if (selectedCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); + } + }; + const updateIssue = useMutation({ mutationFn: (data: Record) => issuesApi.update(issueId!, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); - if (selectedCompanyId) { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); - } - }, + onSuccess: invalidateIssue, }); const addComment = useMutation({ - mutationFn: (body: string) => issuesApi.addComment(issueId!, body), + mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) => + issuesApi.addComment(issueId!, body, reopen), onSuccess: () => { + invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); }, }); @@ -105,7 +201,7 @@ export function IssueDetail() { priority={issue.priority} onChange={(priority) => updateIssue.mutate({ priority })} /> - {issue.id.slice(0, 8)} + {issue.identifier ?? issue.id.slice(0, 8)}
+ + { - await addComment.mutateAsync(body); + comments={commentsWithRunMeta} + issueStatus={issue.status} + agentMap={agentMap} + onAdd={async (body, reopen) => { + await addComment.mutateAsync({ body, reopen }); }} /> + + {/* Linked Runs */} + {linkedRuns && linkedRuns.length > 0 && ( + <> + +
+

Linked Runs

+
+ {linkedRuns.map((run) => ( + +
+ + {run.runId.slice(0, 8)} +
+ {relativeTime(run.createdAt)} + + ))} +
+
+ + )} + + {/* Activity Log */} + {activity && activity.length > 0 && ( + <> + +
+

Activity

+
+ {activity.slice(0, 20).map((evt) => ( +
+ + {formatAction(evt.action)} + {relativeTime(evt.createdAt)} +
+ ))} +
+
+ + )}
); } diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 2dec2246..c47a244a 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button"; import { Tabs } from "@/components/ui/tabs"; import { CircleDot, Plus } from "lucide-react"; import { formatDate } from "../lib/utils"; +import { Identity } from "../components/Identity"; import type { Issue } from "@paperclip/shared"; const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"]; @@ -152,7 +153,7 @@ export function Issues() { {items.map((issue) => ( navigate(`/issues/${issue.id}`)} leading={ @@ -166,11 +167,12 @@ export function Issues() { } trailing={
- {issue.assigneeAgentId && ( - - {agentName(issue.assigneeAgentId) ?? issue.assigneeAgentId.slice(0, 8)} - - )} + {issue.assigneeAgentId && (() => { + const name = agentName(issue.assigneeAgentId); + return name + ? + : {issue.assigneeAgentId.slice(0, 8)}; + })()} {formatDate(issue.createdAt)} diff --git a/ui/src/pages/MyIssues.tsx b/ui/src/pages/MyIssues.tsx index ddd21257..17c9a862 100644 --- a/ui/src/pages/MyIssues.tsx +++ b/ui/src/pages/MyIssues.tsx @@ -50,7 +50,7 @@ export function MyIssues() { {myIssues.map((issue) => ( navigate(`/issues/${issue.id}`)} leading={ diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index d3474f43..e24e9e85 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -91,7 +91,7 @@ export function ProjectDetail() { {projectIssues.map((issue) => ( } />