feat: Add dashboard UI for workflow push approvals (#103)
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
CI / Docker build smoke (push) Waiting to run

This commit is contained in:
seahaven-openswe[bot] 2026-07-02 02:16:28 -04:00 • committed by GitHub
parent a7ddd79e98
commit 1bea7ae2d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 314 additions and 2 deletions

View file

@ -9,7 +9,11 @@ from fastapi import APIRouter, Depends, HTTPException
from .oauth import require_same_origin_for_mutations, require_session from .oauth import require_same_origin_for_mutations, require_session
from .plan_api import _dispatch_followup, _thread_metadata from .plan_api import _dispatch_followup, _thread_metadata
from .thread_api import _user_owns_thread 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( workflow_approval_router = APIRouter(
prefix="/dashboard/api/workflow-approval", prefix="/dashboard/api/workflow-approval",
@ -19,6 +23,33 @@ workflow_approval_router = APIRouter(
_SESSION_DEP = Depends(require_session) _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") @workflow_approval_router.post("/{thread_id}/{fingerprint}/approve")
async def approve_workflow_push( async def approve_workflow_push(
thread_id: str, fingerprint: str, session: dict[str, Any] = _SESSION_DEP thread_id: str, fingerprint: str, session: dict[str, Any] = _SESSION_DEP

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { useStreamContext as useAgentThreadStream } from "@langchain/react" import { useStreamContext as useAgentThreadStream } from "@langchain/react"
import { Map as MapIcon } from "lucide-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 { useModelOptions } from "@/lib/agents/provider/useModelOptions"
import { useIsMobile } from "@/lib/useIsMobile" import { useIsMobile } from "@/lib/useIsMobile"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import {
useApproveWorkflowPush,
useRejectWorkflowPush,
useWorkflowApprovals,
} from "@/lib/agents/queries"
import { WorkflowApprovalCard } from "@/components/agents/WorkflowApprovalCard"
interface AgentThreadViewProps { interface AgentThreadViewProps {
thread: AgentThread 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. // Show a loading state during that one-time fetch instead of the empty state.
const isHydrating = stream.isThreadLoading && !hasMessages 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 ( return (
<div className="flex min-w-0 flex-1"> <div className="flex min-w-0 flex-1">
<div <div
@ -167,6 +198,24 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
)} )}
{hasConversation ? ( {hasConversation ? (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"> <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{approvals && approvals.length > 0 && (
<div className="shrink-0 px-4 pt-4">
{approvals.map((approval) => (
<WorkflowApprovalCard
key={approval.fingerprint}
approval={approval}
isOwner={isApprovalOwner}
isPending={
approveMutation.isPending || rejectMutation.isPending
}
onApprove={() =>
approveMutation.mutate(approval.fingerprint)
}
onReject={() => rejectMutation.mutate(approval.fingerprint)}
/>
))}
</div>
)}
<Messages <Messages
messages={baseMessages} messages={baseMessages}
queuedMessages={queuedMessages} queuedMessages={queuedMessages}

View file

@ -0,0 +1,138 @@
import { Check, FileCode2, ShieldAlert, X } from "lucide-react"
import type { WorkflowApproval } from "@/lib/agents/api"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
type ApprovalStatus = "pending" | "approved" | "rejected" | string
interface WorkflowApprovalCardProps {
approval: WorkflowApproval
isOwner: boolean
isPending: boolean
onApprove: (fingerprint: string) => 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 (
<Card className="mx-auto mb-4 w-full max-w-3xl border-amber-500/20 bg-amber-500/5 dark:bg-amber-500/10">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<ShieldAlert className="size-4 text-amber-600 dark:text-amber-400" />
<CardTitle className="text-sm font-medium">
Workflow push approval required
</CardTitle>
</div>
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase ${statusBadgeClass(
approval.status
)}`}
>
{statusLabel(approval.status)}
</span>
</div>
<CardDescription className="text-xs">
Open SWE is trying to push GitHub workflow file changes in{" "}
<strong>{approval.repo}</strong> on <strong>{approval.branch}</strong>
. Approve only if this exact workflow diff is expected.
</CardDescription>
</CardHeader>
<CardContent className="pb-3">
<div className="space-y-2">
<div className="flex items-center gap-2 text-[11px] font-medium text-[var(--ui-text-muted)]">
<FileCode2 className="size-3.5" />
Changed workflow files
</div>
<ul className="space-y-1">
{approval.files.map((file) => (
<li
key={file}
className="font-mono text-[11px] break-all text-[var(--ui-text)]"
>
{file}
</li>
))}
</ul>
<div className="pt-1 text-[11px] text-[var(--ui-text-muted)]">
Fingerprint:{" "}
<code className="rounded bg-[var(--ui-panel)] px-1 py-0.5 text-[var(--ui-text)]">
{approval.fingerprint}
</code>
</div>
</div>
</CardContent>
{actionable ? (
<CardFooter className="gap-2 border-t border-[var(--ui-border)] pt-3">
<Button
size="sm"
onClick={() => onApprove(approval.fingerprint)}
disabled={isPending}
>
<Check className="mr-1 size-3.5" />
Approve
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => onReject(approval.fingerprint)}
disabled={isPending}
>
<X className="mr-1 size-3.5" />
Reject
</Button>
</CardFooter>
) : decided ? (
<CardFooter className="border-t border-[var(--ui-border)] pt-3 text-[11px] text-[var(--ui-text-muted)]">
This workflow push has been {approval.status}. If the workflow files
change, a new fingerprint will be required.
</CardFooter>
) : null}
{!isOwner && approval.status === "pending" && (
<CardFooter className="border-t border-[var(--ui-border)] pt-3 text-[11px] text-[var(--ui-text-muted)]">
Only the thread owner can approve or reject this workflow push.
</CardFooter>
)}
</Card>
)
}

View file

@ -65,6 +65,24 @@ export interface ThreadRecoveryPatch {
filename: string filename: string
} }
export interface WorkflowApproval {
fingerprint: string
status: string
repo: string
branch: string
files: Array<string>
requested_at: string
}
export interface WorkflowApprovalsPayload {
approvals: Array<WorkflowApproval>
}
export interface WorkflowApprovalDecision {
status: string
fingerprint: string
}
export interface ThreadsPageParams { export interface ThreadsPageParams {
limit?: number limit?: number
offset?: number offset?: number
@ -265,6 +283,24 @@ export const agentsApi = {
), ),
streamUrl: (threadId: string) => streamUrl: (threadId: string) =>
`${API_BASE}/dashboard/api/threads/${encodeURIComponent(threadId)}/stream`, `${API_BASE}/dashboard/api/threads/${encodeURIComponent(threadId)}/stream`,
listWorkflowApprovals: (threadId: string) =>
agentsRequest<WorkflowApprovalsPayload>(
`/workflow-approval/${encodeURIComponent(threadId)}`
),
approveWorkflowPush: (threadId: string, fingerprint: string) =>
agentsRequest<WorkflowApprovalDecision>(
`/workflow-approval/${encodeURIComponent(threadId)}/${encodeURIComponent(
fingerprint
)}/approve`,
{ method: "POST" }
),
rejectWorkflowPush: (threadId: string, fingerprint: string) =>
agentsRequest<WorkflowApprovalDecision>(
`/workflow-approval/${encodeURIComponent(threadId)}/${encodeURIComponent(
fingerprint
)}/reject`,
{ method: "POST" }
),
} }
export type ThreadGroup = "today" | "last7" | "last30" | "older" export type ThreadGroup = "today" | "last7" | "last30" | "older"

View file

@ -8,6 +8,7 @@ import type {
ScheduleUpdateRequest, ScheduleUpdateRequest,
SidebarThreads, SidebarThreads,
ThreadsPageParams, ThreadsPageParams,
WorkflowApproval,
} from "./api" } from "./api"
import type { AgentThread, Chunk, ImageChunk, Message } from "./types" import type { AgentThread, Chunk, ImageChunk, Message } from "./types"
@ -17,6 +18,8 @@ export const agentThreadKeys = {
["agent-threads", "lists", "sidebar", params] as const, ["agent-threads", "lists", "sidebar", params] as const,
detail: (threadId: string) => ["agent-threads", threadId] as const, detail: (threadId: string) => ["agent-threads", threadId] as const,
prDiff: (threadId: string) => ["agent-threads", threadId, "pr-diff"] 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) => page: (params: ThreadsPageParams) =>
["agent-threads", "lists", "page", params] as const, ["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<Array<WorkflowApproval> | 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<Array<WorkflowApproval> | undefined>(
agentThreadKeys.workflowApprovals(threadId),
(prev) =>
prev?.map((record) =>
record.fingerprint === fingerprint
? { ...record, status: "rejected" }
: record
)
)
},
})
}
export function useAgentSchedules() { export function useAgentSchedules() {
return useQuery({ return useQuery({
queryKey: agentScheduleKeys.all, queryKey: agentScheduleKeys.all,