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 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-26 16:33:39 -06:00
parent 5cd12dec89
commit c2709687b8
14 changed files with 610 additions and 128 deletions

View File

@@ -2,7 +2,7 @@ import { Router, type Request } from "express";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { Db } from "@paperclip/db"; import type { Db } from "@paperclip/db";
import { agents as agentsTable, companies, heartbeatRuns } 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 { import {
createAgentKeySchema, createAgentKeySchema,
createAgentHireSchema, createAgentHireSchema,
@@ -987,8 +987,10 @@ export function agentRoutes(db: Db) {
const companyId = req.params.companyId as string; const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId); assertCompanyAccess(req, companyId);
const liveRuns = await db const minCountParam = req.query.minCount as string | undefined;
.select({ const minCount = minCountParam ? Math.max(0, Math.min(20, parseInt(minCountParam, 10) || 0)) : 0;
const columns = {
id: heartbeatRuns.id, id: heartbeatRuns.id,
status: heartbeatRuns.status, status: heartbeatRuns.status,
invocationSource: heartbeatRuns.invocationSource, invocationSource: heartbeatRuns.invocationSource,
@@ -1000,7 +1002,10 @@ export function agentRoutes(db: Db) {
agentName: agentsTable.name, agentName: agentsTable.name,
adapterType: agentsTable.adapterType, adapterType: agentsTable.adapterType,
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"), issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"),
}) };
const liveRuns = await db
.select(columns)
.from(heartbeatRuns) .from(heartbeatRuns)
.innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id)) .innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id))
.where( .where(
@@ -1011,6 +1016,26 @@ export function agentRoutes(db: Db) {
) )
.orderBy(desc(heartbeatRuns.createdAt)); .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); res.json(liveRuns);
}); });

View File

@@ -186,11 +186,24 @@ export function issueRoutes(db: Db, storage: StorageService) {
router.get("/companies/:companyId/issues", async (req, res) => { router.get("/companies/:companyId/issues", async (req, res) => {
const companyId = req.params.companyId as string; const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId); 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, { const result = await svc.list(companyId, {
status: req.query.status as string | undefined, status: req.query.status as string | undefined,
assigneeAgentId: req.query.assigneeAgentId as string | undefined, assigneeAgentId: req.query.assigneeAgentId as string | undefined,
assigneeUserId,
projectId: req.query.projectId as string | undefined, projectId: req.query.projectId as string | undefined,
labelId: req.query.labelId as string | undefined, labelId: req.query.labelId as string | undefined,
q: req.query.q as string | undefined,
}); });
res.json(result); res.json(result);
}); });
@@ -390,9 +403,21 @@ export function issueRoutes(db: Db, storage: StorageService) {
const assigneeWillChange = const assigneeWillChange =
(req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId) || (req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId) ||
(req.body.assigneeUserId !== undefined && req.body.assigneeUserId !== existing.assigneeUserId); (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) { if (assigneeWillChange) {
if (!isAgentReturningIssueToCreator) {
await assertCanAssignTasks(req, existing.companyId); await assertCanAssignTasks(req, existing.companyId);
} }
}
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return; if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body; const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;

View File

@@ -1,11 +1,13 @@
import { Router } from "express"; import { Router } from "express";
import type { Db } from "@paperclip/db"; import type { Db } from "@paperclip/db";
import { and, eq, sql } from "drizzle-orm"; import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import { joinRequests } from "@paperclip/db"; import { issues, joinRequests } from "@paperclip/db";
import { sidebarBadgeService } from "../services/sidebar-badges.js"; import { sidebarBadgeService } from "../services/sidebar-badges.js";
import { accessService } from "../services/access.js"; import { accessService } from "../services/access.js";
import { assertCompanyAccess } from "./authz.js"; import { assertCompanyAccess } from "./authz.js";
const INBOX_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"] as const;
export function sidebarBadgeRoutes(db: Db) { export function sidebarBadgeRoutes(db: Db) {
const router = Router(); const router = Router();
const svc = sidebarBadgeService(db); const svc = sidebarBadgeService(db);
@@ -32,7 +34,26 @@ export function sidebarBadgeRoutes(db: Db) {
.then((rows) => Number(rows[0]?.count ?? 0)) .then((rows) => Number(rows[0]?.count ?? 0))
: 0; : 0;
const badges = await svc.get(companyId, { joinRequests: joinRequestCount }); const assignedIssueCount =
req.actor.type === "board" && req.actor.userId
? await db
.select({ count: sql<number>`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); res.json(badges);
}); });

View File

@@ -1495,7 +1495,11 @@ export function heartbeatService(db: Db) {
return null; 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 agentNameKey = normalizeAgentNameKey(agent.name);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey); const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);

View File

@@ -47,8 +47,10 @@ function applyStatusSideEffects(
export interface IssueFilters { export interface IssueFilters {
status?: string; status?: string;
assigneeAgentId?: string; assigneeAgentId?: string;
assigneeUserId?: string;
projectId?: string; projectId?: string;
labelId?: string; labelId?: string;
q?: string;
} }
type IssueRow = typeof issues.$inferSelect; 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"]); 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<Map<string, IssueLabelRow[]>> { async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<string, IssueLabelRow[]>> {
const map = new Map<string, IssueLabelRow[]>(); const map = new Map<string, IssueLabelRow[]>();
if (issueIds.length === 0) return map; if (issueIds.length === 0) return map;
@@ -219,6 +225,25 @@ export function issueService(db: Db) {
return { return {
list: async (companyId: string, filters?: IssueFilters) => { list: async (companyId: string, filters?: IssueFilters) => {
const conditions = [eq(issues.companyId, companyId)]; 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<boolean>`${issues.title} ILIKE ${startsWithPattern} ESCAPE '\\'`;
const titleContainsMatch = sql<boolean>`${issues.title} ILIKE ${containsPattern} ESCAPE '\\'`;
const identifierStartsWithMatch = sql<boolean>`${issues.identifier} ILIKE ${startsWithPattern} ESCAPE '\\'`;
const identifierContainsMatch = sql<boolean>`${issues.identifier} ILIKE ${containsPattern} ESCAPE '\\'`;
const descriptionContainsMatch = sql<boolean>`${issues.description} ILIKE ${containsPattern} ESCAPE '\\'`;
const commentContainsMatch = sql<boolean>`
EXISTS (
SELECT 1
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'
)
`;
if (filters?.status) { if (filters?.status) {
const statuses = filters.status.split(",").map((s) => s.trim()); const statuses = filters.status.split(",").map((s) => s.trim());
conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]) : inArray(issues.status, statuses)); 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) { if (filters?.assigneeAgentId) {
conditions.push(eq(issues.assigneeAgentId, 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?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
if (filters?.labelId) { if (filters?.labelId) {
const labeledIssueIds = await db const labeledIssueIds = await db
@@ -235,14 +263,35 @@ export function issueService(db: Db) {
if (labeledIssueIds.length === 0) return []; if (labeledIssueIds.length === 0) return [];
conditions.push(inArray(issues.id, labeledIssueIds.map((row) => row.issueId))); conditions.push(inArray(issues.id, labeledIssueIds.map((row) => row.issueId)));
} }
if (hasSearch) {
conditions.push(
or(
titleContainsMatch,
identifierContainsMatch,
descriptionContainsMatch,
commentContainsMatch,
)!,
);
}
conditions.push(isNull(issues.hiddenAt)); 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 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<number>`
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 const rows = await db
.select() .select()
.from(issues) .from(issues)
.where(and(...conditions)) .where(and(...conditions))
.orderBy(asc(priorityOrder), desc(issues.updatedAt)); .orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt));
return withIssueLabels(db, rows); return withIssueLabels(db, rows);
}, },

View File

@@ -8,7 +8,10 @@ const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"];
export function sidebarBadgeService(db: Db) { export function sidebarBadgeService(db: Db) {
return { return {
get: async (companyId: string, extra?: { joinRequests?: number }): Promise<SidebarBadges> => { get: async (
companyId: string,
extra?: { joinRequests?: number; assignedIssues?: number },
): Promise<SidebarBadges> => {
const actionableApprovals = await db const actionableApprovals = await db
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(approvals) .from(approvals)
@@ -40,8 +43,9 @@ export function sidebarBadgeService(db: Db) {
).length; ).length;
const joinRequests = extra?.joinRequests ?? 0; const joinRequests = extra?.joinRequests ?? 0;
const assignedIssues = extra?.assignedIssues ?? 0;
return { return {
inbox: actionableApprovals + failedRuns + joinRequests, inbox: actionableApprovals + failedRuns + joinRequests + assignedIssues,
approvals: actionableApprovals, approvals: actionableApprovals,
failedRuns, failedRuns,
joinRequests, joinRequests,

View File

@@ -42,6 +42,6 @@ export const heartbeatsApi = {
api.get<LiveRunForIssue[]>(`/issues/${issueId}/live-runs`), api.get<LiveRunForIssue[]>(`/issues/${issueId}/live-runs`),
activeRunForIssue: (issueId: string) => activeRunForIssue: (issueId: string) =>
api.get<ActiveRunForIssue | null>(`/issues/${issueId}/active-run`), api.get<ActiveRunForIssue | null>(`/issues/${issueId}/active-run`),
liveRunsForCompany: (companyId: string) => liveRunsForCompany: (companyId: string, minCount?: number) =>
api.get<LiveRunForIssue[]>(`/companies/${companyId}/live-runs`), api.get<LiveRunForIssue[]>(`/companies/${companyId}/live-runs${minCount ? `?minCount=${minCount}` : ""}`),
}; };

View File

@@ -2,9 +2,24 @@ import type { Approval, Issue, IssueAttachment, IssueComment, IssueLabel } from
import { api } from "./client"; import { api } from "./client";
export const issuesApi = { 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(); const params = new URLSearchParams();
if (filters?.status) params.set("status", filters.status);
if (filters?.projectId) params.set("projectId", filters.projectId); 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(); const qs = params.toString();
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`); return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
}, },

View File

@@ -24,6 +24,7 @@ interface FeedItem {
} }
const MAX_FEED_ITEMS = 40; const MAX_FEED_ITEMS = 40;
const MIN_DASHBOARD_RUNS = 4;
function readString(value: unknown): string | null { function readString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null; return typeof value === "string" && value.trim().length > 0 ? value : null;
@@ -137,6 +138,10 @@ function parseStderrChunk(
return items; return items;
} }
function isRunActive(run: LiveRunForIssue): boolean {
return run.status === "queued" || run.status === "running";
}
interface ActiveAgentsPanelProps { interface ActiveAgentsPanelProps {
companyId: string; companyId: string;
} }
@@ -148,8 +153,8 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
const nextIdRef = useRef(1); const nextIdRef = useRef(1);
const { data: liveRuns } = useQuery({ const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(companyId), queryKey: [...queryKeys.liveRuns(companyId), "dashboard"],
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId), queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, MIN_DASHBOARD_RUNS),
}); });
const runs = liveRuns ?? []; const runs = liveRuns ?? [];
@@ -168,7 +173,7 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
}, [issues]); }, [issues]);
const runById = useMemo(() => new Map(runs.map((r) => [r.id, r])), [runs]); 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 // Clean up pending buffers for runs that ended
useEffect(() => { useEffect(() => {
@@ -293,23 +298,28 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
}; };
}, [activeRunIds, companyId, runById]); }, [activeRunIds, companyId, runById]);
if (runs.length === 0) return null;
return ( return (
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3"> <h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Active Agents Agents
</h3> </h3>
<div className="grid md:grid-cols-2 gap-4"> {runs.length === 0 ? (
<div className="border border-border rounded-lg p-4">
<p className="text-sm text-muted-foreground">No recent agent runs.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-2 sm:gap-4">
{runs.map((run) => ( {runs.map((run) => (
<AgentRunCard <AgentRunCard
key={run.id} key={run.id}
run={run} run={run}
issue={run.issueId ? issueById.get(run.issueId) : undefined} issue={run.issueId ? issueById.get(run.issueId) : undefined}
feed={feedByRun.get(run.id) ?? []} feed={feedByRun.get(run.id) ?? []}
isActive={isRunActive(run)}
/> />
))} ))}
</div> </div>
)}
</div> </div>
); );
} }
@@ -318,10 +328,12 @@ function AgentRunCard({
run, run,
issue, issue,
feed, feed,
isActive,
}: { }: {
run: LiveRunForIssue; run: LiveRunForIssue;
issue?: Issue; issue?: Issue;
feed: FeedItem[]; feed: FeedItem[];
isActive: boolean;
}) { }) {
const bodyRef = useRef<HTMLDivElement>(null); const bodyRef = useRef<HTMLDivElement>(null);
const recent = feed.slice(-20); const recent = feed.slice(-20);
@@ -333,34 +345,47 @@ function AgentRunCard({
}, [feed.length]); }, [feed.length]);
return ( return (
<div className="rounded-lg border border-blue-500/30 bg-background/80 overflow-hidden shadow-[0_0_12px_rgba(59,130,246,0.08)]"> <div className={cn(
"flex flex-col rounded-lg border overflow-hidden min-h-[200px]",
isActive
? "border-blue-500/30 bg-background/80 shadow-[0_0_12px_rgba(59,130,246,0.08)]"
: "border-border bg-background/50",
)}>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50"> <div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0">
<span className="relative flex h-2 w-2"> {isActive ? (
<span className="relative flex h-2 w-2 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" /> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" /> <span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span> </span>
<Identity name={run.agentName} size="sm" /> ) : (
<span className="text-[11px] font-medium text-blue-400">Live</span> <span className="flex h-2 w-2 shrink-0">
<span className="text-[10px] text-muted-foreground font-mono"> <span className="inline-flex rounded-full h-2 w-2 bg-muted-foreground/40" />
{run.id.slice(0, 8)}
</span> </span>
)}
<Identity name={run.agentName} size="sm" />
{isActive && (
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
)}
</div> </div>
<Link <Link
to={`/agents/${run.agentId}/runs/${run.id}`} to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center gap-1 text-[10px] text-blue-400 hover:text-blue-300" className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground shrink-0"
> >
Open run
<ExternalLink className="h-2.5 w-2.5" /> <ExternalLink className="h-2.5 w-2.5" />
</Link> </Link>
</div> </div>
{/* Issue context */}
{run.issueId && ( {run.issueId && (
<div className="px-3 py-1.5 border-b border-border/40 text-xs flex items-center gap-1 min-w-0"> <div className="px-3 py-1.5 border-b border-border/40 text-xs flex items-center gap-1 min-w-0">
<span className="text-muted-foreground mr-1">Working on:</span>
<Link <Link
to={`/issues/${issue?.identifier ?? run.issueId}`} to={`/issues/${issue?.identifier ?? run.issueId}`}
className="text-blue-400 hover:text-blue-300 hover:underline min-w-0 truncate" className={cn(
"hover:underline min-w-0 truncate",
isActive ? "text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300" : "text-muted-foreground hover:text-foreground",
)}
title={issue?.title ? `${issue?.identifier ?? run.issueId.slice(0, 8)} - ${issue.title}` : issue?.identifier ?? run.issueId.slice(0, 8)} title={issue?.title ? `${issue?.identifier ?? run.issueId.slice(0, 8)} - ${issue.title}` : issue?.identifier ?? run.issueId.slice(0, 8)}
> >
{issue?.identifier ?? run.issueId.slice(0, 8)} {issue?.identifier ?? run.issueId.slice(0, 8)}
@@ -369,25 +394,31 @@ function AgentRunCard({
</div> </div>
)} )}
<div ref={bodyRef} className="max-h-[180px] overflow-y-auto p-2 font-mono text-[11px] space-y-1"> {/* Feed body */}
{recent.length === 0 && ( <div ref={bodyRef} className="flex-1 max-h-[140px] overflow-y-auto p-2 font-mono text-[11px] space-y-1">
{isActive && recent.length === 0 && (
<div className="text-xs text-muted-foreground">Waiting for output...</div> <div className="text-xs text-muted-foreground">Waiting for output...</div>
)} )}
{!isActive && recent.length === 0 && (
<div className="text-xs text-muted-foreground">
{run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}
</div>
)}
{recent.map((item, index) => ( {recent.map((item, index) => (
<div <div
key={item.id} key={item.id}
className={cn( className={cn(
"flex gap-2 items-start", "flex gap-2 items-start",
index === recent.length - 1 && "animate-in fade-in slide-in-from-bottom-1 duration-300", index === recent.length - 1 && isActive && "animate-in fade-in slide-in-from-bottom-1 duration-300",
)} )}
> >
<span className="text-[10px] text-muted-foreground shrink-0">{relativeTime(item.ts)}</span> <span className="text-[10px] text-muted-foreground shrink-0">{relativeTime(item.ts)}</span>
<span className={cn( <span className={cn(
"min-w-0 break-words", "min-w-0 break-words",
item.tone === "error" && "text-red-300", item.tone === "error" && "text-red-600 dark:text-red-300",
item.tone === "warn" && "text-amber-300", item.tone === "warn" && "text-amber-600 dark:text-amber-300",
item.tone === "assistant" && "text-emerald-200", item.tone === "assistant" && "text-emerald-700 dark:text-emerald-200",
item.tone === "tool" && "text-cyan-300", item.tone === "tool" && "text-cyan-600 dark:text-cyan-300",
item.tone === "info" && "text-foreground/80", item.tone === "info" && "text-foreground/80",
)}> )}>
{item.text} {item.text}

View File

@@ -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 (
<div className="flex gap-[3px] mt-1.5">
{days.map((day, i) => (
<div key={day} className="flex-1 text-center">
{(i === 0 || i === 6 || i === 13) ? (
<span className="text-[9px] text-muted-foreground tabular-nums">{formatDayLabel(day)}</span>
) : null}
</div>
))}
</div>
);
}
function ChartLegend({ items }: { items: { color: string; label: string }[] }) {
return (
<div className="flex flex-wrap gap-x-2.5 gap-y-0.5 mt-2">
{items.map(item => (
<span key={item.label} className="flex items-center gap-1 text-[9px] text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={{ backgroundColor: item.color }} />
{item.label}
</span>
))}
</div>
);
}
export function ChartCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<div className="border border-border rounded-lg p-4 space-y-3">
<div>
<h3 className="text-xs font-medium text-muted-foreground">{title}</h3>
{subtitle && <span className="text-[10px] text-muted-foreground/60">{subtitle}</span>}
</div>
{children}
</div>
);
}
/* ---- Chart Components ---- */
export function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) {
const days = getLast14Days();
const grouped = new Map<string, { succeeded: number; failed: number; other: number }>();
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 <p className="text-xs text-muted-foreground">No runs yet</p>;
return (
<div>
<div className="flex items-end gap-[3px] h-20">
{days.map(day => {
const entry = grouped.get(day)!;
const total = entry.succeeded + entry.failed + entry.other;
const heightPct = (total / maxValue) * 100;
return (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} runs`}>
{total > 0 ? (
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
{entry.succeeded > 0 && <div className="bg-emerald-500" style={{ flex: entry.succeeded }} />}
{entry.failed > 0 && <div className="bg-red-500" style={{ flex: entry.failed }} />}
{entry.other > 0 && <div className="bg-neutral-500" style={{ flex: entry.other }} />}
</div>
) : (
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
)}
</div>
);
})}
</div>
<DateLabels days={days} />
</div>
);
}
const priorityColors: Record<string, string> = {
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<string, Record<string, number>>();
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 <p className="text-xs text-muted-foreground">No issues</p>;
return (
<div>
<div className="flex items-end gap-[3px] h-20">
{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 (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} issues`}>
{total > 0 ? (
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
{priorityOrder.map(p => entry[p] > 0 ? (
<div key={p} style={{ flex: entry[p], backgroundColor: priorityColors[p] }} />
) : null)}
</div>
) : (
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
)}
</div>
);
})}
</div>
<DateLabels days={days} />
<ChartLegend items={priorityOrder.map(p => ({ color: priorityColors[p], label: p.charAt(0).toUpperCase() + p.slice(1) }))} />
</div>
);
}
const statusColors: Record<string, string> = {
todo: "#3b82f6",
in_progress: "#8b5cf6",
in_review: "#a855f7",
done: "#10b981",
blocked: "#ef4444",
cancelled: "#6b7280",
backlog: "#64748b",
};
const statusLabels: Record<string, string> = {
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<string>();
const grouped = new Map<string, Record<string, number>>();
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 <p className="text-xs text-muted-foreground">No issues</p>;
return (
<div>
<div className="flex items-end gap-[3px] h-20">
{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 (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} issues`}>
{total > 0 ? (
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
{statusOrder.map(s => (entry[s] ?? 0) > 0 ? (
<div key={s} style={{ flex: entry[s], backgroundColor: statusColors[s] ?? "#6b7280" }} />
) : null)}
</div>
) : (
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
)}
</div>
);
})}
</div>
<DateLabels days={days} />
<ChartLegend items={statusOrder.map(s => ({ color: statusColors[s] ?? "#6b7280", label: statusLabels[s] ?? s }))} />
</div>
);
}
export function SuccessRateChart({ runs }: { runs: HeartbeatRun[] }) {
const days = getLast14Days();
const grouped = new Map<string, { succeeded: number; total: number }>();
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 <p className="text-xs text-muted-foreground">No runs yet</p>;
return (
<div>
<div className="flex items-end gap-[3px] h-20">
{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 (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${entry.total > 0 ? Math.round(rate * 100) : 0}% (${entry.succeeded}/${entry.total})`}>
{entry.total > 0 ? (
<div style={{ height: `${rate * 100}%`, minHeight: 2, backgroundColor: color }} />
) : (
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
)}
</div>
);
})}
</div>
<DateLabels days={days} />
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
@@ -32,9 +32,11 @@ import { Identity } from "./Identity";
export function CommandPalette() { export function CommandPalette() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const navigate = useNavigate(); const navigate = useNavigate();
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const { openNewIssue, openNewAgent } = useDialog(); const { openNewIssue, openNewAgent } = useDialog();
const searchQuery = query.trim();
useEffect(() => { useEffect(() => {
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
@@ -47,12 +49,22 @@ export function CommandPalette() {
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, []); }, []);
useEffect(() => {
if (!open) setQuery("");
}, [open]);
const { data: issues = [] } = useQuery({ const { data: issues = [] } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!), queryKey: queryKeys.issues.list(selectedCompanyId!),
queryFn: () => issuesApi.list(selectedCompanyId!), queryFn: () => issuesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && open, 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({ const { data: agents = [] } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!), queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!),
@@ -75,12 +87,49 @@ export function CommandPalette() {
return agents.find((a) => a.id === id)?.name ?? null; return agents.find((a) => a.id === id)?.name ?? null;
}; };
const visibleIssues = useMemo(
() => (searchQuery.length > 0 ? searchedIssues : issues),
[issues, searchedIssues, searchQuery],
);
return ( return (
<CommandDialog open={open} onOpenChange={setOpen}> <CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Search issues, agents, projects..." /> <CommandInput
placeholder="Search issues, agents, projects..."
value={query}
onValueChange={setQuery}
/>
<CommandList> <CommandList>
<CommandEmpty>No results found.</CommandEmpty> <CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Actions">
<CommandItem
onSelect={() => {
setOpen(false);
openNewIssue();
}}
>
<SquarePen className="mr-2 h-4 w-4" />
Create new issue
<span className="ml-auto text-xs text-muted-foreground">C</span>
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false);
openNewAgent();
}}
>
<Plus className="mr-2 h-4 w-4" />
Create new agent
</CommandItem>
<CommandItem onSelect={() => go("/projects")}>
<Plus className="mr-2 h-4 w-4" />
Create new project
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Pages"> <CommandGroup heading="Pages">
<CommandItem onSelect={() => go("/dashboard")}> <CommandItem onSelect={() => go("/dashboard")}>
<LayoutDashboard className="mr-2 h-4 w-4" /> <LayoutDashboard className="mr-2 h-4 w-4" />
@@ -116,40 +165,21 @@ export function CommandPalette() {
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
<CommandSeparator /> {visibleIssues.length > 0 && (
<CommandGroup heading="Actions">
<CommandItem
onSelect={() => {
setOpen(false);
openNewIssue();
}}
>
<SquarePen className="mr-2 h-4 w-4" />
Create new issue
<span className="ml-auto text-xs text-muted-foreground">C</span>
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false);
openNewAgent();
}}
>
<Plus className="mr-2 h-4 w-4" />
Create new agent
</CommandItem>
<CommandItem onSelect={() => go("/projects")}>
<Plus className="mr-2 h-4 w-4" />
Create new project
</CommandItem>
</CommandGroup>
{issues.length > 0 && (
<> <>
<CommandSeparator /> <CommandSeparator />
<CommandGroup heading="Issues"> <CommandGroup heading="Issues">
{issues.slice(0, 10).map((issue) => ( {visibleIssues.slice(0, 10).map((issue) => (
<CommandItem key={issue.id} onSelect={() => go(`/issues/${issue.identifier ?? issue.id}`)}> <CommandItem
key={issue.id}
value={
searchQuery.length > 0
? `${searchQuery} ${issue.identifier ?? ""} ${issue.title} ${issue.description ?? ""}`
: undefined
}
keywords={issue.description ? [issue.description] : undefined}
onSelect={() => go(`/issues/${issue.identifier ?? issue.id}`)}
>
<CircleDot className="mr-2 h-4 w-4" /> <CircleDot className="mr-2 h-4 w-4" />
<span className="text-muted-foreground mr-2 font-mono text-xs"> <span className="text-muted-foreground mr-2 font-mono text-xs">
{issue.identifier ?? issue.id.slice(0, 8)} {issue.identifier ?? issue.id.slice(0, 8)}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState, useCallback } from "react"; import { useDeferredValue, useMemo, useState, useCallback } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
@@ -94,25 +94,6 @@ function applyFilters(issues: Issue[], state: IssueViewState): Issue[] {
return result; 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[] { function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
const sorted = [...issues]; const sorted = [...issues];
const dir = state.sortDir === "asc" ? 1 : -1; const dir = state.sortDir === "asc" ? 1 : -1;
@@ -186,6 +167,8 @@ export function IssuesList({
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null); const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
const [assigneeSearch, setAssigneeSearch] = useState(""); const [assigneeSearch, setAssigneeSearch] = useState("");
const [issueSearch, setIssueSearch] = useState(""); const [issueSearch, setIssueSearch] = useState("");
const deferredIssueSearch = useDeferredValue(issueSearch);
const normalizedIssueSearch = deferredIssueSearch.trim();
const updateView = useCallback((patch: Partial<IssueViewState>) => { const updateView = useCallback((patch: Partial<IssueViewState>) => {
setViewState((prev) => { setViewState((prev) => {
@@ -195,16 +178,25 @@ export function IssuesList({
}); });
}, [viewStateKey]); }, [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; if (!id || !agents) return null;
return agents.find((a) => a.id === id)?.name ?? null; return agents.find((a) => a.id === id)?.name ?? null;
}; }, [agents]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const filteredByControls = applyFilters(issues, viewState); const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
const filteredBySearch = applySearch(filteredByControls, issueSearch, agentName); const filteredByControls = applyFilters(sourceIssues, viewState);
return sortIssues(filteredBySearch, viewState); if (normalizedIssueSearch.length > 0) {
}, [issues, viewState, issueSearch, agents]); return filteredByControls;
}
return sortIssues(filteredByControls, viewState);
}, [issues, searchedIssues, viewState, normalizedIssueSearch]);
const { data: labels } = useQuery({ const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(selectedCompanyId!), queryKey: queryKeys.issues.labels(selectedCompanyId!),

View File

@@ -14,6 +14,9 @@ export const queryKeys = {
}, },
issues: { issues: {
list: (companyId: string) => ["issues", companyId] as const, 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, labels: (companyId: string) => ["issues", companyId, "labels"] as const,
listByProject: (companyId: string, projectId: string) => listByProject: (companyId: string, projectId: string) =>
["issues", companyId, "project", projectId] as const, ["issues", companyId, "project", projectId] as const,

View File

@@ -6,6 +6,7 @@ import { activityApi } from "../api/activity";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -20,6 +21,7 @@ import { timeAgo } from "../lib/timeAgo";
import { cn, formatCents } from "../lib/utils"; import { cn, formatCents } from "../lib/utils";
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react"; import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react";
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel"; import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
import type { Agent, Issue } from "@paperclip/shared"; import type { Agent, Issue } from "@paperclip/shared";
function getRecentIssues(issues: Issue[]): Issue[] { function getRecentIssues(issues: Issue[]): Issue[] {
@@ -28,7 +30,7 @@ function getRecentIssues(issues: Issue[]): Issue[] {
} }
export function Dashboard() { export function Dashboard() {
const { selectedCompanyId, selectedCompany, companies } = useCompany(); const { selectedCompanyId, companies } = useCompany();
const { openOnboarding } = useDialog(); const { openOnboarding } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const [animatedActivityIds, setAnimatedActivityIds] = useState<Set<string>>(new Set()); const [animatedActivityIds, setAnimatedActivityIds] = useState<Set<string>>(new Set());
@@ -70,6 +72,12 @@ export function Dashboard() {
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
const { data: runs } = useQuery({
queryKey: queryKeys.heartbeats(selectedCompanyId!),
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const recentIssues = issues ? getRecentIssues(issues) : []; const recentIssues = issues ? getRecentIssues(issues) : [];
const recentActivity = useMemo(() => (activity ?? []).slice(0, 10), [activity]); const recentActivity = useMemo(() => (activity ?? []).slice(0, 10), [activity]);
@@ -171,16 +179,14 @@ export function Dashboard() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{selectedCompany && (
<p className="text-sm text-muted-foreground">{selectedCompany.name}</p>
)}
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>} {isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>} {error && <p className="text-sm text-destructive">{error.message}</p>}
<ActiveAgentsPanel companyId={selectedCompanyId!} />
{data && ( {data && (
<> <>
<div className="grid grid-cols-2 xl:grid-cols-4 gap-2 sm:gap-4"> <div className="grid grid-cols-2 xl:grid-cols-4 gap-1 sm:gap-2">
<MetricCard <MetricCard
icon={Bot} icon={Bot}
value={data.agents.active + data.agents.running + data.agents.paused + data.agents.error} value={data.agents.active + data.agents.running + data.agents.paused + data.agents.error}
@@ -232,6 +238,21 @@ export function Dashboard() {
/> />
</div> </div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<ChartCard title="Run Activity" subtitle="Last 14 days">
<RunActivityChart runs={runs ?? []} />
</ChartCard>
<ChartCard title="Issues by Priority" subtitle="Last 14 days">
<PriorityChart issues={issues ?? []} />
</ChartCard>
<ChartCard title="Issues by Status" subtitle="Last 14 days">
<IssueStatusChart issues={issues ?? []} />
</ChartCard>
<ChartCard title="Success Rate" subtitle="Last 14 days">
<SuccessRateChart runs={runs ?? []} />
</ChartCard>
</div>
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
{/* Recent Activity */} {/* Recent Activity */}
{recentActivity.length > 0 && ( {recentActivity.length > 0 && (
@@ -298,7 +319,6 @@ export function Dashboard() {
</div> </div>
</div> </div>
<ActiveAgentsPanel companyId={selectedCompanyId!} />
</> </>
)} )}
</div> </div>