feat(web): Phase 6.5c UX improvements for Y/n execution feedback

- 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 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-23 14:28:35 +08:00
parent d1fb3aa010
commit 3de8a7701d

View File

@@ -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<DualStateIncidentCardProps> = ({
const [buttonState, setButtonState] = useState<ButtonState>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [currentProposalId, setCurrentProposalId] = useState<string | null>(proposalId || null)
const timeoutRef = useRef<NodeJS.Timeout | null>(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<DualStateIncidentCardProps> = ({
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<DualStateIncidentCardProps> = ({
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<DualStateIncidentCardProps> = ({
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<DualStateIncidentCardProps> = ({
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<DualStateIncidentCardProps> = ({
}
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<DualStateIncidentCardProps> = ({
)
case 'error':
return (
<span className="px-3 py-1 bg-orange-500 text-white text-xs" title={errorMessage || ''}>
[ ]
</span>
<div className="flex flex-col gap-1 items-end">
<div className="flex items-center gap-1">
<span className="px-2 py-1 bg-red-600 text-white text-xs">
</span>
<button
onClick={() => {
setButtonState('idle')
setErrorMessage(null)
}}
className="px-2 py-1 bg-neutral-700 text-white text-xs hover:bg-neutral-600 transition-colors"
>
</button>
</div>
{errorMessage && (
<span className="text-[10px] text-red-600 max-w-[200px] truncate" title={errorMessage}>
{errorMessage.length > 40 ? errorMessage.slice(0, 40) + '...' : errorMessage}
</span>
)}
</div>
)
case 'timeout':
return (
<div className="flex flex-col gap-1 items-end">
<div className="flex items-center gap-1">
<span className="px-2 py-1 bg-amber-500 text-white text-xs animate-pulse">
</span>
<button
onClick={() => {
setButtonState('idle')
setErrorMessage(null)
}}
className="px-2 py-1 bg-neutral-700 text-white text-xs hover:bg-neutral-600 transition-colors"
>
</button>
</div>
<span className="text-[10px] text-amber-600">
API
</span>
</div>
)
default:
return (