feat(approvals): enhance approval model with task titles and confidence as float

This commit is contained in:
Abhimanyu Saharan
2026-02-12 19:57:04 +05:30
parent 8bd606a8dc
commit 032b77afb8
13 changed files with 370 additions and 40 deletions

View File

@@ -26,6 +26,7 @@ from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session from app.db.session import async_session_maker, get_session
from app.models.agents import Agent from app.models.agents import Agent
from app.models.approvals import Approval from app.models.approvals import Approval
from app.models.tasks import Task
from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalStatus, ApprovalUpdate from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalStatus, ApprovalUpdate
from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.activity_log import record_activity from app.services.activity_log import record_activity
@@ -96,10 +97,36 @@ async def _approval_task_ids_map(
return mapping return mapping
def _approval_to_read(approval: Approval, *, task_ids: list[UUID]) -> ApprovalRead: async def _task_titles_by_id(
session: AsyncSession,
*,
task_ids: set[UUID],
) -> dict[UUID, str]:
if not task_ids:
return {}
rows = list(
await session.exec(
select(col(Task.id), col(Task.title)).where(col(Task.id).in_(task_ids)),
),
)
return {task_id: title for task_id, title in rows}
def _approval_to_read(
approval: Approval,
*,
task_ids: list[UUID],
task_titles: list[str],
) -> ApprovalRead:
primary_task_id = task_ids[0] if task_ids else None primary_task_id = task_ids[0] if task_ids else None
model = ApprovalRead.model_validate(approval, from_attributes=True) model = ApprovalRead.model_validate(approval, from_attributes=True)
return model.model_copy(update={"task_id": primary_task_id, "task_ids": task_ids}) return model.model_copy(
update={
"task_id": primary_task_id,
"task_ids": task_ids,
"task_titles": task_titles,
},
)
async def _approval_reads( async def _approval_reads(
@@ -107,8 +134,17 @@ async def _approval_reads(
approvals: Sequence[Approval], approvals: Sequence[Approval],
) -> list[ApprovalRead]: ) -> list[ApprovalRead]:
mapping = await _approval_task_ids_map(session, approvals) mapping = await _approval_task_ids_map(session, approvals)
title_by_id = await _task_titles_by_id(
session,
task_ids={task_id for task_ids in mapping.values() for task_id in task_ids},
)
return [ return [
_approval_to_read(approval, task_ids=mapping.get(approval.id, [])) for approval in approvals _approval_to_read(
approval,
task_ids=(task_ids := mapping.get(approval.id, [])),
task_titles=[title_by_id[task_id] for task_id in task_ids if task_id in title_by_id],
)
for approval in approvals
] ]
@@ -389,7 +425,12 @@ async def create_approval(
) )
await session.commit() await session.commit()
await session.refresh(approval) await session.refresh(approval)
return _approval_to_read(approval, task_ids=task_ids) title_by_id = await _task_titles_by_id(session, task_ids=set(task_ids))
return _approval_to_read(
approval,
task_ids=task_ids,
task_titles=[title_by_id[task_id] for task_id in task_ids if task_id in title_by_id],
)
@router.patch("/{approval_id}", response_model=ApprovalRead) @router.patch("/{approval_id}", response_model=ApprovalRead)

View File

@@ -250,9 +250,7 @@ async def _query_wip(
if not board_ids: if not board_ids:
return _wip_series_from_mapping(range_spec, {}) return _wip_series_from_mapping(range_spec, {})
inbox_bucket_col = func.date_trunc(range_spec.bucket, Task.created_at).label( inbox_bucket_col = func.date_trunc(range_spec.bucket, Task.created_at).label("inbox_bucket")
"inbox_bucket"
)
inbox_statement = ( inbox_statement = (
select(inbox_bucket_col, func.count()) select(inbox_bucket_col, func.count())
.where(col(Task.status) == "inbox") .where(col(Task.status) == "inbox")
@@ -264,9 +262,7 @@ async def _query_wip(
) )
inbox_results = (await session.exec(inbox_statement)).all() inbox_results = (await session.exec(inbox_statement)).all()
status_bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label( status_bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("status_bucket")
"status_bucket"
)
progress_case = case((col(Task.status) == "in_progress", 1), else_=0) progress_case = case((col(Task.status) == "in_progress", 1), else_=0)
review_case = case((col(Task.status) == "review", 1), else_=0) review_case = case((col(Task.status) == "review", 1), else_=0)
done_case = case((col(Task.status) == "done", 1), else_=0) done_case = case((col(Task.status) == "done", 1), else_=0)

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import JSON, Column from sqlalchemy import JSON, Column, Float
from sqlmodel import Field from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
@@ -25,7 +25,7 @@ class Approval(QueryModel, table=True):
agent_id: UUID | None = Field(default=None, foreign_key="agents.id", index=True) agent_id: UUID | None = Field(default=None, foreign_key="agents.id", index=True)
action_type: str action_type: str
payload: dict[str, object] | None = Field(default=None, sa_column=Column(JSON)) payload: dict[str, object] | None = Field(default=None, sa_column=Column(JSON))
confidence: int confidence: float = Field(sa_column=Column(Float, nullable=False))
rubric_scores: dict[str, int] | None = Field(default=None, sa_column=Column(JSON)) rubric_scores: dict[str, int] | None = Field(default=None, sa_column=Column(JSON))
status: str = Field(default="pending", index=True) status: str = Field(default="pending", index=True)
created_at: datetime = Field(default_factory=utcnow) created_at: datetime = Field(default_factory=utcnow)

View File

@@ -11,6 +11,7 @@ from sqlmodel import Field, SQLModel
ApprovalStatus = Literal["pending", "approved", "rejected"] ApprovalStatus = Literal["pending", "approved", "rejected"]
STATUS_REQUIRED_ERROR = "status is required" STATUS_REQUIRED_ERROR = "status is required"
LEAD_REASONING_REQUIRED_ERROR = "lead reasoning is required"
RUNTIME_ANNOTATION_TYPES = (datetime, UUID) RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
@@ -21,7 +22,7 @@ class ApprovalBase(SQLModel):
task_id: UUID | None = None task_id: UUID | None = None
task_ids: list[UUID] = Field(default_factory=list) task_ids: list[UUID] = Field(default_factory=list)
payload: dict[str, object] | None = None payload: dict[str, object] | None = None
confidence: int confidence: float = Field(ge=0, le=100)
rubric_scores: dict[str, int] | None = None rubric_scores: dict[str, int] | None = None
status: ApprovalStatus = "pending" status: ApprovalStatus = "pending"
@@ -48,6 +49,21 @@ class ApprovalCreate(ApprovalBase):
agent_id: UUID | None = None agent_id: UUID | None = None
@model_validator(mode="after")
def validate_lead_reasoning(self) -> Self:
"""Ensure each approval request includes explicit lead reasoning."""
payload = self.payload
if isinstance(payload, dict):
reason = payload.get("reason")
if isinstance(reason, str) and reason.strip():
return self
decision = payload.get("decision")
if isinstance(decision, dict):
nested_reason = decision.get("reason")
if isinstance(nested_reason, str) and nested_reason.strip():
return self
raise ValueError(LEAD_REASONING_REQUIRED_ERROR)
class ApprovalUpdate(SQLModel): class ApprovalUpdate(SQLModel):
"""Payload for mutating approval status.""" """Payload for mutating approval status."""
@@ -67,6 +83,7 @@ class ApprovalRead(ApprovalBase):
id: UUID id: UUID
board_id: UUID board_id: UUID
task_titles: list[str] = Field(default_factory=list)
agent_id: UUID | None = None agent_id: UUID | None = None
created_at: datetime created_at: datetime
resolved_at: datetime | None = None resolved_at: datetime | None = None

View File

@@ -36,10 +36,21 @@ def _memory_to_read(memory: BoardMemory) -> BoardMemoryRead:
return BoardMemoryRead.model_validate(memory, from_attributes=True) return BoardMemoryRead.model_validate(memory, from_attributes=True)
def _approval_to_read(approval: Approval, *, task_ids: list[UUID]) -> ApprovalRead: def _approval_to_read(
approval: Approval,
*,
task_ids: list[UUID],
task_titles: list[str],
) -> ApprovalRead:
model = ApprovalRead.model_validate(approval, from_attributes=True) model = ApprovalRead.model_validate(approval, from_attributes=True)
primary_task_id = task_ids[0] if task_ids else None primary_task_id = task_ids[0] if task_ids else None
return model.model_copy(update={"task_id": primary_task_id, "task_ids": task_ids}) return model.model_copy(
update={
"task_id": primary_task_id,
"task_ids": task_ids,
"task_titles": task_titles,
},
)
def _task_to_card( def _task_to_card(
@@ -137,13 +148,21 @@ async def build_board_snapshot(session: AsyncSession, board: Board) -> BoardSnap
session, session,
approval_ids=approval_ids, approval_ids=approval_ids,
) )
task_title_by_id = {task.id: task.title for task in tasks}
approval_reads = [ approval_reads = [
_approval_to_read( _approval_to_read(
approval, approval,
task_ids=task_ids_by_approval.get( task_ids=(
approval.id, linked_task_ids := task_ids_by_approval.get(
[approval.task_id] if approval.task_id is not None else [], approval.id,
[approval.task_id] if approval.task_id is not None else [],
)
), ),
task_titles=[
task_title_by_id[task_id]
for task_id in linked_task_ids
if task_id in task_title_by_id
],
) )
for approval in approvals for approval in approvals
] ]

View File

@@ -5,16 +5,16 @@ from __future__ import annotations
import hashlib import hashlib
from typing import Mapping from typing import Mapping
CONFIDENCE_THRESHOLD = 80 CONFIDENCE_THRESHOLD = 80.0
MIN_PLANNING_SIGNALS = 2 MIN_PLANNING_SIGNALS = 2
def compute_confidence(rubric_scores: Mapping[str, int]) -> int: def compute_confidence(rubric_scores: Mapping[str, int]) -> float:
"""Compute aggregate confidence from rubric score components.""" """Compute aggregate confidence from rubric score components."""
return int(sum(rubric_scores.values())) return float(sum(rubric_scores.values()))
def approval_required(*, confidence: int, is_external: bool, is_risky: bool) -> bool: def approval_required(*, confidence: float, is_external: bool, is_risky: bool) -> bool:
"""Return whether an action must go through explicit approval.""" """Return whether an action must go through explicit approval."""
return is_external or is_risky or confidence < CONFIDENCE_THRESHOLD return is_external or is_risky or confidence < CONFIDENCE_THRESHOLD

View File

@@ -0,0 +1,39 @@
"""make approval confidence float
Revision ID: e2f9c6b4a1d3
Revises: d8c1e5a4f7b2
Create Date: 2026-02-12 20:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "e2f9c6b4a1d3"
down_revision = "d8c1e5a4f7b2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column(
"approvals",
"confidence",
existing_type=sa.Integer(),
type_=sa.Float(),
existing_nullable=False,
)
def downgrade() -> None:
op.alter_column(
"approvals",
"confidence",
existing_type=sa.Float(),
type_=sa.Integer(),
existing_nullable=False,
)

View File

@@ -51,22 +51,25 @@ async def test_create_approval_rejects_duplicate_pending_for_same_task() -> None
async with await _make_session(engine) as session: async with await _make_session(engine) as session:
board, task_ids = await _seed_board_with_tasks(session, task_count=1) board, task_ids = await _seed_board_with_tasks(session, task_count=1)
task_id = task_ids[0] task_id = task_ids[0]
await approvals_api.create_approval( created = await approvals_api.create_approval(
payload=ApprovalCreate( payload=ApprovalCreate(
action_type="task.execute", action_type="task.execute",
task_id=task_id, task_id=task_id,
payload={"reason": "Initial execution needs confirmation."},
confidence=80, confidence=80,
status="pending", status="pending",
), ),
board=board, board=board,
session=session, session=session,
) )
assert created.task_titles == [f"task-{task_id}"]
with pytest.raises(HTTPException) as exc: with pytest.raises(HTTPException) as exc:
await approvals_api.create_approval( await approvals_api.create_approval(
payload=ApprovalCreate( payload=ApprovalCreate(
action_type="task.retry", action_type="task.retry",
task_id=task_id, task_id=task_id,
payload={"reason": "Retry should still be gated."},
confidence=77, confidence=77,
status="pending", status="pending",
), ),
@@ -91,22 +94,25 @@ async def test_create_approval_rejects_pending_conflict_from_linked_task_ids() -
async with await _make_session(engine) as session: async with await _make_session(engine) as session:
board, task_ids = await _seed_board_with_tasks(session, task_count=2) board, task_ids = await _seed_board_with_tasks(session, task_count=2)
task_a, task_b = task_ids task_a, task_b = task_ids
await approvals_api.create_approval( created = await approvals_api.create_approval(
payload=ApprovalCreate( payload=ApprovalCreate(
action_type="task.batch_execute", action_type="task.batch_execute",
task_ids=[task_a, task_b], task_ids=[task_a, task_b],
payload={"reason": "Batch operation requires sign-off."},
confidence=85, confidence=85,
status="pending", status="pending",
), ),
board=board, board=board,
session=session, session=session,
) )
assert created.task_titles == [f"task-{task_a}", f"task-{task_b}"]
with pytest.raises(HTTPException) as exc: with pytest.raises(HTTPException) as exc:
await approvals_api.create_approval( await approvals_api.create_approval(
payload=ApprovalCreate( payload=ApprovalCreate(
action_type="task.execute", action_type="task.execute",
task_id=task_b, task_id=task_b,
payload={"reason": "Single task overlaps with pending batch."},
confidence=70, confidence=70,
status="pending", status="pending",
), ),
@@ -135,6 +141,7 @@ async def test_update_approval_rejects_reopening_to_pending_with_existing_pendin
payload=ApprovalCreate( payload=ApprovalCreate(
action_type="task.execute", action_type="task.execute",
task_id=task_id, task_id=task_id,
payload={"reason": "Primary pending approval is active."},
confidence=83, confidence=83,
status="pending", status="pending",
), ),
@@ -145,6 +152,7 @@ async def test_update_approval_rejects_reopening_to_pending_with_existing_pendin
payload=ApprovalCreate( payload=ApprovalCreate(
action_type="task.review", action_type="task.review",
task_id=task_id, task_id=task_id,
payload={"reason": "Review decision completed earlier."},
confidence=90, confidence=90,
status="approved", status="approved",
), ),

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.schemas.approvals import ApprovalCreate
def test_approval_create_requires_confidence_score() -> None:
with pytest.raises(ValidationError, match="confidence"):
ApprovalCreate.model_validate(
{
"action_type": "task.update",
"payload": {"reason": "Missing confidence should fail."},
},
)
@pytest.mark.parametrize("confidence", [-1.0, 101.0])
def test_approval_create_rejects_out_of_range_confidence(confidence: float) -> None:
with pytest.raises(ValidationError, match="confidence"):
ApprovalCreate.model_validate(
{
"action_type": "task.update",
"payload": {"reason": "Confidence must be in range."},
"confidence": confidence,
},
)
def test_approval_create_requires_lead_reasoning() -> None:
with pytest.raises(ValidationError, match="lead reasoning is required"):
ApprovalCreate.model_validate(
{
"action_type": "task.update",
"confidence": 80,
},
)
def test_approval_create_accepts_nested_decision_reason() -> None:
model = ApprovalCreate.model_validate(
{
"action_type": "task.update",
"confidence": 80,
"payload": {"decision": {"reason": "Needs manual approval."}},
},
)
assert model.payload == {"decision": {"reason": "Needs manual approval."}}
def test_approval_create_accepts_float_confidence() -> None:
model = ApprovalCreate.model_validate(
{
"action_type": "task.update",
"confidence": 88.75,
"payload": {"reason": "Fractional confidence should be preserved."},
},
)
assert model.confidence == 88.75

View File

@@ -252,7 +252,8 @@ export default function DashboardPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const selectedRangeParam = searchParams.get("range"); const selectedRangeParam = searchParams.get("range");
const selectedRange: RangeKey = const selectedRange: RangeKey =
selectedRangeParam && DASHBOARD_RANGE_SET.has(selectedRangeParam as RangeKey) selectedRangeParam &&
DASHBOARD_RANGE_SET.has(selectedRangeParam as RangeKey)
? (selectedRangeParam as RangeKey) ? (selectedRangeParam as RangeKey)
: DEFAULT_RANGE; : DEFAULT_RANGE;
const metricsQuery = useDashboardMetricsApiV1MetricsDashboardGet< const metricsQuery = useDashboardMetricsApiV1MetricsDashboardGet<
@@ -401,10 +402,7 @@ export default function DashboardPage() {
</div> </div>
<div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-2"> <div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
<ChartCard <ChartCard title="Completed Tasks" subtitle="Throughput">
title="Completed Tasks"
subtitle="Throughput"
>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart <BarChart
data={throughputSeries} data={throughputSeries}
@@ -449,10 +447,7 @@ export default function DashboardPage() {
</ResponsiveContainer> </ResponsiveContainer>
</ChartCard> </ChartCard>
<ChartCard <ChartCard title="Avg Hours to Review" subtitle="Cycle time">
title="Avg Hours to Review"
subtitle="Cycle time"
>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart <LineChart
data={cycleSeries} data={cycleSeries}
@@ -501,10 +496,7 @@ export default function DashboardPage() {
</ResponsiveContainer> </ResponsiveContainer>
</ChartCard> </ChartCard>
<ChartCard <ChartCard title="Failed Events" subtitle="Error rate">
title="Failed Events"
subtitle="Error rate"
>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart <LineChart
data={errorSeries} data={errorSeries}

View File

@@ -52,9 +52,14 @@ describe("BoardApprovalsPanel", () => {
linked_request: { linked_request: {
tasks: [ tasks: [
{ {
task_id: "task-1",
title: "Launch onboarding checklist", title: "Launch onboarding checklist",
description: "Create and validate the v1 onboarding checklist.", description: "Create and validate the v1 onboarding checklist.",
}, },
{
task_id: "task-2",
title: "Publish onboarding checklist",
},
], ],
task_ids: ["task-1", "task-2"], task_ids: ["task-1", "task-2"],
}, },
@@ -84,7 +89,46 @@ describe("BoardApprovalsPanel", () => {
expect( expect(
screen.getByText("Needs explicit sign-off before rollout."), screen.getByText("Needs explicit sign-off before rollout."),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("62% score")).toBeInTheDocument();
expect(screen.getByText(/related tasks/i)).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Launch onboarding checklist" }),
).toHaveAttribute("href", "/boards/board-1?taskId=task-1");
expect(
screen.getByRole("link", { name: "Publish onboarding checklist" }),
).toHaveAttribute("href", "/boards/board-1?taskId=task-2");
expect(screen.getByText(/rubric scores/i)).toBeInTheDocument(); expect(screen.getByText(/rubric scores/i)).toBeInTheDocument();
expect(screen.getByText("Clarity")).toBeInTheDocument(); expect(screen.getByText("Clarity")).toBeInTheDocument();
}); });
it("uses schema task_titles for related task links when payload titles are missing", () => {
const approval = {
id: "approval-2",
board_id: "board-1",
action_type: "task.update",
confidence: 88,
status: "pending",
task_id: "task-a",
task_ids: ["task-a", "task-b"],
task_titles: ["Prepare release notes", "Publish release notes"],
created_at: "2026-02-12T11:00:00Z",
resolved_at: null,
payload: {
task_ids: ["task-a", "task-b"],
reason: "Needs sign-off before publishing.",
},
rubric_scores: null,
} as ApprovalRead;
renderWithQueryClient(
<BoardApprovalsPanel boardId="board-1" approvals={[approval]} />,
);
expect(
screen.getByRole("link", { name: "Prepare release notes" }),
).toHaveAttribute("href", "/boards/board-1?taskId=task-a");
expect(
screen.getByRole("link", { name: "Publish release notes" }),
).toHaveAttribute("href", "/boards/board-1?taskId=task-b");
});
}); });

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/auth/clerk"; import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
@@ -28,9 +29,16 @@ import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type Approval = ApprovalRead & { status: string }; type Approval = ApprovalRead & { status: string };
const normalizeScore = (value: unknown): number => {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return value;
};
const normalizeApproval = (approval: ApprovalRead): Approval => ({ const normalizeApproval = (approval: ApprovalRead): Approval => ({
...approval, ...approval,
status: approval.status ?? "pending", status: approval.status ?? "pending",
confidence: normalizeScore(approval.confidence),
}); });
type BoardApprovalsPanelProps = { type BoardApprovalsPanelProps = {
@@ -237,6 +245,79 @@ const approvalTaskIds = (approval: Approval) => {
return [...new Set(merged)]; return [...new Set(merged)];
}; };
type RelatedTaskSummary = {
id: string;
title: string;
};
const approvalRelatedTasks = (approval: Approval): RelatedTaskSummary[] => {
const payload = approval.payload ?? {};
const taskIds = approvalTaskIds(approval);
if (taskIds.length === 0) return [];
const apiTaskTitles = (
approval as Approval & { task_titles?: string[] | null }
).task_titles;
const titleByTaskId = new Map<string, string>();
const orderedTitles: string[] = [];
const collectTaskTitles = (path: string[]) => {
const tasks = payloadAtPath(payload, path);
if (!Array.isArray(tasks)) return;
for (const task of tasks) {
if (!isRecord(task)) continue;
const rawTitle = task["title"];
const title = typeof rawTitle === "string" ? rawTitle.trim() : "";
if (!title) continue;
orderedTitles.push(title);
const taskId =
typeof task["task_id"] === "string"
? task["task_id"]
: typeof task["taskId"] === "string"
? task["taskId"]
: typeof task["id"] === "string"
? task["id"]
: null;
if (taskId && taskId.trim()) {
titleByTaskId.set(taskId, title);
}
}
};
collectTaskTitles(["linked_request", "tasks"]);
collectTaskTitles(["linkedRequest", "tasks"]);
const indexedTitles = [
...(Array.isArray(apiTaskTitles) ? apiTaskTitles : []),
...orderedTitles,
...payloadValues(payload, "task_titles"),
...payloadValues(payload, "taskTitles"),
...payloadNestedValues(payload, ["linked_request", "task_titles"]),
...payloadNestedValues(payload, ["linked_request", "taskTitles"]),
...payloadNestedValues(payload, ["linkedRequest", "task_titles"]),
...payloadNestedValues(payload, ["linkedRequest", "taskTitles"]),
]
.map((value) => value.trim())
.filter((value) => value.length > 0);
const singleTitle =
payloadValue(payload, "title") ??
payloadNestedValue(payload, ["task", "title"]) ??
payloadFirstLinkedTaskValue(payload, "title");
return taskIds.map((taskId, index) => {
const resolvedTitle =
titleByTaskId.get(taskId) ??
indexedTitles[index] ??
(taskIds.length === 1 ? singleTitle : null) ??
"Untitled task";
return { id: taskId, title: resolvedTitle };
});
};
const taskHref = (boardId: string, taskId: string) =>
`/boards/${encodeURIComponent(boardId)}?taskId=${encodeURIComponent(taskId)}`;
const approvalSummary = (approval: Approval, boardLabel?: string | null) => { const approvalSummary = (approval: Approval, boardLabel?: string | null) => {
const payload = approval.payload ?? {}; const payload = approval.payload ?? {};
const taskIds = approvalTaskIds(approval); const taskIds = approvalTaskIds(approval);
@@ -544,6 +625,9 @@ export function BoardApprovalsPanel({
</p> </p>
) : null} ) : null}
<div className="mt-2 flex items-center gap-2 text-xs text-slate-500"> <div className="mt-2 flex items-center gap-2 text-xs text-slate-500">
<span className="rounded bg-slate-100 px-1.5 py-0.5 font-semibold text-slate-700">
{approval.confidence}% score
</span>
<Clock className="h-3.5 w-3.5 opacity-60" /> <Clock className="h-3.5 w-3.5 opacity-60" />
<span>{formatTimestamp(approval.created_at)}</span> <span>{formatTimestamp(approval.created_at)}</span>
</div> </div>
@@ -582,10 +666,12 @@ export function BoardApprovalsPanel({
const titleText = titleRow?.value?.trim() ?? ""; const titleText = titleRow?.value?.trim() ?? "";
const descriptionText = summary.description?.trim() ?? ""; const descriptionText = summary.description?.trim() ?? "";
const reasoningText = summary.reason?.trim() ?? ""; const reasoningText = summary.reason?.trim() ?? "";
const relatedTasks = approvalRelatedTasks(selectedApproval);
const extraRows = summary.rows.filter((row) => { const extraRows = summary.rows.filter((row) => {
const normalized = row.label.toLowerCase(); const normalized = row.label.toLowerCase();
if (normalized === "title") return false; if (normalized === "title") return false;
if (normalized === "task") return false; if (normalized === "task") return false;
if (normalized === "tasks") return false;
if (normalized === "assignee") return false; if (normalized === "assignee") return false;
return true; return true;
}); });
@@ -733,6 +819,28 @@ export function BoardApprovalsPanel({
</div> </div>
) : null} ) : null}
{relatedTasks.length > 0 ? (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Related tasks
</p>
<div className="flex flex-wrap gap-2">
{relatedTasks.map((task) => (
<Link
key={`${selectedApproval.id}-task-${task.id}`}
href={taskHref(
selectedApproval.board_id,
task.id,
)}
className="rounded-md border border-slate-200 bg-white px-2 py-1 text-xs text-slate-700 underline-offset-2 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 hover:underline"
>
{task.title}
</Link>
))}
</div>
</div>
) : null}
{extraRows.length > 0 ? ( {extraRows.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500"> <p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">

View File

@@ -57,10 +57,14 @@ export function DashboardSidebar() {
return ( return (
<aside className="flex h-full w-64 flex-col border-r border-slate-200 bg-white"> <aside className="flex h-full w-64 flex-col border-r border-slate-200 bg-white">
<div className="flex-1 px-3 py-4"> <div className="flex-1 px-3 py-4">
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">Navigation</p> <p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
Navigation
</p>
<nav className="mt-3 space-y-4 text-sm"> <nav className="mt-3 space-y-4 text-sm">
<div> <div>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">Overview</p> <p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
Overview
</p>
<div className="mt-1 space-y-1"> <div className="mt-1 space-y-1">
<Link <Link
href="/dashboard" href="/dashboard"
@@ -90,7 +94,9 @@ export function DashboardSidebar() {
</div> </div>
<div> <div>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">Boards</p> <p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
Boards
</p>
<div className="mt-1 space-y-1"> <div className="mt-1 space-y-1">
<Link <Link
href="/board-groups" href="/board-groups"