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:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user