feat(review-panel): split reviews by author — board / human / manager

One flat list of `status = in_review` tasks couldn't tell the
operator whether the next decision belonged to the board (approval
pending), to the human user (top-level assignee or explicit
`requires_user_review`), or to a middle manager walking up the
`reports_to` chain. Three very different escalation paths rendered
identically, so "reviewed by X" was the only signal and operators
missed the ones actually waiting on them.

`PendingReviewList` now classifies each in-review task into one of
three kinds (`board` / `user` / `manager`) using the pending
approval set plus the existing assignee/manager lookup. Rendering
groups them under labelled sub-headings in priority order (board
first — the operator is the bottleneck there), each with its own
awaiting-text so the row's status is unambiguous at a glance.

The component accepts approvals via prop; the caller passes the
existing `pendingApprovals` it already holds. Translations for both
locales carry the new group labels.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 01:24:01 -04:00
1 parent d47e637a45
commit 0746664f72
4 files changed
+131 -18

No files matched your search

@@ -1,27 +1,130 @@
import { Robot } from "../ui/icons"
import type { AgentRecord, TaskRecord } from "@shared/types"
import { useT } from "../../i18n"
import type { AgentRecord, ApprovalRecord, TaskRecord } from "@shared/types"
import { useT, type TranslationFn } from "../../i18n"
import { EmptyState, ListFrame } from "../ui"
export function PendingReviewList({ tasks, agents }: { tasks: TaskRecord[]; agents: AgentRecord[] }) {
interface PendingReviewListProps {
tasks: TaskRecord[]
agents: AgentRecord[]
approvals: ApprovalRecord[]
}
type ReviewKind = "board" | "user" | "manager"
interface ReviewRow {
task: TaskRecord
assignee: AgentRecord | null
reviewer: AgentRecord | null
kind: ReviewKind
}
function classifyReview(
task: TaskRecord,
agents: AgentRecord[],
pendingApprovalTaskIds: Set<string>,
): ReviewRow {
const assignee = agents.find((a) => a.id === task.assigneeAgentId) ?? null
if (pendingApprovalTaskIds.has(task.id)) {
return { task, assignee, reviewer: null, kind: "board" }
}
if (task.requiresUserReview) {
return { task, assignee, reviewer: null, kind: "user" }
}
if (!assignee) {
return { task, assignee: null, reviewer: null, kind: "user" }
}
if (!assignee.reportsTo) {
return { task, assignee, reviewer: null, kind: "user" }
}
const reviewer = agents.find((a) => a.id === assignee.reportsTo) ?? null
return { task, assignee, reviewer, kind: "manager" }
}
function groupTitle(kind: ReviewKind, t: TranslationFn): string {
if (kind === "board") return t("exec.reviewGroupBoard")
if (kind === "user") return t("exec.reviewGroupUser")
return t("exec.reviewGroupManager")
}
function renderReviewerLine(row: ReviewRow, t: TranslationFn): React.ReactNode {
const assigneeName = row.assignee?.name ?? "Unassigned"
if (row.kind === "board") {
return (
<>
{assigneeName} · <span className="text-warning">{t("exec.reviewAwaitsBoard")}</span>
</>
)
}
if (row.kind === "user") {
return (
<>
{assigneeName} · <span className="text-warning">{t("exec.reviewAwaitsUser")}</span>
</>
)
}
const reviewer = row.reviewer
if (!reviewer) {
return <>{assigneeName} · {t("exec.awaitingReview")}</>
}
return (
<>
{assigneeName} &rarr; {t("exec.reviewedBy")}{" "}
<span className="text-muted-foreground">
{reviewer.name}
{reviewer.title ? ` (${reviewer.title})` : ""}
</span>
</>
)
}
function ReviewGroup({ title, rows, t }: { title: string; rows: ReviewRow[]; t: TranslationFn }) {
return (
<div>
<div className="px-4 pt-3 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{title}
</div>
{rows.map((row) => (
<div key={row.task.id} className="flex items-start gap-3 border-b border-border px-4 py-3 last:border-b-0">
<Robot size={16} weight="fill" className="mt-0.5 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{row.task.title}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">{renderReviewerLine(row, t)}</div>
</div>
</div>
))}
</div>
)
}
/**
* Pending reviews separated into three visually distinct groups so
* Board / Human / Manager review semantics do not collapse into one
* faceless list. Priority order (board > user > manager) lines up
* with who is actually being asked to act next.
*/
export function PendingReviewList({ tasks, agents, approvals }: PendingReviewListProps) {
const t = useT()
const reviewTasks = tasks.filter((tk) => tk.status === "in_review")
if (reviewTasks.length === 0) return <EmptyState title={t("exec.noReviewsInProgress")} detail={t("exec.noReviewsDetail")} />
if (reviewTasks.length === 0) {
return <EmptyState title={t("exec.noReviewsInProgress")} detail={t("exec.noReviewsDetail")} />
}
const pendingApprovalTaskIds = new Set(
approvals
.filter((approval) => approval.state === "pending" || approval.state === "revision_requested")
.map((approval) => approval.relatedTaskId)
.filter((taskId): taskId is string => Boolean(taskId)),
)
const rows = reviewTasks.map((task) => classifyReview(task, agents, pendingApprovalTaskIds))
const boardRows = rows.filter((r) => r.kind === "board")
const userRows = rows.filter((r) => r.kind === "user")
const managerRows = rows.filter((r) => r.kind === "manager")
return (
<ListFrame>
{reviewTasks.map((task) => {
const assignee = agents.find((a) => a.id === task.assigneeAgentId) ?? null
const reviewer = assignee?.reportsTo ? (agents.find((a) => a.id === assignee.reportsTo) ?? null) : null
return (
<div key={task.id} className="flex items-start gap-3 border-b border-border px-4 py-3 last:border-b-0">
<Robot size={16} weight="fill" className="mt-0.5 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{task.title}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">{assignee ? assignee.name : "Unassigned"}{reviewer ? <> &rarr; reviewed by <span className="text-muted-foreground">{reviewer.name}{reviewer.title ? ` (${reviewer.title})` : ""}</span></> : " · awaiting review"}</div>
</div>
</div>
)
})}
{boardRows.length > 0 ? <ReviewGroup title={t("exec.reviewGroupBoard")} rows={boardRows} t={t} /> : null}
{userRows.length > 0 ? <ReviewGroup title={groupTitle("user", t)} rows={userRows} t={t} /> : null}
{managerRows.length > 0 ? <ReviewGroup title={groupTitle("manager", t)} rows={managerRows} t={t} /> : null}
</ListFrame>
)
}