perf(panel): kanban virtualization + row memoization + scorecards batch endpoint (#594)

* perf(panel): kanban virtualization + row memoization + scorecards batch endpoint

The audit's remaining phases: the kanban card lists render through
@tanstack/react-virtual windows (columns are the dnd drop targets, cards
only drag — no sortable conflict) with memoized columns/cards and a
stabilized handleAction; the task table's desktop row and mobile card are
extracted and memoized (the table itself already client-paginates to 100).
Backend: the per-member scorecard N+1 (~20 requests x 3 queries per poll)
collapses into GET /dashboard/metrics/members backed by
get_all_member_scorecards with grouped rollup/overlay SQL shared with the
single-agent path.

* test(metrics): shared-DB-safe scorecard tests — unique seeds, delta assertions

The two new batch-scorecard tests assumed a private DB: fixed ceo/system
slugs collided with other tests' seeds (ix_agents_slug) and a global
exactly-one CEO lookup + exact roster count broke in the one-process
suite. Unique slugs, subset/disjoint assertions, dead count constant
dropped.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-19 19:42:24 +02:00
committed by GitHub
co-authored by Renn F
parent db04674342
commit bab53e31ca
14 changed files with 1009 additions and 442 deletions
+1
View File
@@ -37,6 +37,7 @@
"@radix-ui/react-tooltip": "^1.2.12",
"@tailwindcss/typography": "^0.5.20",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-virtual": "3.14.6",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+20
View File
@@ -74,6 +74,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.101.2
version: 5.101.2(react@19.2.7)
'@tanstack/react-virtual':
specifier: 3.14.6
version: 3.14.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
axios:
specifier: ^1.16.0
version: 1.18.1
@@ -1397,6 +1400,15 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-virtual@3.14.6':
resolution: {integrity: sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.17.4':
resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -5009,6 +5021,14 @@ snapshots:
'@tanstack/query-core': 5.101.2
react: 19.2.7
'@tanstack/react-virtual@3.14.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@tanstack/virtual-core': 3.17.4
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@tanstack/virtual-core@3.17.4': {}
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.7
@@ -28,7 +28,7 @@ import {
useSensor,
useSensors,
} from "@dnd-kit/core";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { KanbanCard } from "./kanban-card";
import { RequiredNotesDialog } from "@/components/tasks/task-detail/task-action-dialogs";
import { skippedPreconditions } from "./bypass-preconditions";
@@ -104,6 +104,23 @@ export function KanbanBoard({
const updateTask = useUpdateTask();
const [activeTask, setActiveTask] = useState<Task | null>(null);
// Latest tasks snapshot for handleAction below, read via ref instead of a
// closure — React Query hands back a new array reference on every refetch
// even when the underlying rows are unchanged, which would otherwise bust
// handleAction's identity (and, through it, KanbanCard's memoization) every
// 30s regardless of whether anything actually changed.
const tasksRef = useRef<Task[]>([]);
tasksRef.current = tasks || [];
// useMutation's mutate/mutateAsync are bound once per observer and stay
// referentially stable across renders (query-core binds them in the
// constructor) — pulling them out lets handleAction's useCallback below
// stay stable even though `lifecycle` itself is a fresh object every render.
const claimMutateAsync = lifecycle.claim.mutateAsync;
const startMutateAsync = lifecycle.start.mutateAsync;
const submitQaMutateAsync = lifecycle.submitQa.mutateAsync;
const unblockMutateAsync = lifecycle.unblock.mutateAsync;
const { register, unregister } = usePageRefresh();
useEffect(() => {
@@ -127,13 +144,21 @@ export function KanbanBoard({
}),
);
// Group tasks by status
const tasksByStatus = columns.reduce(
(acc, col) => {
acc[col.status] = (tasks || []).filter((t) => t.status === col.status);
return acc;
},
{} as Record<TaskStatus, Task[]>,
// Group tasks by status. Memoized so each column's `tasks` prop keeps a
// stable reference across renders that don't actually change the data —
// required for KanbanColumn/KanbanCard's React.memo to skip re-rendering.
const tasksByStatus = useMemo(
() =>
columns.reduce(
(acc, col) => {
acc[col.status] = (tasks || []).filter(
(t) => t.status === col.status,
);
return acc;
},
{} as Record<TaskStatus, Task[]>,
),
[tasks, columns],
);
const handleDragStart = (event: DragStartEvent) => {
@@ -223,57 +248,66 @@ export function KanbanBoard({
}
};
const handleAction = async (action: string, taskId: string) => {
try {
switch (action) {
case "move-forward":
// Determine next action based on current status
const task = tasks?.find((t) => t.id === taskId);
if (!task) return;
const handleAction = useCallback(
async (action: string, taskId: string) => {
try {
switch (action) {
case "move-forward":
// Determine next action based on current status
const task = tasksRef.current.find((t) => t.id === taskId);
if (!task) return;
switch (task.status) {
case TaskStatus.BACKLOG:
// BACKLOG tasks need session before activation - PM only
toast.info(
"Backlog tasks must be activated by PM with a session",
);
break;
case TaskStatus.PENDING:
await lifecycle.claim.mutateAsync(taskId);
toast.success("Task claimed");
break;
case TaskStatus.CLAIMED:
await lifecycle.start.mutateAsync(taskId);
toast.success("Task started");
break;
case TaskStatus.IN_PROGRESS:
await lifecycle.submitQa.mutateAsync({ taskId });
toast.success("Submitted for QA");
break;
case TaskStatus.BLOCKED:
await lifecycle.unblock.mutateAsync(taskId);
toast.success("Task unblocked");
break;
case TaskStatus.AWAITING_QA:
setPendingNotesAction({ kind: "pass-qa", taskId });
return; // Dialog collects the required note
case TaskStatus.AWAITING_DOCUMENTATION:
setPendingNotesAction({ kind: "complete", taskId });
return; // Dialog collects the required justification
}
break;
case "pass-qa":
setPendingNotesAction({ kind: "pass-qa", taskId });
return; // Dialog collects the required note
case "fail-qa":
setPendingNotesAction({ kind: "fail-qa", taskId });
return; // Dialog collects the required note
switch (task.status) {
case TaskStatus.BACKLOG:
// BACKLOG tasks need session before activation - PM only
toast.info(
"Backlog tasks must be activated by PM with a session",
);
break;
case TaskStatus.PENDING:
await claimMutateAsync(taskId);
toast.success("Task claimed");
break;
case TaskStatus.CLAIMED:
await startMutateAsync(taskId);
toast.success("Task started");
break;
case TaskStatus.IN_PROGRESS:
await submitQaMutateAsync({ taskId });
toast.success("Submitted for QA");
break;
case TaskStatus.BLOCKED:
await unblockMutateAsync(taskId);
toast.success("Task unblocked");
break;
case TaskStatus.AWAITING_QA:
setPendingNotesAction({ kind: "pass-qa", taskId });
return; // Dialog collects the required note
case TaskStatus.AWAITING_DOCUMENTATION:
setPendingNotesAction({ kind: "complete", taskId });
return; // Dialog collects the required justification
}
break;
case "pass-qa":
setPendingNotesAction({ kind: "pass-qa", taskId });
return; // Dialog collects the required note
case "fail-qa":
setPendingNotesAction({ kind: "fail-qa", taskId });
return; // Dialog collects the required note
}
refetch();
} catch {
toast.error("Action failed");
}
refetch();
} catch {
toast.error("Action failed");
}
};
},
[
claimMutateAsync,
startMutateAsync,
submitQaMutateAsync,
unblockMutateAsync,
refetch,
],
);
const handleNotesConfirm = async (text: string) => {
if (!pendingNotesAction) return;
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { memo, useState } from "react";
import { Task, TaskStatus } from "@/types";
import { useUpdateTask } from "@/hooks/use-tasks";
import { Card, CardContent } from "@/components/ui/card";
@@ -44,7 +44,12 @@ interface KanbanCardProps {
isDragging?: boolean;
}
export function KanbanCard({
// Memoized: a kanban column can mount hundreds of these across all statuses.
// `task` stays referentially stable across React Query refetches for
// unchanged rows (structural sharing), and `onAction` is stabilized by the
// board — so the default shallow prop comparison actually skips re-renders
// instead of every card re-rendering on any board-level state change.
function KanbanCardImpl({
task,
onAction,
showQaActions,
@@ -302,3 +307,5 @@ export function KanbanCard({
</Card>
);
}
export const KanbanCard = memo(KanbanCardImpl);
@@ -1,5 +1,6 @@
"use client";
import { memo, useRef } from "react";
import { Task, TaskStatus } from "@/types";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
@@ -12,8 +13,14 @@ import { HelpTip } from "@/components/ui/help-tip";
import { taskStatusDescription } from "@/components/tasks/task-status-badge";
import { KanbanCard } from "./kanban-card";
import { useDroppable } from "@dnd-kit/core";
import { useVirtualizer } from "@tanstack/react-virtual";
import { cn } from "@/lib/utils";
// Rough collapsed-card height (title + badges + assign row) used as the
// virtualizer's initial estimate — corrected per-card via measureElement
// once mounted, so a wrong guess only costs one extra frame of scroll jitter.
const ESTIMATED_CARD_HEIGHT = 132;
interface KanbanColumnProps {
id: string;
title: string;
@@ -27,7 +34,11 @@ interface KanbanColumnProps {
className?: string;
}
export function KanbanColumn({
// Memoized: KanbanBoard renders one of these per lifecycle-status column, and
// a column's `tasks` slice is stabilized upstream (kanban-board.tsx's
// tasksByStatus useMemo) so an unrelated board re-render (dialogs, drag
// state) skips every column that didn't actually change.
function KanbanColumnImpl({
id: _id,
title,
status,
@@ -42,6 +53,20 @@ export function KanbanColumn({
const { setNodeRef, isOver } = useDroppable({
id: status,
});
const scrollRef = useRef<HTMLDivElement>(null);
// A column can carry hundreds of cards (every task in a status, across a
// whole team's history) — window the DOM to roughly what's on screen
// instead of mounting every dnd-kit draggable card at once. Safe with
// dnd-kit here because the drop target is the column itself
// (useDroppable above), not per-card — cards only register useDraggable,
// no sortable reordering depends on every card's DOM node existing.
const rowVirtualizer = useVirtualizer({
count: tasks.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ESTIMATED_CARD_HEIGHT,
overscan: 6,
getItemKey: (index) => tasks[index]?.id ?? index,
});
return (
<div
@@ -88,8 +113,9 @@ export function KanbanColumn({
</div>
{/* Native overflow scroll: Radix ScrollArea's display:table viewport
let cards grow past the column width and clip — a plain div keeps
content constrained to the column. */}
<div className="flex-1 min-h-0 overflow-y-auto">
content constrained to the column. Also the virtualizer's scroll
container. */}
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto pr-2">
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-24" />
@@ -101,22 +127,37 @@ export function KanbanColumn({
No tasks
</div>
) : (
<div className="space-y-2 pr-2">
{tasks.map((task) => (
<KanbanCard
key={task.id}
task={task}
onAction={onAction}
showQaActions={
showQaActions &&
(status === TaskStatus.AWAITING_QA ||
status === TaskStatus.VERIFYING)
}
/>
))}
<div
className="relative w-full"
style={{ height: rowVirtualizer.getTotalSize() }}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const task = tasks[virtualRow.index];
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<KanbanCard
task={task}
onAction={onAction}
showQaActions={
showQaActions &&
(status === TaskStatus.AWAITING_QA ||
status === TaskStatus.VERIFYING)
}
/>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
export const KanbanColumn = memo(KanbanColumnImpl);
@@ -14,7 +14,7 @@ const { mockOrg, mockCeo, mockMember, mockAgents } = vi.hoisted(() => ({
vi.mock("@/hooks/use-observability", () => ({
useOrgScorecard: mockOrg,
useCeoScorecard: mockCeo,
useMemberScorecard: mockMember,
useAllMemberScorecards: mockMember,
}));
vi.mock("@/hooks/use-agents", () => ({
@@ -96,18 +96,21 @@ describe("ScorecardsTabContent", () => {
isLoading: false,
});
mockMember.mockReturnValue({
data: member({
id: "dev1",
name: "be-dev-1",
tasks_completed: 7,
first_pass_yield: 0.8,
active_runtime_hours: 4.2,
qa_pass_rate: 0.9,
escalations: 1,
blocked_others: 2,
utilization: 0.6,
}),
data: [
member({
id: "dev1",
name: "be-dev-1",
tasks_completed: 7,
first_pass_yield: 0.8,
active_runtime_hours: 4.2,
qa_pass_rate: 0.9,
escalations: 1,
blocked_others: 2,
utilization: 0.6,
}),
],
isLoading: false,
isError: false,
});
mockAgents.mockReturnValue({
data: [
@@ -164,8 +167,10 @@ describe("ScorecardsTabContent", () => {
expect(
screen.getByText(/failed to load organization metrics/i),
).toBeInTheDocument();
// The member row shows a failed marker rather than a perpetual skeleton
// (exact lowercase text, distinct from the org card's message).
expect(screen.getByText("failed to load")).toBeInTheDocument();
// The batched member-scorecard fetch surfaces one table-level banner
// rather than a per-row error (or an endless per-row skeleton).
expect(
screen.getByText(/failed to load member scorecards/i),
).toBeInTheDocument();
});
});
+27 -17
View File
@@ -1,5 +1,6 @@
"use client";
import { useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
@@ -13,12 +14,12 @@ import {
TableRow,
} from "@/components/ui/table";
import {
useAllMemberScorecards,
useCeoScorecard,
useMemberScorecard,
useOrgScorecard,
} from "@/hooks/use-observability";
import { useAgents } from "@/hooks/use-agents";
import { AgentRole, type Agent } from "@/types";
import { AgentRole, type Agent, type MemberScorecard } from "@/types";
function pctOrNa(rate: number | null): string {
return rate === null ? "n/a" : (rate * 100).toFixed(0) + "%";
@@ -32,20 +33,18 @@ function hoursOrDash(seconds: number): string {
return (seconds / 3600).toFixed(1) + "h";
}
/** One member's row — each row self-fetches its rollup scorecard. */
function MemberRow({ agent }: { agent: Agent }) {
const { data, isLoading, isError } = useMemberScorecard(agent.id);
if (isError) {
return (
<TableRow>
<TableCell>{agent.name || agent.slug}</TableCell>
<TableCell colSpan={8} className="text-muted-foreground text-xs">
failed to load
</TableCell>
</TableRow>
);
}
if (isLoading || !data) {
/** One member's row. Data comes from the batched useAllMemberScorecards
* fetch (one request for the whole table) rather than each row self-fetching
* — ~20 agents used to mean ~20 parallel `/metrics/member/{id}` requests
* (each 3 DB queries) on every table poll. */
function MemberRow({
agent,
data,
}: {
agent: Agent;
data: MemberScorecard | undefined;
}) {
if (!data) {
return (
<TableRow>
<TableCell>{agent.name || agent.slug}</TableCell>
@@ -172,6 +171,12 @@ export function ScorecardsTabContent() {
const members = (agents ?? []).filter(
(a) => a.role !== AgentRole.CEO && a.role !== AgentRole.SYSTEM,
);
const { data: scorecards, isError: scorecardsError } =
useAllMemberScorecards();
const scorecardById = useMemo(
() => new Map((scorecards ?? []).map((s) => [s.id, s])),
[scorecards],
);
return (
<div className="space-y-6">
<Card>
@@ -197,6 +202,11 @@ export function ScorecardsTabContent() {
<CardTitle>Members</CardTitle>
</CardHeader>
<CardContent>
{scorecardsError && (
<p className="text-muted-foreground mb-2 text-sm">
Failed to load member scorecards.
</p>
)}
<Table>
<TableHeader>
<TableRow>
@@ -245,7 +255,7 @@ export function ScorecardsTabContent() {
</TableHeader>
<TableBody>
{members.map((a) => (
<MemberRow key={a.id} agent={a} />
<MemberRow key={a.id} agent={a} data={scorecardById.get(a.id)} />
))}
</TableBody>
</Table>
+357 -316
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { useState, useMemo, useCallback, useEffect, memo } from "react";
import { Task } from "@/types";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -262,6 +262,319 @@ function SortableHeader({
);
}
interface TaskRowProps {
node: TaskTreeNode;
isExpanded: boolean;
childCount: number;
projectNames: Record<string, string>;
projectGitUrls: Record<string, string>;
productNames: Record<string, string>;
onToggleExpand: (taskId: string) => void;
}
// Memoized: a page renders up to 100 of these (PAGE_SIZE_OPTIONS caps there)
// and re-renders on every sort/page/expand change or unrelated parent update
// — memoizing stops an unchanged row's markup from being torn down and
// rebuilt every time. `node` stays referentially stable across React Query
// refetches for unchanged tasks (structural sharing); projectNames/
// projectGitUrls/productNames/onToggleExpand are stabilized by the caller.
const TaskTableRow = memo(function TaskTableRow({
node,
isExpanded,
childCount,
projectNames,
projectGitUrls,
productNames,
onToggleExpand,
}: TaskRowProps) {
const task = node.task;
const hasChildren = node.children.length > 0;
const handleRowClick = (e: React.MouseEvent) => {
// Don't toggle if clicking on interactive elements
const target = e.target as HTMLElement;
if (
target.closest("a") ||
target.closest("button") ||
target.closest('[role="button"]') ||
target.closest("[data-no-expand]")
) {
return;
}
if (hasChildren) {
onToggleExpand(task.id);
}
};
return (
<TableRow
className={cn(
"hover:bg-muted/50",
node.depth > 0 && "bg-muted/20",
hasChildren && "cursor-pointer",
)}
onClick={handleRowClick}
>
<TableCell className="max-w-[22rem]">
<div
className="flex items-center gap-1 min-w-0"
style={{ paddingLeft: `${node.depth * 1.5}rem` }}
>
{hasChildren ? (
<HelpTip
label={
isExpanded
? "Hides this task's subtasks"
: "Shows this task's subtasks inline"
}
>
<Button
onClick={() => onToggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="p-0.5 h-5 w-5 shrink-0"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
</HelpTip>
) : (
<span className="w-5 shrink-0" />
)}
<Link
prefetch={false}
href={"/tasks/" + task.id}
className="block hover:underline min-w-0"
>
<div className="font-medium flex items-center gap-2 min-w-0">
<span className="truncate" title={task.title}>
{task.title}
</span>
{task.batch_id && !task.parent_task_id && (
<HelpTip label="A multi-task batch — this is the umbrella task for a set of related tasks">
<Badge
variant="outline"
className="text-xs shrink-0 border-primary/50 text-primary"
>
MegaTask
</Badge>
</HelpTip>
)}
{childCount > 0 && (
<HelpTip label="Direct subtasks under this task, regardless of their current status.">
<Badge variant="secondary" className="text-xs shrink-0">
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
</HelpTip>
)}
</div>
</Link>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<TaskStatusBadge status={task.status} />
</TableCell>
<TableCell className="whitespace-nowrap">
<GitStatusBadge
task={task}
repoUrl={
task.project_id ? projectGitUrls[task.project_id] : undefined
}
/>
</TableCell>
<TableCell className="capitalize whitespace-nowrap">
{task.team.replace(/_/g, " ")}
</TableCell>
<TableCell className="whitespace-nowrap text-sm">
{task.project_id && projectNames[task.project_id] ? (
<span>{projectNames[task.project_id]}</span>
) : task.product_id && productNames[task.product_id] ? (
<span className="text-muted-foreground">
{productNames[task.product_id]}{" "}
<span className="text-xs">(product)</span>
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
className={
(priorityColors[task.priority] ?? priorityColors[2]) + " text-xs"
}
>
{priorityLabels[task.priority] ?? "P2 - Medium"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">
<HelpTip
label={
task.assigned_to
? "The agent currently pinned to this task."
: "No agent pinned — the orchestrator routes it by role and availability."
}
>
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
</HelpTip>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
<HelpTip label={new Date(task.created_at).toLocaleString()}>
<span>
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
</span>
</HelpTip>
</TableCell>
<TableCell>
<TaskActions task={task} />
</TableCell>
</TableRow>
);
});
// Mobile card counterpart of TaskTableRow — same memoization rationale.
const TaskTableCard = memo(function TaskTableCard({
node,
isExpanded,
childCount,
projectNames,
projectGitUrls,
productNames,
onToggleExpand,
}: TaskRowProps) {
const task = node.task;
const hasChildren = node.children.length > 0;
return (
<ResponsiveTableCard style={{ marginLeft: `${node.depth * 1}rem` }}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{hasChildren && (
<HelpTip
label={
isExpanded
? "Hides this task's subtasks"
: "Shows this task's subtasks inline"
}
>
<Button
onClick={() => onToggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="h-5 w-5 shrink-0 p-0.5"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
</HelpTip>
)}
<Link
prefetch={false}
href={"/tasks/" + task.id}
className="min-w-0 truncate font-medium hover:underline"
title={task.title}
>
{task.title}
</Link>
</div>
{(task.batch_id && !task.parent_task_id) || childCount > 0 ? (
<div className="mt-1 flex flex-wrap gap-1">
{task.batch_id && !task.parent_task_id && (
<HelpTip label="A multi-task batch — this is the umbrella task for a set of related tasks">
<Badge
variant="outline"
className="border-primary/50 text-xs text-primary"
>
MegaTask
</Badge>
</HelpTip>
)}
{childCount > 0 && (
<HelpTip label="Direct subtasks under this task, regardless of their current status.">
<Badge variant="secondary" className="text-xs">
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
</HelpTip>
)}
</div>
) : null}
</div>
<TaskActions task={task} />
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Status">
<TaskStatusBadge status={task.status} />
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Git">
<GitStatusBadge
task={task}
repoUrl={
task.project_id ? projectGitUrls[task.project_id] : undefined
}
/>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Team">
<span className="capitalize">{task.team.replace(/_/g, " ")}</span>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Project">
{task.project_id && projectNames[task.project_id]
? projectNames[task.project_id]
: task.product_id && productNames[task.product_id]
? `${productNames[task.product_id]} (product)`
: "—"}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Priority">
<Badge
className={
(priorityColors[task.priority] ?? priorityColors[2]) +
" text-xs"
}
>
{priorityLabels[task.priority] ?? "P2 - Medium"}
</Badge>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Assigned">
<HelpTip
label={
task.assigned_to
? "The agent currently pinned to this task."
: "No agent pinned — the orchestrator routes it by role and availability."
}
>
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
</HelpTip>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Created">
<HelpTip label={new Date(task.created_at).toLocaleString()}>
<span>
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
</span>
</HelpTip>
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
);
});
export function TaskTable({
tasks,
isLoading,
@@ -431,19 +744,25 @@ export function TaskTable({
}
};
const toggleExpand = (taskId: string) => {
const next = new Set(expandedIds);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
if (onExpandedChange) {
onExpandedChange(next);
} else {
setInternalExpandedIds(next);
}
};
// Stable across renders that don't touch expand state, so TaskTableRow's
// memoization actually holds on an unrelated re-render (e.g. a sibling
// page-chrome state change) instead of every row rebuilding regardless.
const toggleExpand = useCallback(
(taskId: string) => {
const next = new Set(expandedIds);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
if (onExpandedChange) {
onExpandedChange(next);
} else {
setInternalExpandedIds(next);
}
},
[expandedIds, onExpandedChange],
);
const expandAll = () => {
const allParentIds = new Set<string>();
@@ -563,171 +882,18 @@ export function TaskTable({
) : paginatedTasks.length === 0 ? (
<TaskTableEmpty />
) : (
paginatedTasks.map((node) => {
const task = node.task;
const hasChildren = node.children.length > 0;
const isExpanded = expandedIds.has(task.id);
const childCount = childrenMap.get(task.id)?.length || 0;
const handleRowClick = (e: React.MouseEvent) => {
// Don't toggle if clicking on interactive elements
const target = e.target as HTMLElement;
if (
target.closest("a") ||
target.closest("button") ||
target.closest('[role="button"]') ||
target.closest("[data-no-expand]")
) {
return;
}
if (hasChildren) {
toggleExpand(task.id);
}
};
return (
<TableRow
key={task.id}
className={cn(
"hover:bg-muted/50",
node.depth > 0 && "bg-muted/20",
hasChildren && "cursor-pointer",
)}
onClick={handleRowClick}
>
<TableCell className="max-w-[22rem]">
<div
className="flex items-center gap-1 min-w-0"
style={{ paddingLeft: `${node.depth * 1.5}rem` }}
>
{hasChildren ? (
<HelpTip
label={
isExpanded
? "Hides this task's subtasks"
: "Shows this task's subtasks inline"
}
>
<Button
onClick={() => toggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="p-0.5 h-5 w-5 shrink-0"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
</HelpTip>
) : (
<span className="w-5 shrink-0" />
)}
<Link
prefetch={false}
href={"/tasks/" + task.id}
className="block hover:underline min-w-0"
>
<div className="font-medium flex items-center gap-2 min-w-0">
<span className="truncate" title={task.title}>
{task.title}
</span>
{task.batch_id && !task.parent_task_id && (
<HelpTip label="A multi-task batch — this is the umbrella task for a set of related tasks">
<Badge
variant="outline"
className="text-xs shrink-0 border-primary/50 text-primary"
>
MegaTask
</Badge>
</HelpTip>
)}
{childCount > 0 && (
<HelpTip label="Direct subtasks under this task, regardless of their current status.">
<Badge
variant="secondary"
className="text-xs shrink-0"
>
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
</HelpTip>
)}
</div>
</Link>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<TaskStatusBadge status={task.status} />
</TableCell>
<TableCell className="whitespace-nowrap">
<GitStatusBadge
task={task}
repoUrl={
task.project_id
? projectGitUrls[task.project_id]
: undefined
}
/>
</TableCell>
<TableCell className="capitalize whitespace-nowrap">
{task.team.replace(/_/g, " ")}
</TableCell>
<TableCell className="whitespace-nowrap text-sm">
{task.project_id && projectNames[task.project_id] ? (
<span>{projectNames[task.project_id]}</span>
) : task.product_id &&
productNames[task.product_id] ? (
<span className="text-muted-foreground">
{productNames[task.product_id]}{" "}
<span className="text-xs">(product)</span>
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
className={
(priorityColors[task.priority] ??
priorityColors[2]) + " text-xs"
}
>
{priorityLabels[task.priority] ?? "P2 - Medium"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">
<HelpTip
label={
task.assigned_to
? "The agent currently pinned to this task."
: "No agent pinned — the orchestrator routes it by role and availability."
}
>
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
</HelpTip>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
<HelpTip
label={new Date(task.created_at).toLocaleString()}
>
<span>
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
</span>
</HelpTip>
</TableCell>
<TableCell>
<TaskActions task={task} />
</TableCell>
</TableRow>
);
})
paginatedTasks.map((node) => (
<TaskTableRow
key={node.task.id}
node={node}
isExpanded={expandedIds.has(node.task.id)}
childCount={childrenMap.get(node.task.id)?.length || 0}
projectNames={projectNames}
projectGitUrls={projectGitUrls}
productNames={productNames}
onToggleExpand={toggleExpand}
/>
))
)}
</TableBody>
</Table>
@@ -741,143 +907,18 @@ export function TaskTable({
</ResponsiveTableCardEmpty>
) : (
<ResponsiveTableCardList className="p-3">
{paginatedTasks.map((node) => {
const task = node.task;
const hasChildren = node.children.length > 0;
const isExpanded = expandedIds.has(task.id);
const childCount = childrenMap.get(task.id)?.length || 0;
return (
<ResponsiveTableCard
key={task.id}
style={{ marginLeft: `${node.depth * 1}rem` }}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{hasChildren && (
<HelpTip
label={
isExpanded
? "Hides this task's subtasks"
: "Shows this task's subtasks inline"
}
>
<Button
onClick={() => toggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="h-5 w-5 shrink-0 p-0.5"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
</HelpTip>
)}
<Link
prefetch={false}
href={"/tasks/" + task.id}
className="min-w-0 truncate font-medium hover:underline"
title={task.title}
>
{task.title}
</Link>
</div>
{(task.batch_id && !task.parent_task_id) ||
childCount > 0 ? (
<div className="mt-1 flex flex-wrap gap-1">
{task.batch_id && !task.parent_task_id && (
<HelpTip label="A multi-task batch — this is the umbrella task for a set of related tasks">
<Badge
variant="outline"
className="border-primary/50 text-xs text-primary"
>
MegaTask
</Badge>
</HelpTip>
)}
{childCount > 0 && (
<HelpTip label="Direct subtasks under this task, regardless of their current status.">
<Badge variant="secondary" className="text-xs">
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
</HelpTip>
)}
</div>
) : null}
</div>
<TaskActions task={task} />
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Status">
<TaskStatusBadge status={task.status} />
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Git">
<GitStatusBadge
task={task}
repoUrl={
task.project_id
? projectGitUrls[task.project_id]
: undefined
}
/>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Team">
<span className="capitalize">
{task.team.replace(/_/g, " ")}
</span>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Project">
{task.project_id && projectNames[task.project_id]
? projectNames[task.project_id]
: task.product_id && productNames[task.product_id]
? `${productNames[task.product_id]} (product)`
: "—"}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Priority">
<Badge
className={
(priorityColors[task.priority] ??
priorityColors[2]) + " text-xs"
}
>
{priorityLabels[task.priority] ?? "P2 - Medium"}
</Badge>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Assigned">
<HelpTip
label={
task.assigned_to
? "The agent currently pinned to this task."
: "No agent pinned — the orchestrator routes it by role and availability."
}
>
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
</HelpTip>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Created">
<HelpTip
label={new Date(task.created_at).toLocaleString()}
>
<span>
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
</span>
</HelpTip>
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
);
})}
{paginatedTasks.map((node) => (
<TaskTableCard
key={node.task.id}
node={node}
isExpanded={expandedIds.has(node.task.id)}
childCount={childrenMap.get(node.task.id)?.length || 0}
projectNames={projectNames}
projectGitUrls={projectGitUrls}
productNames={productNames}
onToggleExpand={toggleExpand}
/>
))}
</ResponsiveTableCardList>
)
}
+18
View File
@@ -31,6 +31,14 @@ export const observabilityKeys = {
[...observabilityKeys.all, "scorecard", "ceo", days] as const,
memberScorecard: (agentId: string, days: number) =>
[...observabilityKeys.all, "scorecard", "member", agentId, days] as const,
allMemberScorecards: (days: number, team?: string) =>
[
...observabilityKeys.all,
"scorecard",
"members",
team ?? "all",
days,
] as const,
orgScorecard: (days: number, team?: string) =>
[
...observabilityKeys.all,
@@ -115,6 +123,16 @@ export function useMemberScorecard(agentId: string, days = 30) {
});
}
/** Every agent's rollup scorecard in one batch replaces the N+1 pattern of
* calling useMemberScorecard once per row in a member list/table. */
export function useAllMemberScorecards(days = 30, team?: string) {
return useQuery<MemberScorecard[]>({
queryKey: observabilityKeys.allMemberScorecards(days, team),
queryFn: () => observabilityApi.getAllMemberScorecards(days, team),
refetchInterval: SCORECARD_REFETCH_INTERVAL,
});
}
/** Org-wide (or per-cell) rollup aggregate. */
export function useOrgScorecard(days = 30, team?: string) {
return useQuery<OrgScorecard>({
+14
View File
@@ -167,6 +167,20 @@ export const observabilityApi = {
return data;
},
/** Every agent's rollup scorecard in one batch (N+1 fix for the panel's
* Members table) GET /dashboard/metrics/members?team&days */
getAllMemberScorecards: async (
days = 30,
team?: string,
): Promise<MemberScorecard[]> => {
if (isMockMode()) return [];
const { data } = await api.get<MemberScorecard[]>(
"/dashboard/metrics/members",
{ params: { days, ...(team ? { team } : {}) } },
);
return data;
},
/** Org / team rollup — GET /dashboard/metrics/org?team&days */
getOrgScorecard: async (days = 30, team?: string): Promise<OrgScorecard> => {
if (isMockMode()) return emptyOrg(team ?? null);