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:
@@ -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}
|
||||
|
||||
263
ui/src/components/ActivityCharts.tsx
Normal file
263
ui/src/components/ActivityCharts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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!),
|
||||
|
||||
Reference in New Issue
Block a user