feat(web): disable unmanaged approval actions

Hide submit/review/approve on none docs, surface the blocked-run hint in
chat, and warn that unmanaged duties cannot be added after activation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-14 08:00:21 +00:00
co-authored by Cursor
parent 9baf3c83f8
commit ab9fccded4
8 changed files with 66 additions and 11 deletions
+29
View File
@@ -59,6 +59,10 @@ import {
resolveReconnectRunId,
shouldSubscribeExistingRun,
} from './lib/taskRunReconnect';
import {
GOVERNANCE_BLOCKED_HINT,
isBlockedGovernanceStatus,
} from './lib/blockedGovernanceCopy';
import {
buildControlledLoadErrorSkeleton,
buildControlledLoadingSkeleton,
@@ -2815,6 +2819,19 @@ export default function App() {
retainedRunId = created.run_id;
taskRunStream.setRunId(sessionId, created.run_id);
patchAsst({ taskRunId: created.run_id });
if (isBlockedGovernanceStatus(created.status)) {
settled = true;
patchAsst({
status: 'blocked_pending_governance',
content: GOVERNANCE_BLOCKED_HINT,
livePhaseLabel: undefined,
taskRunId: created.run_id,
});
taskRunStream.setRunId(sessionId, null);
taskRunStream.setThinking(sessionId, false);
await chatWindow.refresh().catch(() => undefined);
return;
}
await subscribeTaskRunEvents(created.run_id, { onEvent }, controller.signal);
return;
}
@@ -2844,6 +2861,18 @@ export default function App() {
taskRunStream.setThinking(sessionId, false);
return;
}
if (isBlockedGovernanceStatus(remote.status)) {
settled = true;
patchAsst({
status: 'blocked_pending_governance',
content: GOVERNANCE_BLOCKED_HINT,
livePhaseLabel: undefined,
taskWorkspaceId: remote.workspace_id,
taskRunId: runId,
});
taskRunStream.setThinking(sessionId, false);
return;
}
if (remote.status === 'completed') {
settled = true;
patchAsst({
+4
View File
@@ -28,6 +28,8 @@ export function mapTaskStatusToMessageStatus(
case 'quality_failed':
// 门禁未过必须保持失败态,不得映射 completed
return 'quality_failed';
case 'blocked_pending_governance':
return 'blocked_pending_governance';
case 'completed':
case '':
return 'completed';
@@ -47,6 +49,8 @@ export function assistantTaskStatusLabel(status: AssistantMessageStatus | undefi
return '待编制人确认';
case 'quality_failed':
return '质量未通过';
case 'blocked_pending_governance':
return '待激活工作流';
case 'failed':
return '执行失败';
case 'cancelled':
+6 -2
View File
@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react';
import { ChatErrorInfo, ChatMessage } from '../../types/workbench';
import { assistantTaskStatusLabel } from '../../api/chatWindow';
import { isBlockedGovernanceBubble } from '../../lib/blockedGovernanceCopy';
import { PhaseEventStrip } from './PhaseEventStrip';
import { RecommendedCaseCard } from './RecommendedCaseCard';
import { InteractiveCard } from './InteractiveCard';
@@ -62,6 +63,9 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
const isFailed = !isUser && status === 'failed' && !!message.errorInfo;
const isCancelled = !isUser && status === 'cancelled';
const isPendingAuthorConfirmation = !isUser && status === 'pending_author_confirmation';
const isBlockedGovernance =
!isUser &&
isBlockedGovernanceBubble({ taskStatus: status, content: message.content });
const taskStatusLabel = assistantTaskStatusLabel(status);
const hasContent = !!message.content && message.content.trim().length > 0;
const [diagCopied, setDiagCopied] = useState(false);
@@ -273,7 +277,7 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
? 'var(--brand-500)'
: isFailed
? 'var(--danger-bg)'
: isPendingAuthorConfirmation
: isPendingAuthorConfirmation || isBlockedGovernance
? 'rgba(245, 158, 11, 0.08)'
: 'var(--bg-panel)',
color: isUser ? '#ffffff' : 'var(--text-strong)',
@@ -281,7 +285,7 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
? 'none'
: isFailed
? '1px solid var(--danger)'
: isPendingAuthorConfirmation
: isPendingAuthorConfirmation || isBlockedGovernance
? '1px solid rgba(245, 158, 11, 0.45)'
: '1px solid var(--border)',
boxShadow: isUser ? '0 2px 8px rgba(79, 107, 255, 0.25)' : 'var(--shadow-xs)',
+13 -3
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { DocumentAST } from '../../types/block';
import { UserRole } from '../../types/docgov';
import { canShowDocGovApprovalAction, isUnmanagedDocDuties } from '../../lib/docGovActionVisibility';
import {
Send,
CheckCircle2,
@@ -68,6 +69,11 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
const isPrivate = ast.category === 'private';
const isShared = ast.category === 'shared';
const isWorkflow = !isPrivate && !isShared;
const duties = { authorId: ast.authorId, reviewerId: ast.reviewerId, approverId: ast.approverId };
const unmanagedDuties = isUnmanagedDocDuties(duties);
const showSubmit = canShowDocGovApprovalAction('submit', currentRole, duties);
const showReview = canShowDocGovApprovalAction('review', currentRole, duties);
const showApprove = canShowDocGovApprovalAction('approve', currentRole, duties);
return (
<div
@@ -402,7 +408,11 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
{/* 1. 编制阶段 */}
{ast.lifecycleState === 'drafting' && (
<>
{currentRole === 'author' ? (
{unmanagedDuties ? (
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
该文档未纳入人工审批,不提供送审、审核或批准
</span>
) : showSubmit ? (
<>
{!ast.isClaimed ? (
<button
@@ -469,7 +479,7 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
>
<Lock size={12} /> 文档已锁定
</span>
{currentRole === 'reviewer' ? (
{showReview ? (
<>
<button
onClick={() => openReject('review')}
@@ -522,7 +532,7 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
{/* 3. 批准阶段 */}
{ast.lifecycleState === 'approving' && (
<>
{currentRole === 'approver' ? (
{showApprove ? (
<>
<button
onClick={() => openReject('approve')}
@@ -36,6 +36,7 @@ import {
needsThreeMembersForDuties,
toggleHumanApproval,
} from '../../lib/projectSettingsDuties';
import { ACTIVATION_UNMANAGED_FREEZE_HINT } from '../../lib/blockedGovernanceCopy';
import type { ProjectSettingsIssue } from '../../api/projectSettingsError';
interface ProjectMemberOption {
@@ -582,8 +583,8 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
)}
</div>
<p style={{ fontSize: '12px', color: 'var(--text-muted)', margin: 0 }}>
确认后将锁定工作流配置,运行期间不可直接修改开关与职责。令牌有效期至{' '}
{new Date(preview.expires_at).toLocaleTimeString()}。
确认后将锁定工作流配置,运行期间不可直接修改开关与职责。{ACTIVATION_UNMANAGED_FREEZE_HINT}{' '}
令牌有效期至 {new Date(preview.expires_at).toLocaleTimeString()}。
</p>
</div>
)}
+8 -3
View File
@@ -1,12 +1,17 @@
export const INFLIGHT_CHAT_STATUSES = ['pending', 'streaming_status', 'auto_retrying'] as const;
export const SUCCESS_TERMINAL_CHAT_STATUSES = ['completed', 'pending_author_confirmation'] as const;
export const ERROR_TERMINAL_CHAT_STATUSES = ['failed', 'cancelled', 'quality_failed'] as const;
export const ERROR_TERMINAL_CHAT_STATUSES = [
'failed',
'cancelled',
'quality_failed',
'blocked_pending_governance',
] as const;
export const SUCCESS_TERMINAL_STATUS_RE =
/^(completed|pending_author_confirmation)$/;
export const ERROR_TERMINAL_STATUS_RE = /^(failed|cancelled|quality_failed)$/;
export const ERROR_TERMINAL_STATUS_RE = /^(failed|cancelled|quality_failed|blocked_pending_governance)$/;
export const ANY_TERMINAL_STATUS_RE =
/^(completed|pending_author_confirmation|failed|cancelled|quality_failed)$/;
/^(completed|pending_author_confirmation|failed|cancelled|quality_failed|blocked_pending_governance)$/;
export type SquadObservationFrame = {
userPromptMatched: boolean;
+2 -1
View File
@@ -21,7 +21,8 @@ export function isRemoteTaskTerminal(status: string | null | undefined): boolean
return (
status === 'completed' ||
status === 'pending_author_confirmation' ||
status === 'quality_failed'
status === 'quality_failed' ||
status === 'blocked_pending_governance'
);
}
+1
View File
@@ -117,6 +117,7 @@ export type AssistantMessageStatus =
| 'pending_author_confirmation'
| 'failed'
| 'quality_failed'
| 'blocked_pending_governance'
| 'cancelled';
export interface ChatErrorInfo {