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,