From c2709687b8e0f5b58ff8f65ac8872871fd87c361 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Thu, 26 Feb 2026 16:33:39 -0600 Subject: [PATCH] feat: server-side issue search, dashboard charts, and inbox badges Add ILIKE-based issue search across title, identifier, description, and comments with relevance ranking. Add assigneeUserId filter and allow agents to return issues to creator. Show assigned issue count in sidebar badges. Add minCount param to live-runs endpoint. Add activity charts (run activity, priority, status, success rate) to dashboard. Improve active agents panel with recent run cards. Co-Authored-By: Claude Opus 4.6 --- server/src/routes/agents.ts | 53 +++-- server/src/routes/issues.ts | 27 ++- server/src/routes/sidebar-badges.ts | 27 ++- server/src/services/heartbeat.ts | 6 +- server/src/services/issues.ts | 51 ++++- server/src/services/sidebar-badges.ts | 8 +- ui/src/api/heartbeats.ts | 4 +- ui/src/api/issues.ts | 17 +- ui/src/components/ActiveAgentsPanel.tsx | 105 ++++++---- ui/src/components/ActivityCharts.tsx | 263 ++++++++++++++++++++++++ ui/src/components/CommandPalette.tsx | 96 ++++++--- ui/src/components/IssuesList.tsx | 44 ++-- ui/src/lib/queryKeys.ts | 3 + ui/src/pages/Dashboard.tsx | 34 ++- 14 files changed, 610 insertions(+), 128 deletions(-) create mode 100644 ui/src/components/ActivityCharts.tsx 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() {
- )}