diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 120f80d8..4b1148bf 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2,7 +2,7 @@ import { Router, type Request } from "express"; import { randomUUID } from "node:crypto"; import type { Db } from "@paperclip/db"; import { agents as agentsTable, companies, heartbeatRuns } from "@paperclip/db"; -import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { createAgentKeySchema, createAgentHireSchema, @@ -987,20 +987,25 @@ export function agentRoutes(db: Db) { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + const minCountParam = req.query.minCount as string | undefined; + const minCount = minCountParam ? Math.max(0, Math.min(20, parseInt(minCountParam, 10) || 0)) : 0; + + const columns = { + id: heartbeatRuns.id, + status: heartbeatRuns.status, + invocationSource: heartbeatRuns.invocationSource, + triggerDetail: heartbeatRuns.triggerDetail, + startedAt: heartbeatRuns.startedAt, + finishedAt: heartbeatRuns.finishedAt, + createdAt: heartbeatRuns.createdAt, + agentId: heartbeatRuns.agentId, + agentName: agentsTable.name, + adapterType: agentsTable.adapterType, + issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"), + }; + const liveRuns = await db - .select({ - id: heartbeatRuns.id, - status: heartbeatRuns.status, - invocationSource: heartbeatRuns.invocationSource, - triggerDetail: heartbeatRuns.triggerDetail, - startedAt: heartbeatRuns.startedAt, - finishedAt: heartbeatRuns.finishedAt, - createdAt: heartbeatRuns.createdAt, - agentId: heartbeatRuns.agentId, - agentName: agentsTable.name, - adapterType: agentsTable.adapterType, - issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"), - }) + .select(columns) .from(heartbeatRuns) .innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id)) .where( @@ -1011,6 +1016,26 @@ export function agentRoutes(db: Db) { ) .orderBy(desc(heartbeatRuns.createdAt)); + if (minCount > 0 && liveRuns.length < minCount) { + const activeIds = liveRuns.map((r) => r.id); + const recentRuns = await db + .select(columns) + .from(heartbeatRuns) + .innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id)) + .where( + and( + eq(heartbeatRuns.companyId, companyId), + not(inArray(heartbeatRuns.status, ["queued", "running"])), + ...(activeIds.length > 0 ? [not(inArray(heartbeatRuns.id, activeIds))] : []), + ), + ) + .orderBy(desc(heartbeatRuns.createdAt)) + .limit(minCount - liveRuns.length); + + res.json([...liveRuns, ...recentRuns]); + return; + } + res.json(liveRuns); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index bbd17910..a8b93ff5 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -186,11 +186,24 @@ export function issueRoutes(db: Db, storage: StorageService) { router.get("/companies/:companyId/issues", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + const assigneeUserFilterRaw = req.query.assigneeUserId as string | undefined; + const assigneeUserId = + assigneeUserFilterRaw === "me" && req.actor.type === "board" + ? req.actor.userId + : assigneeUserFilterRaw; + + if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) { + res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); + return; + } + const result = await svc.list(companyId, { status: req.query.status as string | undefined, assigneeAgentId: req.query.assigneeAgentId as string | undefined, + assigneeUserId, projectId: req.query.projectId as string | undefined, labelId: req.query.labelId as string | undefined, + q: req.query.q as string | undefined, }); res.json(result); }); @@ -390,8 +403,20 @@ export function issueRoutes(db: Db, storage: StorageService) { const assigneeWillChange = (req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId) || (req.body.assigneeUserId !== undefined && req.body.assigneeUserId !== existing.assigneeUserId); + + const isAgentReturningIssueToCreator = + req.actor.type === "agent" && + !!req.actor.agentId && + existing.assigneeAgentId === req.actor.agentId && + req.body.assigneeAgentId === null && + typeof req.body.assigneeUserId === "string" && + !!existing.createdByUserId && + req.body.assigneeUserId === existing.createdByUserId; + if (assigneeWillChange) { - await assertCanAssignTasks(req, existing.companyId); + if (!isAgentReturningIssueToCreator) { + await assertCanAssignTasks(req, existing.companyId); + } } if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return; diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts index b5b245c0..1dd03904 100644 --- a/server/src/routes/sidebar-badges.ts +++ b/server/src/routes/sidebar-badges.ts @@ -1,11 +1,13 @@ import { Router } from "express"; import type { Db } from "@paperclip/db"; -import { and, eq, sql } from "drizzle-orm"; -import { joinRequests } from "@paperclip/db"; +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { issues, joinRequests } from "@paperclip/db"; import { sidebarBadgeService } from "../services/sidebar-badges.js"; import { accessService } from "../services/access.js"; import { assertCompanyAccess } from "./authz.js"; +const INBOX_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"] as const; + export function sidebarBadgeRoutes(db: Db) { const router = Router(); const svc = sidebarBadgeService(db); @@ -32,7 +34,26 @@ export function sidebarBadgeRoutes(db: Db) { .then((rows) => Number(rows[0]?.count ?? 0)) : 0; - const badges = await svc.get(companyId, { joinRequests: joinRequestCount }); + const assignedIssueCount = + req.actor.type === "board" && req.actor.userId + ? await db + .select({ count: sql`count(*)` }) + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + eq(issues.assigneeUserId, req.actor.userId), + inArray(issues.status, [...INBOX_ISSUE_STATUSES]), + isNull(issues.hiddenAt), + ), + ) + .then((rows) => Number(rows[0]?.count ?? 0)) + : 0; + + const badges = await svc.get(companyId, { + joinRequests: joinRequestCount, + assignedIssues: assignedIssueCount, + }); res.json(badges); }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index ab42c50f..24eaccbb 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1495,7 +1495,11 @@ export function heartbeatService(db: Db) { return null; } - if (issueId) { + const bypassIssueExecutionLock = + reason === "issue_comment_mentioned" || + readNonEmptyString(enrichedContextSnapshot.wakeReason) === "issue_comment_mentioned"; + + if (issueId && !bypassIssueExecutionLock) { const agentNameKey = normalizeAgentNameKey(agent.name); const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 4fc013f9..eb8d57d1 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -47,8 +47,10 @@ function applyStatusSideEffects( export interface IssueFilters { status?: string; assigneeAgentId?: string; + assigneeUserId?: string; projectId?: string; labelId?: string; + q?: string; } type IssueRow = typeof issues.$inferSelect; @@ -62,6 +64,10 @@ function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]); +function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, "\\$&"); +} + async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise> { const map = new Map(); if (issueIds.length === 0) return map; @@ -219,6 +225,25 @@ export function issueService(db: Db) { return { list: async (companyId: string, filters?: IssueFilters) => { const conditions = [eq(issues.companyId, companyId)]; + const rawSearch = filters?.q?.trim() ?? ""; + const hasSearch = rawSearch.length > 0; + const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : ""; + const startsWithPattern = `${escapedSearch}%`; + const containsPattern = `%${escapedSearch}%`; + const titleStartsWithMatch = sql`${issues.title} ILIKE ${startsWithPattern} ESCAPE '\\'`; + const titleContainsMatch = sql`${issues.title} ILIKE ${containsPattern} ESCAPE '\\'`; + const identifierStartsWithMatch = sql`${issues.identifier} ILIKE ${startsWithPattern} ESCAPE '\\'`; + const identifierContainsMatch = sql`${issues.identifier} ILIKE ${containsPattern} ESCAPE '\\'`; + const descriptionContainsMatch = sql`${issues.description} ILIKE ${containsPattern} ESCAPE '\\'`; + const commentContainsMatch = sql` + EXISTS ( + SELECT 1 + FROM ${issueComments} + WHERE ${issueComments.issueId} = ${issues.id} + AND ${issueComments.companyId} = ${companyId} + AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\' + ) + `; if (filters?.status) { const statuses = filters.status.split(",").map((s) => s.trim()); conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]) : inArray(issues.status, statuses)); @@ -226,6 +251,9 @@ export function issueService(db: Db) { if (filters?.assigneeAgentId) { conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId)); } + if (filters?.assigneeUserId) { + conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId)); + } if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); if (filters?.labelId) { const labeledIssueIds = await db @@ -235,14 +263,35 @@ export function issueService(db: Db) { if (labeledIssueIds.length === 0) return []; conditions.push(inArray(issues.id, labeledIssueIds.map((row) => row.issueId))); } + if (hasSearch) { + conditions.push( + or( + titleContainsMatch, + identifierContainsMatch, + descriptionContainsMatch, + commentContainsMatch, + )!, + ); + } conditions.push(isNull(issues.hiddenAt)); 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`; + const searchOrder = sql` + CASE + WHEN ${titleStartsWithMatch} THEN 0 + WHEN ${titleContainsMatch} THEN 1 + WHEN ${identifierStartsWithMatch} THEN 2 + WHEN ${identifierContainsMatch} THEN 3 + WHEN ${descriptionContainsMatch} THEN 4 + WHEN ${commentContainsMatch} THEN 5 + ELSE 6 + END + `; const rows = await db .select() .from(issues) .where(and(...conditions)) - .orderBy(asc(priorityOrder), desc(issues.updatedAt)); + .orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt)); return withIssueLabels(db, rows); }, diff --git a/server/src/services/sidebar-badges.ts b/server/src/services/sidebar-badges.ts index 5ed1fb8a..d4675f7b 100644 --- a/server/src/services/sidebar-badges.ts +++ b/server/src/services/sidebar-badges.ts @@ -8,7 +8,10 @@ const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"]; export function sidebarBadgeService(db: Db) { return { - get: async (companyId: string, extra?: { joinRequests?: number }): Promise => { + get: async ( + companyId: string, + extra?: { joinRequests?: number; assignedIssues?: number }, + ): Promise => { const actionableApprovals = await db .select({ count: sql`count(*)` }) .from(approvals) @@ -40,8 +43,9 @@ export function sidebarBadgeService(db: Db) { ).length; const joinRequests = extra?.joinRequests ?? 0; + const assignedIssues = extra?.assignedIssues ?? 0; return { - inbox: actionableApprovals + failedRuns + joinRequests, + inbox: actionableApprovals + failedRuns + joinRequests + assignedIssues, approvals: actionableApprovals, failedRuns, joinRequests, diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 14c51017..6ceb40f5 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -42,6 +42,6 @@ export const heartbeatsApi = { api.get(`/issues/${issueId}/live-runs`), activeRunForIssue: (issueId: string) => api.get(`/issues/${issueId}/active-run`), - liveRunsForCompany: (companyId: string) => - api.get(`/companies/${companyId}/live-runs`), + liveRunsForCompany: (companyId: string, minCount?: number) => + api.get(`/companies/${companyId}/live-runs${minCount ? `?minCount=${minCount}` : ""}`), }; diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 8b3825d0..cdd3446a 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -2,9 +2,24 @@ import type { Approval, Issue, IssueAttachment, IssueComment, IssueLabel } from import { api } from "./client"; export const issuesApi = { - list: (companyId: string, filters?: { projectId?: string }) => { + list: ( + companyId: string, + filters?: { + status?: string; + projectId?: string; + assigneeAgentId?: string; + assigneeUserId?: string; + labelId?: string; + q?: string; + }, + ) => { const params = new URLSearchParams(); + if (filters?.status) params.set("status", filters.status); if (filters?.projectId) params.set("projectId", filters.projectId); + if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId); + if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId); + if (filters?.labelId) params.set("labelId", filters.labelId); + if (filters?.q) params.set("q", filters.q); const qs = params.toString(); return api.get(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`); }, diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 1cef5722..1b0b7483 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -24,6 +24,7 @@ interface FeedItem { } const MAX_FEED_ITEMS = 40; +const MIN_DASHBOARD_RUNS = 4; function readString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; @@ -137,6 +138,10 @@ function parseStderrChunk( return items; } +function isRunActive(run: LiveRunForIssue): boolean { + return run.status === "queued" || run.status === "running"; +} + interface ActiveAgentsPanelProps { companyId: string; } @@ -148,8 +153,8 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { const nextIdRef = useRef(1); const { data: liveRuns } = useQuery({ - queryKey: queryKeys.liveRuns(companyId), - queryFn: () => heartbeatsApi.liveRunsForCompany(companyId), + queryKey: [...queryKeys.liveRuns(companyId), "dashboard"], + queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, MIN_DASHBOARD_RUNS), }); const runs = liveRuns ?? []; @@ -168,7 +173,7 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { }, [issues]); const runById = useMemo(() => new Map(runs.map((r) => [r.id, r])), [runs]); - const activeRunIds = useMemo(() => new Set(runs.map((r) => r.id)), [runs]); + const activeRunIds = useMemo(() => new Set(runs.filter(isRunActive).map((r) => r.id)), [runs]); // Clean up pending buffers for runs that ended useEffect(() => { @@ -293,23 +298,28 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { }; }, [activeRunIds, companyId, runById]); - if (runs.length === 0) return null; - return (

- Active Agents + Agents

-
- {runs.map((run) => ( - - ))} -
+ {runs.length === 0 ? ( +
+

No recent agent runs.

+
+ ) : ( +
+ {runs.map((run) => ( + + ))} +
+ )}
); } @@ -318,10 +328,12 @@ function AgentRunCard({ run, issue, feed, + isActive, }: { run: LiveRunForIssue; issue?: Issue; feed: FeedItem[]; + isActive: boolean; }) { const bodyRef = useRef(null); const recent = feed.slice(-20); @@ -333,34 +345,47 @@ function AgentRunCard({ }, [feed.length]); return ( -
+
+ {/* Header */}
-
- - - - +
+ {isActive ? ( + + + + + ) : ( + + + + )} - Live - - {run.id.slice(0, 8)} - + {isActive && ( + Live + )}
- Open run
+ {/* Issue context */} {run.issueId && (
- Working on: {issue?.identifier ?? run.issueId.slice(0, 8)} @@ -369,25 +394,31 @@ function AgentRunCard({
)} -
- {recent.length === 0 && ( + {/* Feed body */} +
+ {isActive && recent.length === 0 && (
Waiting for output...
)} + {!isActive && recent.length === 0 && ( +
+ {run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`} +
+ )} {recent.map((item, index) => (
{relativeTime(item.ts)} {item.text} diff --git a/ui/src/components/ActivityCharts.tsx b/ui/src/components/ActivityCharts.tsx new file mode 100644 index 00000000..24c996f4 --- /dev/null +++ b/ui/src/components/ActivityCharts.tsx @@ -0,0 +1,263 @@ +import type { HeartbeatRun } from "@paperclip/shared"; + +/* ---- Utilities ---- */ + +export function getLast14Days(): string[] { + return Array.from({ length: 14 }, (_, i) => { + const d = new Date(); + d.setDate(d.getDate() - (13 - i)); + return d.toISOString().slice(0, 10); + }); +} + +function formatDayLabel(dateStr: string): string { + const d = new Date(dateStr + "T12:00:00"); + return `${d.getMonth() + 1}/${d.getDate()}`; +} + +/* ---- Sub-components ---- */ + +function DateLabels({ days }: { days: string[] }) { + return ( +
+ {days.map((day, i) => ( +
+ {(i === 0 || i === 6 || i === 13) ? ( + {formatDayLabel(day)} + ) : null} +
+ ))} +
+ ); +} + +function ChartLegend({ items }: { items: { color: string; label: string }[] }) { + return ( +
+ {items.map(item => ( + + + {item.label} + + ))} +
+ ); +} + +export function ChartCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+ {subtitle && {subtitle}} +
+ {children} +
+ ); +} + +/* ---- Chart Components ---- */ + +export function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) { + const days = getLast14Days(); + + const grouped = new Map(); + for (const day of days) grouped.set(day, { succeeded: 0, failed: 0, other: 0 }); + for (const run of runs) { + const day = new Date(run.createdAt).toISOString().slice(0, 10); + const entry = grouped.get(day); + if (!entry) continue; + if (run.status === "succeeded") entry.succeeded++; + else if (run.status === "failed" || run.status === "timed_out") entry.failed++; + else entry.other++; + } + + const maxValue = Math.max(...Array.from(grouped.values()).map(v => v.succeeded + v.failed + v.other), 1); + const hasData = Array.from(grouped.values()).some(v => v.succeeded + v.failed + v.other > 0); + + if (!hasData) return

No runs yet

; + + return ( +
+
+ {days.map(day => { + const entry = grouped.get(day)!; + const total = entry.succeeded + entry.failed + entry.other; + const heightPct = (total / maxValue) * 100; + return ( +
+ {total > 0 ? ( +
+ {entry.succeeded > 0 &&
} + {entry.failed > 0 &&
} + {entry.other > 0 &&
} +
+ ) : ( +
+ )} +
+ ); + })} +
+ +
+ ); +} + +const priorityColors: Record = { + critical: "#ef4444", + high: "#f97316", + medium: "#eab308", + low: "#6b7280", +}; + +const priorityOrder = ["critical", "high", "medium", "low"] as const; + +export function PriorityChart({ issues }: { issues: { priority: string; createdAt: Date }[] }) { + const days = getLast14Days(); + const grouped = new Map>(); + for (const day of days) grouped.set(day, { critical: 0, high: 0, medium: 0, low: 0 }); + for (const issue of issues) { + const day = new Date(issue.createdAt).toISOString().slice(0, 10); + const entry = grouped.get(day); + if (!entry) continue; + if (issue.priority in entry) entry[issue.priority]++; + } + + const maxValue = Math.max(...Array.from(grouped.values()).map(v => Object.values(v).reduce((a, b) => a + b, 0)), 1); + const hasData = Array.from(grouped.values()).some(v => Object.values(v).reduce((a, b) => a + b, 0) > 0); + + if (!hasData) return

No issues

; + + return ( +
+
+ {days.map(day => { + const entry = grouped.get(day)!; + const total = Object.values(entry).reduce((a, b) => a + b, 0); + const heightPct = (total / maxValue) * 100; + return ( +
+ {total > 0 ? ( +
+ {priorityOrder.map(p => entry[p] > 0 ? ( +
+ ) : null)} +
+ ) : ( +
+ )} +
+ ); + })} +
+ + ({ color: priorityColors[p], label: p.charAt(0).toUpperCase() + p.slice(1) }))} /> +
+ ); +} + +const statusColors: Record = { + todo: "#3b82f6", + in_progress: "#8b5cf6", + in_review: "#a855f7", + done: "#10b981", + blocked: "#ef4444", + cancelled: "#6b7280", + backlog: "#64748b", +}; + +const statusLabels: Record = { + todo: "To Do", + in_progress: "In Progress", + in_review: "In Review", + done: "Done", + blocked: "Blocked", + cancelled: "Cancelled", + backlog: "Backlog", +}; + +export function IssueStatusChart({ issues }: { issues: { status: string; createdAt: Date }[] }) { + const days = getLast14Days(); + const allStatuses = new Set(); + const grouped = new Map>(); + for (const day of days) grouped.set(day, {}); + for (const issue of issues) { + const day = new Date(issue.createdAt).toISOString().slice(0, 10); + const entry = grouped.get(day); + if (!entry) continue; + entry[issue.status] = (entry[issue.status] ?? 0) + 1; + allStatuses.add(issue.status); + } + + const statusOrder = ["todo", "in_progress", "in_review", "done", "blocked", "cancelled", "backlog"].filter(s => allStatuses.has(s)); + const maxValue = Math.max(...Array.from(grouped.values()).map(v => Object.values(v).reduce((a, b) => a + b, 0)), 1); + const hasData = allStatuses.size > 0; + + if (!hasData) return

No issues

; + + return ( +
+
+ {days.map(day => { + const entry = grouped.get(day)!; + const total = Object.values(entry).reduce((a, b) => a + b, 0); + const heightPct = (total / maxValue) * 100; + return ( +
+ {total > 0 ? ( +
+ {statusOrder.map(s => (entry[s] ?? 0) > 0 ? ( +
+ ) : null)} +
+ ) : ( +
+ )} +
+ ); + })} +
+ + ({ color: statusColors[s] ?? "#6b7280", label: statusLabels[s] ?? s }))} /> +
+ ); +} + +export function SuccessRateChart({ runs }: { runs: HeartbeatRun[] }) { + const days = getLast14Days(); + const grouped = new Map(); + for (const day of days) grouped.set(day, { succeeded: 0, total: 0 }); + for (const run of runs) { + const day = new Date(run.createdAt).toISOString().slice(0, 10); + const entry = grouped.get(day); + if (!entry) continue; + entry.total++; + if (run.status === "succeeded") entry.succeeded++; + } + + const hasData = Array.from(grouped.values()).some(v => v.total > 0); + if (!hasData) return

No runs yet

; + + return ( +
+
+ {days.map(day => { + const entry = grouped.get(day)!; + const rate = entry.total > 0 ? entry.succeeded / entry.total : 0; + const color = entry.total === 0 ? undefined : rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444"; + return ( +
0 ? Math.round(rate * 100) : 0}% (${entry.succeeded}/${entry.total})`}> + {entry.total > 0 ? ( +
+ ) : ( +
+ )} +
+ ); + })} +
+ +
+ ); +} diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 12953143..b88c36b1 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { useCompany } from "../context/CompanyContext"; @@ -32,9 +32,11 @@ import { Identity } from "./Identity"; export function CommandPalette() { const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); const navigate = useNavigate(); const { selectedCompanyId } = useCompany(); const { openNewIssue, openNewAgent } = useDialog(); + const searchQuery = query.trim(); useEffect(() => { function handleKeyDown(e: KeyboardEvent) { @@ -47,12 +49,22 @@ export function CommandPalette() { return () => document.removeEventListener("keydown", handleKeyDown); }, []); + useEffect(() => { + if (!open) setQuery(""); + }, [open]); + const { data: issues = [] } = useQuery({ queryKey: queryKeys.issues.list(selectedCompanyId!), queryFn: () => issuesApi.list(selectedCompanyId!), enabled: !!selectedCompanyId && open, }); + const { data: searchedIssues = [] } = useQuery({ + queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery), + queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery }), + enabled: !!selectedCompanyId && open && searchQuery.length > 0, + }); + const { data: agents = [] } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), @@ -75,12 +87,49 @@ export function CommandPalette() { return agents.find((a) => a.id === id)?.name ?? null; }; + const visibleIssues = useMemo( + () => (searchQuery.length > 0 ? searchedIssues : issues), + [issues, searchedIssues, searchQuery], + ); + return ( - + No results found. + + { + setOpen(false); + openNewIssue(); + }} + > + + Create new issue + C + + { + setOpen(false); + openNewAgent(); + }} + > + + Create new agent + + go("/projects")}> + + Create new project + + + + + go("/dashboard")}> @@ -116,40 +165,21 @@ export function CommandPalette() { - - - - { - setOpen(false); - openNewIssue(); - }} - > - - Create new issue - C - - { - setOpen(false); - openNewAgent(); - }} - > - - Create new agent - - go("/projects")}> - - Create new project - - - - {issues.length > 0 && ( + {visibleIssues.length > 0 && ( <> - {issues.slice(0, 10).map((issue) => ( - go(`/issues/${issue.identifier ?? issue.id}`)}> + {visibleIssues.slice(0, 10).map((issue) => ( + 0 + ? `${searchQuery} ${issue.identifier ?? ""} ${issue.title} ${issue.description ?? ""}` + : undefined + } + keywords={issue.description ? [issue.description] : undefined} + onSelect={() => go(`/issues/${issue.identifier ?? issue.id}`)} + > {issue.identifier ?? issue.id.slice(0, 8)} diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 3d57bcd7..87fa148a 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, useCallback } from "react"; +import { useDeferredValue, useMemo, useState, useCallback } from "react"; import { Link } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; @@ -94,25 +94,6 @@ function applyFilters(issues: Issue[], state: IssueViewState): Issue[] { return result; } -function applySearch(issues: Issue[], searchQuery: string, agentName: (id: string | null) => string | null): Issue[] { - const query = searchQuery.trim().toLowerCase(); - if (!query) return issues; - - return issues.filter((issue) => { - const fields = [ - issue.identifier ?? "", - issue.title, - issue.description ?? "", - issue.status, - issue.priority, - agentName(issue.assigneeAgentId) ?? "", - ...(issue.labels ?? []).map((label) => label.name), - ]; - - return fields.some((field) => field.toLowerCase().includes(query)); - }); -} - function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { const sorted = [...issues]; const dir = state.sortDir === "asc" ? 1 : -1; @@ -186,6 +167,8 @@ export function IssuesList({ const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); const [issueSearch, setIssueSearch] = useState(""); + const deferredIssueSearch = useDeferredValue(issueSearch); + const normalizedIssueSearch = deferredIssueSearch.trim(); const updateView = useCallback((patch: Partial) => { setViewState((prev) => { @@ -195,16 +178,25 @@ export function IssuesList({ }); }, [viewStateKey]); - const agentName = (id: string | null) => { + const { data: searchedIssues = [] } = useQuery({ + queryKey: queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), + queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId }), + enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, + }); + + const agentName = useCallback((id: string | null) => { if (!id || !agents) return null; return agents.find((a) => a.id === id)?.name ?? null; - }; + }, [agents]); const filtered = useMemo(() => { - const filteredByControls = applyFilters(issues, viewState); - const filteredBySearch = applySearch(filteredByControls, issueSearch, agentName); - return sortIssues(filteredBySearch, viewState); - }, [issues, viewState, issueSearch, agents]); + const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; + const filteredByControls = applyFilters(sourceIssues, viewState); + if (normalizedIssueSearch.length > 0) { + return filteredByControls; + } + return sortIssues(filteredByControls, viewState); + }, [issues, searchedIssues, viewState, normalizedIssueSearch]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index ee63dc6a..1a7722d3 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -14,6 +14,9 @@ export const queryKeys = { }, issues: { list: (companyId: string) => ["issues", companyId] as const, + search: (companyId: string, q: string, projectId?: string) => + ["issues", companyId, "search", q, projectId ?? "__all-projects__"] as const, + listAssignedToMe: (companyId: string) => ["issues", companyId, "assigned-to-me"] as const, labels: (companyId: string) => ["issues", companyId, "labels"] as const, listByProject: (companyId: string, projectId: string) => ["issues", companyId, "project", projectId] as const, diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 5d15de87..a50129ad 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -6,6 +6,7 @@ import { activityApi } from "../api/activity"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; +import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; @@ -20,6 +21,7 @@ import { timeAgo } from "../lib/timeAgo"; import { cn, formatCents } from "../lib/utils"; import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react"; import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel"; +import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts"; import type { Agent, Issue } from "@paperclip/shared"; function getRecentIssues(issues: Issue[]): Issue[] { @@ -28,7 +30,7 @@ function getRecentIssues(issues: Issue[]): Issue[] { } export function Dashboard() { - const { selectedCompanyId, selectedCompany, companies } = useCompany(); + const { selectedCompanyId, companies } = useCompany(); const { openOnboarding } = useDialog(); const { setBreadcrumbs } = useBreadcrumbs(); const [animatedActivityIds, setAnimatedActivityIds] = useState>(new Set()); @@ -70,6 +72,12 @@ export function Dashboard() { enabled: !!selectedCompanyId, }); + const { data: runs } = useQuery({ + queryKey: queryKeys.heartbeats(selectedCompanyId!), + queryFn: () => heartbeatsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const recentIssues = issues ? getRecentIssues(issues) : []; const recentActivity = useMemo(() => (activity ?? []).slice(0, 10), [activity]); @@ -171,16 +179,14 @@ export function Dashboard() { return (
- {selectedCompany && ( -

{selectedCompany.name}

- )} - {isLoading &&

Loading...

} {error &&

{error.message}

} + + {data && ( <> -
+
+
+ + + + + + + + + + + + +
+
{/* Recent Activity */} {recentActivity.length > 0 && ( @@ -298,7 +319,6 @@ export function Dashboard() {
- )}