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:
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} → {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 ? <> → 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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user