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 ? ( + + + + + ) : 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,