diff --git a/panel/src/components/dashboard/pr-review-queue.tsx b/panel/src/components/dashboard/pr-review-queue.tsx
index cbfcec96..4dce1a1b 100644
--- a/panel/src/components/dashboard/pr-review-queue.tsx
+++ b/panel/src/components/dashboard/pr-review-queue.tsx
@@ -21,7 +21,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import { GitPullRequest, ExternalLink, Rocket, XCircle, FileText } from "lucide-react";
+import { GitPullRequest, ExternalLink, Rocket, XCircle, FileText, Loader2 } from "lucide-react";
import Link from "next/link";
import { type Task } from "@/types";
import { toast } from "sonner";
@@ -133,67 +133,89 @@ export function PrReviewQueue({ className }: PrReviewQueueProps) {
- External PRs the org reviewed — supersede or dismiss
+ External PRs the org is reviewing or has reviewed — the reviewer
+ posts its change-request on the PR; supersede or dismiss once it
+ lands
diff --git a/roboco/api/routes/tasks.py b/roboco/api/routes/tasks.py
index 42c85b9e..65e83698 100644
--- a/roboco/api/routes/tasks.py
+++ b/roboco/api/routes/tasks.py
@@ -647,10 +647,13 @@ async def get_external_pr_reviews(
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
) -> list[TaskResponse]:
- """Inbound external PRs that were reviewed and await the CEO's decision.
+ """Inbound external PRs the org is reviewing or has reviewed.
- The PR-review decision queue: completed external-PR review tasks the CEO has
- neither superseded nor dismissed. Org-wide; visible to PMs and above.
+ The PR-review queue: external-PR review tasks still in flight (the reviewer
+ is working) OR completed and awaiting the CEO's decision (not yet superseded
+ or dismissed). Active reviews surface so the panel shows a review underway
+ and links to the PR where the change-request is posted, instead of going
+ dark until it finishes. Org-wide; visible to PMs and above.
"""
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
is_pm = agent.role in (AgentRole.CELL_PM, AgentRole.MAIN_PM)
@@ -661,7 +664,7 @@ async def get_external_pr_reviews(
detail="Only PMs and management can view the PR-review queue",
)
service = get_task_service(db)
- tasks = await service.list_external_pr_reviews_awaiting_decision()
+ tasks = await service.list_external_pr_reviews()
return task_list_to_response(tasks)
diff --git a/roboco/services/task.py b/roboco/services/task.py
index 55ff84ae..7be4eddc 100644
--- a/roboco/services/task.py
+++ b/roboco/services/task.py
@@ -741,6 +741,37 @@ class TaskService(BaseService):
if "dismissed=1" not in (t.quick_context or "").split()
]
+ async def list_external_pr_reviews(self) -> list[TaskTable]:
+ """Live external-PR reviews for the panel: in-flight PLUS awaiting-decision.
+
+ Every ``source='external_pr'`` task that is not finished-and-decided:
+ active reviews (the reviewer is still working — pending / claimed /
+ in_progress / verifying / blocked / paused) AND completed reviews the CEO
+ has neither superseded (``confirmed_by_human=True``) nor dismissed
+ (``dismissed=1`` in quick_context). Cancelled tasks are excluded.
+
+ Active reviews are surfaced on purpose: the reviewer posts its
+ change-request to the PR itself, so the panel must show that a review is
+ underway and link to that PR rather than going dark until the review
+ finishes. Each task carries its ``status`` so the panel can tell
+ "reviewing" apart from "awaiting your decision".
+ """
+ result = await self.session.execute(
+ select(TaskTable).where(
+ TaskTable.source == "external_pr",
+ TaskTable.status != TaskStatus.CANCELLED,
+ or_(
+ TaskTable.status != TaskStatus.COMPLETED,
+ TaskTable.confirmed_by_human.is_(False),
+ ),
+ )
+ )
+ return [
+ t
+ for t in result.scalars().all()
+ if "dismissed=1" not in (t.quick_context or "").split()
+ ]
+
async def dismiss_external_pr_review(self, task_id: UUID) -> TaskTable | None:
"""CEO declines to act on a reviewed external PR — drop it from the queue.
diff --git a/tests/unit/services/test_external_pr_ingest.py b/tests/unit/services/test_external_pr_ingest.py
index 0a282f2d..3966220d 100644
--- a/tests/unit/services/test_external_pr_ingest.py
+++ b/tests/unit/services/test_external_pr_ingest.py
@@ -76,6 +76,17 @@ async def test_list_awaiting_decision_excludes_dismissed() -> None:
assert out == [pending]
+@pytest.mark.asyncio
+async def test_list_external_pr_reviews_excludes_dismissed() -> None:
+ # The panel queue surfaces in-flight reviews too (the status filter lives in
+ # SQL); here we pin the post-query behavior: dismissed reviews drop out.
+ reviewing = MagicMock(quick_context="external_pr_head=abc")
+ dismissed = MagicMock(quick_context="external_pr_head=def dismissed=1")
+ svc = _service([reviewing, dismissed])
+ out = await svc.list_external_pr_reviews()
+ assert out == [reviewing]
+
+
@pytest.mark.asyncio
async def test_dismiss_marks_and_is_idempotent() -> None:
task = MagicMock(source="external_pr", quick_context="external_pr_head=abc")