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 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<string | null>`${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<string | null>`${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);
});

View File

@@ -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;

View File

@@ -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<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);
});

View File

@@ -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);

View File

@@ -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<Map<string, IssueLabelRow[]>> {
const map = new Map<string, IssueLabelRow[]>();
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<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) {
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<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
.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);
},

View File

@@ -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<SidebarBadges> => {
get: async (
companyId: string,
extra?: { joinRequests?: number; assignedIssues?: number },
): Promise<SidebarBadges> => {
const actionableApprovals = await db
.select({ count: sql<number>`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,

View File

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

View File

@@ -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 (
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Active Agents
Agents
</h3>
<div className="grid md:grid-cols-2 gap-4">
{runs.map((run) => (
<AgentRunCard
key={run.id}
run={run}
issue={run.issueId ? issueById.get(run.issueId) : undefined}
feed={feedByRun.get(run.id) ?? []}
/>
))}
</div>
{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) => (
<AgentRunCard
key={run.id}
run={run}
issue={run.issueId ? issueById.get(run.issueId) : undefined}
feed={feedByRun.get(run.id) ?? []}
isActive={isRunActive(run)}
/>
))}
</div>
)}
</div>
);
}
@@ -318,10 +328,12 @@ function AgentRunCard({
run,
issue,
feed,
isActive,
}: {
run: LiveRunForIssue;
issue?: Issue;
feed: FeedItem[];
isActive: boolean;
}) {
const bodyRef = useRef<HTMLDivElement>(null);
const recent = feed.slice(-20);
@@ -333,34 +345,47 @@ function AgentRunCard({
}, [feed.length]);
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 gap-2">
<span className="relative flex h-2 w-2">
<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>
<div className="flex items-center gap-2 min-w-0">
{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="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
) : (
<span className="flex h-2 w-2 shrink-0">
<span className="inline-flex rounded-full h-2 w-2 bg-muted-foreground/40" />
</span>
)}
<Identity name={run.agentName} size="sm" />
<span className="text-[11px] font-medium text-blue-400">Live</span>
<span className="text-[10px] text-muted-foreground font-mono">
{run.id.slice(0, 8)}
</span>
{isActive && (
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
)}
</div>
<Link
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" />
</Link>
</div>
{/* Issue context */}
{run.issueId && (
<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
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)}
>
{issue?.identifier ?? run.issueId.slice(0, 8)}
@@ -369,25 +394,31 @@ function AgentRunCard({
</div>
)}
<div ref={bodyRef} className="max-h-[180px] overflow-y-auto p-2 font-mono text-[11px] space-y-1">
{recent.length === 0 && (
{/* Feed body */}
<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>
)}
{!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) => (
<div
key={item.id}
className={cn(
"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={cn(
"min-w-0 break-words",
item.tone === "error" && "text-red-300",
item.tone === "warn" && "text-amber-300",
item.tone === "assistant" && "text-emerald-200",
item.tone === "tool" && "text-cyan-300",
item.tone === "error" && "text-red-600 dark:text-red-300",
item.tone === "warn" && "text-amber-600 dark:text-amber-300",
item.tone === "assistant" && "text-emerald-700 dark:text-emerald-200",
item.tone === "tool" && "text-cyan-600 dark:text-cyan-300",
item.tone === "info" && "text-foreground/80",
)}>
{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 { 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 (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Search issues, agents, projects..." />
<CommandInput
placeholder="Search issues, agents, projects..."
value={query}
onValueChange={setQuery}
/>
<CommandList>
<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">
<CommandItem onSelect={() => go("/dashboard")}>
<LayoutDashboard className="mr-2 h-4 w-4" />
@@ -116,40 +165,21 @@ export function CommandPalette() {
</CommandItem>
</CommandGroup>
<CommandSeparator />
<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 && (
{visibleIssues.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Issues">
{issues.slice(0, 10).map((issue) => (
<CommandItem key={issue.id} onSelect={() => go(`/issues/${issue.identifier ?? issue.id}`)}>
{visibleIssues.slice(0, 10).map((issue) => (
<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" />
<span className="text-muted-foreground mr-2 font-mono text-xs">
{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 { 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<string | null>(null);
const [assigneeSearch, setAssigneeSearch] = useState("");
const [issueSearch, setIssueSearch] = useState("");
const deferredIssueSearch = useDeferredValue(issueSearch);
const normalizedIssueSearch = deferredIssueSearch.trim();
const updateView = useCallback((patch: Partial<IssueViewState>) => {
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!),

View File

@@ -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,

View File

@@ -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<Set<string>>(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 (
<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>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
<ActiveAgentsPanel companyId={selectedCompanyId!} />
{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
icon={Bot}
value={data.agents.active + data.agents.running + data.agents.paused + data.agents.error}
@@ -232,6 +238,21 @@ export function Dashboard() {
/>
</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">
{/* Recent Activity */}
{recentActivity.length > 0 && (
@@ -298,7 +319,6 @@ export function Dashboard() {
</div>
</div>
<ActiveAgentsPanel companyId={selectedCompanyId!} />
</>
)}
</div>