From c74fab4462d4dadf47a86e66c79e558faa51cd39 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 9 May 2026 23:49:30 -0500 Subject: [PATCH] Align recovery resolution UI contract --- packages/shared/src/validators/issue.ts | 2 +- ui/src/api/issues.ts | 2 +- ui/src/pages/IssueDetail.tsx | 21 ++++++++++++++++++--- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 4364863cfb7..9504ab5c426 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -212,7 +212,7 @@ const RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES = [ export const resolveIssueRecoveryActionSchema = z.object({ actionId: z.string().uuid().optional(), outcome: z.enum(RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES), - sourceIssueStatus: z.enum(["done", "in_review", "blocked"]).optional().nullable(), + sourceIssueStatus: z.enum(["done", "in_review", "blocked"]).optional(), resolutionNote: multilineTextSchema.optional().nullable(), }).strict().superRefine((value, ctx) => { if (value.outcome === "restored") { diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index f404d5e48ec..cf0a20cf3d6 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -106,7 +106,7 @@ export const issuesApi = { data: { actionId?: string; outcome: IssueRecoveryActionOutcome; - sourceIssueStatus?: "done" | "in_review" | "blocked" | null; + sourceIssueStatus?: "done" | "in_review" | "blocked"; resolutionNote?: string | null; }, ) => api.post(`/issues/${id}/recovery-actions/resolve`, data), diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index cc171d1dcba..ec35a808b0a 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -8,7 +8,7 @@ import { approvalsApi } from "../api/approvals"; import { activityApi, type RunForIssue } from "../api/activity"; import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; -import { accessApi } from "../api/access"; +import { accessApi, type CurrentBoardAccess } from "../api/access"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; import { projectsApi } from "../api/projects"; @@ -212,6 +212,20 @@ function treeControlPreviewErrorCopy(error: unknown): string { return error instanceof Error ? error.message : "Unable to load preview."; } +function canBoardResolveRecoveryAction( + companyId: string | null | undefined, + boardAccess: CurrentBoardAccess | undefined, +) { + if (!companyId || !boardAccess) return false; + if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true; + + const membership = boardAccess.memberships?.find( + (item) => item.companyId === companyId && item.status === "active", + ); + if (!membership) return boardAccess.companyIds.includes(companyId) && !boardAccess.memberships; + return membership.membershipRole !== "viewer" && membership.membershipRole !== null; +} + function resolveRunningIssueRun( activeRun: ActiveRunForIssue | null | undefined, liveRuns: readonly LiveRunForIssue[] | undefined, @@ -1391,6 +1405,7 @@ export function IssueDetail() { selectedCompanyId && boardAccess?.companyIds?.includes(selectedCompanyId), ); + const canResolveBoardRecoveryAction = canBoardResolveRecoveryAction(selectedCompanyId, boardAccess); const { data: feedbackVotes } = useQuery({ queryKey: queryKeys.issues.feedbackVotes(issueId!), queryFn: () => issuesApi.listFeedbackVotes(issueId!), @@ -1730,7 +1745,7 @@ export function IssueDetail() { mutationFn: (data: { actionId?: string; outcome: IssueRecoveryActionOutcome; - sourceIssueStatus?: "done" | "in_review" | "blocked" | null; + sourceIssueStatus?: "done" | "in_review" | "blocked"; resolutionNote?: string | null; }) => issuesApi.resolveRecoveryAction(issueId!, data), onSuccess: ({ issue: nextIssue }) => { @@ -3872,7 +3887,7 @@ export function IssueDetail() { successfulRunHandoff={issue.successfulRunHandoff ?? null} recoveryAction={issue.activeRecoveryAction ?? null} onResolveRecoveryAction={handleResolveRecoveryAction} - canCancelRecoveryAction={canManageTreeControl} + canCancelRecoveryAction={canResolveBoardRecoveryAction} legacyRecoverySourceIssue={legacyRecoverySourceIssue} comments={threadComments} locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}