From 1bea7ae2d2bbf8d37a4e83629c22a1440a1bba83 Mon Sep 17 00:00:00 2001
From: "seahaven-openswe[bot]"
<296972425+seahaven-openswe[bot]@users.noreply.github.com>
Date: Thu, 2 Jul 2026 02:16:28 -0400
Subject: [PATCH] feat: Add dashboard UI for workflow push approvals (#103)
---
agent/dashboard/workflow_approval_api.py | 33 ++++-
ui/src/components/agents/AgentThreadView.tsx | 51 ++++++-
.../agents/WorkflowApprovalCard.tsx | 138 ++++++++++++++++++
ui/src/lib/agents/api.ts | 36 +++++
ui/src/lib/agents/queries.ts | 58 ++++++++
5 files changed, 314 insertions(+), 2 deletions(-)
create mode 100644 ui/src/components/agents/WorkflowApprovalCard.tsx
diff --git a/agent/dashboard/workflow_approval_api.py b/agent/dashboard/workflow_approval_api.py
index 7e30fa11..40396226 100644
--- a/agent/dashboard/workflow_approval_api.py
+++ b/agent/dashboard/workflow_approval_api.py
@@ -9,7 +9,11 @@ from fastapi import APIRouter, Depends, HTTPException
from .oauth import require_same_origin_for_mutations, require_session
from .plan_api import _dispatch_followup, _thread_metadata
from .thread_api import _user_owns_thread
-from .workflow_approval import decide_workflow_push_approval
+from .workflow_approval import (
+ WORKFLOW_APPROVAL_PENDING,
+ decide_workflow_push_approval,
+ get_workflow_push_approvals,
+)
workflow_approval_router = APIRouter(
prefix="/dashboard/api/workflow-approval",
@@ -19,6 +23,33 @@ workflow_approval_router = APIRouter(
_SESSION_DEP = Depends(require_session)
+def _approval_record_response(record: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "fingerprint": record.get("fingerprint"),
+ "status": record.get("status"),
+ "repo": record.get("repo"),
+ "branch": record.get("branch"),
+ "files": record.get("files"),
+ "requested_at": record.get("requested_at"),
+ }
+
+
+@workflow_approval_router.get("/{thread_id}")
+async def list_workflow_approvals_for_thread(
+ thread_id: str, session: dict[str, Any] = _SESSION_DEP
+) -> dict[str, Any]:
+ metadata = await _thread_metadata(thread_id)
+ if not _user_owns_thread(metadata, session["sub"], session.get("email")):
+ raise HTTPException(403, "only the thread owner can view workflow push approvals")
+ approvals = await get_workflow_push_approvals(thread_id)
+ pending = [
+ _approval_record_response(record)
+ for record in approvals.values()
+ if record.get("status") == WORKFLOW_APPROVAL_PENDING
+ ]
+ return {"approvals": pending}
+
+
@workflow_approval_router.post("/{thread_id}/{fingerprint}/approve")
async def approve_workflow_push(
thread_id: str, fingerprint: str, session: dict[str, Any] = _SESSION_DEP
diff --git a/ui/src/components/agents/AgentThreadView.tsx b/ui/src/components/agents/AgentThreadView.tsx
index a6e93c5d..0080e8da 100644
--- a/ui/src/components/agents/AgentThreadView.tsx
+++ b/ui/src/components/agents/AgentThreadView.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useState } from "react"
+import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "@tanstack/react-router"
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
import { Map as MapIcon } from "lucide-react"
@@ -23,6 +23,12 @@ import { useSubmitAgentMessage } from "@/lib/agents/provider/useSubmitAgentMessa
import { useModelOptions } from "@/lib/agents/provider/useModelOptions"
import { useIsMobile } from "@/lib/useIsMobile"
import { cn } from "@/lib/utils"
+import {
+ useApproveWorkflowPush,
+ useRejectWorkflowPush,
+ useWorkflowApprovals,
+} from "@/lib/agents/queries"
+import { WorkflowApprovalCard } from "@/components/agents/WorkflowApprovalCard"
interface AgentThreadViewProps {
thread: AgentThread
@@ -126,6 +132,31 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
// Show a loading state during that one-time fetch instead of the empty state.
const isHydrating = stream.isThreadLoading && !hasMessages
+ const approvalsQuery = useWorkflowApprovals(thread.id)
+ const approveMutation = useApproveWorkflowPush(thread.id)
+ const rejectMutation = useRejectWorkflowPush(thread.id)
+
+ const approvals = approvalsQuery.data
+ const isApprovalOwner = thread.isOwner ?? false
+
+ const hasWorkflowApprovalError = useMemo(
+ () =>
+ baseMessages.some((message) =>
+ message.chunks.some(
+ (chunk) =>
+ chunk.kind === "tool-execution" &&
+ chunk.output?.includes("WorkflowPushApprovalRequired")
+ )
+ ),
+ [baseMessages]
+ )
+
+ useEffect(() => {
+ if (hasWorkflowApprovalError) {
+ void approvalsQuery.refetch()
+ }
+ }, [hasWorkflowApprovalError])
+
return (
+ {approvals && approvals.length > 0 && (
+
+ {approvals.map((approval) => (
+
+ approveMutation.mutate(approval.fingerprint)
+ }
+ onReject={() => rejectMutation.mutate(approval.fingerprint)}
+ />
+ ))}
+
+ )}
void
+ onReject: (fingerprint: string) => void
+}
+
+function statusBadgeClass(status: ApprovalStatus) {
+ switch (status) {
+ case "approved":
+ return "bg-green-500/10 text-green-600 dark:text-green-400"
+ case "rejected":
+ return "bg-red-500/10 text-red-600 dark:text-red-400"
+ default:
+ return "bg-amber-500/10 text-amber-600 dark:text-amber-400"
+ }
+}
+
+function statusLabel(status: ApprovalStatus) {
+ switch (status) {
+ case "approved":
+ return "Approved"
+ case "rejected":
+ return "Rejected"
+ default:
+ return "Pending approval"
+ }
+}
+
+export function WorkflowApprovalCard({
+ approval,
+ isOwner,
+ isPending,
+ onApprove,
+ onReject,
+}: WorkflowApprovalCardProps) {
+ const actionable = isOwner && approval.status === "pending" && !isPending
+ const decided = approval.status !== "pending"
+
+ return (
+
+
+
+
+
+
+ Workflow push approval required
+
+
+
+ {statusLabel(approval.status)}
+
+
+
+ Open SWE is trying to push GitHub workflow file changes in{" "}
+ {approval.repo} on {approval.branch}
+ . Approve only if this exact workflow diff is expected.
+
+
+
+
+
+
+ Changed workflow files
+
+
+ {approval.files.map((file) => (
+
+ {file}
+
+ ))}
+
+
+ Fingerprint:{" "}
+
+ {approval.fingerprint}
+
+
+
+
+ {actionable ? (
+
+ onApprove(approval.fingerprint)}
+ disabled={isPending}
+ >
+
+ Approve
+
+ onReject(approval.fingerprint)}
+ disabled={isPending}
+ >
+
+ Reject
+
+
+ ) : decided ? (
+
+ This workflow push has been {approval.status}. If the workflow files
+ change, a new fingerprint will be required.
+
+ ) : null}
+ {!isOwner && approval.status === "pending" && (
+
+ Only the thread owner can approve or reject this workflow push.
+
+ )}
+
+ )
+}
diff --git a/ui/src/lib/agents/api.ts b/ui/src/lib/agents/api.ts
index 2409bd20..886bf896 100644
--- a/ui/src/lib/agents/api.ts
+++ b/ui/src/lib/agents/api.ts
@@ -65,6 +65,24 @@ export interface ThreadRecoveryPatch {
filename: string
}
+export interface WorkflowApproval {
+ fingerprint: string
+ status: string
+ repo: string
+ branch: string
+ files: Array
+ requested_at: string
+}
+
+export interface WorkflowApprovalsPayload {
+ approvals: Array
+}
+
+export interface WorkflowApprovalDecision {
+ status: string
+ fingerprint: string
+}
+
export interface ThreadsPageParams {
limit?: number
offset?: number
@@ -265,6 +283,24 @@ export const agentsApi = {
),
streamUrl: (threadId: string) =>
`${API_BASE}/dashboard/api/threads/${encodeURIComponent(threadId)}/stream`,
+ listWorkflowApprovals: (threadId: string) =>
+ agentsRequest(
+ `/workflow-approval/${encodeURIComponent(threadId)}`
+ ),
+ approveWorkflowPush: (threadId: string, fingerprint: string) =>
+ agentsRequest(
+ `/workflow-approval/${encodeURIComponent(threadId)}/${encodeURIComponent(
+ fingerprint
+ )}/approve`,
+ { method: "POST" }
+ ),
+ rejectWorkflowPush: (threadId: string, fingerprint: string) =>
+ agentsRequest(
+ `/workflow-approval/${encodeURIComponent(threadId)}/${encodeURIComponent(
+ fingerprint
+ )}/reject`,
+ { method: "POST" }
+ ),
}
export type ThreadGroup = "today" | "last7" | "last30" | "older"
diff --git a/ui/src/lib/agents/queries.ts b/ui/src/lib/agents/queries.ts
index cf5ff2b8..0f60bf2b 100644
--- a/ui/src/lib/agents/queries.ts
+++ b/ui/src/lib/agents/queries.ts
@@ -8,6 +8,7 @@ import type {
ScheduleUpdateRequest,
SidebarThreads,
ThreadsPageParams,
+ WorkflowApproval,
} from "./api"
import type { AgentThread, Chunk, ImageChunk, Message } from "./types"
@@ -17,6 +18,8 @@ export const agentThreadKeys = {
["agent-threads", "lists", "sidebar", params] as const,
detail: (threadId: string) => ["agent-threads", threadId] as const,
prDiff: (threadId: string) => ["agent-threads", threadId, "pr-diff"] as const,
+ workflowApprovals: (threadId: string) =>
+ ["agent-threads", threadId, "workflow-approvals"] as const,
page: (params: ThreadsPageParams) =>
["agent-threads", "lists", "page", params] as const,
}
@@ -125,6 +128,61 @@ export function useAgentThreadPrDiff(threadId: string, enabled: boolean) {
})
}
+export function useWorkflowApprovals(threadId: string) {
+ return useQuery({
+ queryKey: agentThreadKeys.workflowApprovals(threadId),
+ queryFn: async () => {
+ const { approvals } = await agentsApi.listWorkflowApprovals(threadId)
+ return approvals
+ },
+ refetchInterval: (query) => {
+ const data = query.state.data
+ return data?.some((a) => a.status === "pending") ? 3000 : false
+ },
+ retry: false,
+ })
+}
+
+export function useApproveWorkflowPush(threadId: string) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (fingerprint: string) =>
+ agentsApi.approveWorkflowPush(threadId, fingerprint),
+ onSuccess: (_, fingerprint) => {
+ queryClient.setQueryData | undefined>(
+ agentThreadKeys.workflowApprovals(threadId),
+ (prev) =>
+ prev?.map((record) =>
+ record.fingerprint === fingerprint
+ ? { ...record, status: "approved" }
+ : record
+ )
+ )
+ },
+ })
+}
+
+export function useRejectWorkflowPush(threadId: string) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (fingerprint: string) =>
+ agentsApi.rejectWorkflowPush(threadId, fingerprint),
+ onSuccess: (_, fingerprint) => {
+ queryClient.setQueryData | undefined>(
+ agentThreadKeys.workflowApprovals(threadId),
+ (prev) =>
+ prev?.map((record) =>
+ record.fingerprint === fingerprint
+ ? { ...record, status: "rejected" }
+ : record
+ )
+ )
+ },
+ })
+}
+
export function useAgentSchedules() {
return useQuery({
queryKey: agentScheduleKeys.all,