From 3de8a7701dfef643f50a6b39e87180a0d57961e7 Mon Sep 17 00:00:00 2001 From: OG T Date: Mon, 23 Mar 2026 14:28:35 +0800 Subject: [PATCH] feat(web): Phase 6.5c UX improvements for Y/n execution feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show actual error message on screen (not just hover tooltip) - Add retry button after error/timeout - Add 30-second timeout warning with "超時" state - Remove auto-dismiss of error (let user see and retry) - Truncate long error messages with full text in tooltip Fixes P0 UX issue: Users can now see what went wrong Co-Authored-By: Claude Opus 4.5 --- .../incident/dual-state-incident-card.tsx | 117 ++++++++++++++++-- 1 file changed, 107 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/incident/dual-state-incident-card.tsx b/apps/web/src/components/incident/dual-state-incident-card.tsx index ffcd947ca..a32a8e611 100644 --- a/apps/web/src/components/incident/dual-state-incident-card.tsx +++ b/apps/web/src/components/incident/dual-state-incident-card.tsx @@ -19,13 +19,20 @@ * - decision.state === 'ready' 時解鎖按鈕 * - 點擊 Y/n 時先建立 Approval 再簽核 * + * Phase 6.5c: UX 改善 (2026-03-23) + * - 錯誤訊息明顯顯示 (不只是 hover) + * - 執行超時提示 (30秒) + * - 重試按鈕 + * * 統帥鐵律: 禁止假數據!UI 永不鎖死! */ -import React, { useState, useCallback } from 'react' +import React, { useState, useCallback, useEffect, useRef } from 'react' import { apiClient, DecisionInfo } from '@/lib/api-client' -type ButtonState = 'idle' | 'loading' | 'approved' | 'rejected' | 'error' +type ButtonState = 'idle' | 'loading' | 'approved' | 'rejected' | 'error' | 'timeout' + +const EXECUTION_TIMEOUT_MS = 30000 // 30 秒超時警告 export interface DualStateIncidentCardProps { id: string @@ -57,6 +64,16 @@ export const DualStateIncidentCard: React.FC = ({ const [buttonState, setButtonState] = useState('idle') const [errorMessage, setErrorMessage] = useState(null) const [currentProposalId, setCurrentProposalId] = useState(proposalId || null) + const timeoutRef = useRef(null) + + // 清理 timeout + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + } + }, []) // Phase 6.5: 決策令牌驅動 // 按鈕可用條件: decision.state === 'ready' 或有 proposalId @@ -84,6 +101,13 @@ export const DualStateIncidentCard: React.FC = ({ setButtonState('loading') setErrorMessage(null) + // Phase 6.5c: 30 秒超時警告 (如果未被清除則觸發) + if (timeoutRef.current) clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => { + setButtonState('timeout') + setErrorMessage('執行超時,請檢查 API 日誌') + }, EXECUTION_TIMEOUT_MS) + try { let approvalId = currentProposalId @@ -109,6 +133,12 @@ export const DualStateIncidentCard: React.FC = ({ const result = await apiClient.signApproval(approvalId, 'commander', 'Authorized via WarRoom') console.log('✅ 簽核回應:', result) + // 清除超時計時器 + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + if (result.status === 'approved' || result.status === 'APPROVED') { setButtonState('approved') console.log('🎯 授權成功,觸發 onApprovalChange') @@ -119,11 +149,18 @@ export const DualStateIncidentCard: React.FC = ({ setButtonState('idle') } } catch (error) { + // 清除超時計時器 + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + console.error('❌ 授權失敗:', error) setButtonState('error') - setErrorMessage(error instanceof Error ? error.message : 'Unknown error') - // 3 秒後恢復 - setTimeout(() => setButtonState('idle'), 3000) + // Phase 6.5c: 更清楚的錯誤訊息 + const errMsg = error instanceof Error ? error.message : String(error) + setErrorMessage(errMsg) + // 不自動恢復,讓用戶看到錯誤並主動點擊重試 } }, [currentProposalId, decision, id, isDecisionReady, buttonState, onApprovalChange]) @@ -142,6 +179,13 @@ export const DualStateIncidentCard: React.FC = ({ setButtonState('loading') setErrorMessage(null) + // 超時計時器 (如果未被清除則觸發) + if (timeoutRef.current) clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => { + setButtonState('timeout') + setErrorMessage('執行超時,請檢查 API 日誌') + }, EXECUTION_TIMEOUT_MS) + try { let approvalId = currentProposalId @@ -159,14 +203,27 @@ export const DualStateIncidentCard: React.FC = ({ } await apiClient.rejectApproval(approvalId, 'Rejected via WarRoom') + + // 清除超時計時器 + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + console.log('✅ 拒絕成功') setButtonState('rejected') onApprovalChange?.(approvalId, 'rejected') } catch (error) { + // 清除超時計時器 + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + console.error('❌ 拒絕失敗:', error) setButtonState('error') - setErrorMessage(error instanceof Error ? error.message : 'Unknown error') - setTimeout(() => setButtonState('idle'), 3000) + const errMsg = error instanceof Error ? error.message : String(error) + setErrorMessage(errMsg) } }, [currentProposalId, decision, id, isDecisionReady, buttonState, onApprovalChange]) @@ -196,9 +253,49 @@ export const DualStateIncidentCard: React.FC = ({ ) case 'error': return ( - - [ 錯誤 ] - +
+
+ + 錯誤 + + +
+ {errorMessage && ( + + {errorMessage.length > 40 ? errorMessage.slice(0, 40) + '...' : errorMessage} + + )} +
+ ) + case 'timeout': + return ( +
+
+ + 超時 + + +
+ + 請檢查 API 日誌 + +
) default: return (