feat(web): Phase 11 對話式 AI UI/UX (#47-59)

Phase 11.1 對話式容器:
- ConversationalView 雙欄佈局 (左側列表 + 右側詳情)
- ApprovalThreadItem 風險等級 + 相對時間顯示
- SSE 即時更新整合

Phase 11.2 批次處理:
- BatchModeSelector 組件 (全部接受/逐一審核/CRITICAL Only)
- POST /api/v1/approvals/bulk-approve API 端點
- CRITICAL + DESTRUCTIVE 安全過濾 (禁止批次核准)

Phase 11.4 鍵盤快捷鍵:
- useKeyboardShortcuts hook (Y/N/方向鍵/Esc)
- Y 鍵長按 2 秒核准 + 頂部進度指示器
- 快捷鍵說明 Modal (Y/N 高亮顯示)

i18n: 100% next-intl 覆蓋

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-25 10:31:35 +08:00
parent 170102a4ee
commit b13b063282
11 changed files with 1341 additions and 9 deletions

View File

@@ -0,0 +1,186 @@
'use client'
/**
* ApprovalThreadItem - 對話列表中的單一審核項目
* ================================================
* Phase 11: #48 ApprovalThread 子組件
*
* Features:
* - 顯示風險等級、標題、時間戳
* - 選中狀態視覺回饋
* - 懸停效果
* - Nothing.tech 風格
*
* i18n: 100% next-intl
*/
import { useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { StatusOrb } from '@/components/ui/status-orb'
import type { ApprovalRequest } from './approval-card'
import { Clock, AlertTriangle, Shield, Zap } from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
interface ApprovalThreadItemProps {
approval: ApprovalRequest
isSelected: boolean
onSelect: () => void
}
// =============================================================================
// Helpers
// =============================================================================
/**
* 風險等級對應的顏色和圖示
*/
function getRiskConfig(riskLevel: ApprovalRequest['riskLevel']) {
switch (riskLevel) {
case 'critical':
return {
color: 'text-status-critical',
bgColor: 'bg-status-critical/10',
borderColor: 'border-status-critical/30',
icon: AlertTriangle,
orbStatus: 'critical' as const,
}
case 'high':
return {
color: 'text-status-warning',
bgColor: 'bg-status-warning/10',
borderColor: 'border-status-warning/30',
icon: Shield,
orbStatus: 'warning' as const,
}
case 'medium':
return {
color: 'text-claw-blue',
bgColor: 'bg-claw-blue/10',
borderColor: 'border-claw-blue/30',
icon: Zap,
orbStatus: 'healthy' as const,
}
case 'low':
default:
return {
color: 'text-nothing-gray-500',
bgColor: 'bg-nothing-gray-100',
borderColor: 'border-nothing-gray-200',
icon: Clock,
orbStatus: 'healthy' as const,
}
}
}
/**
* 格式化時間戳為相對時間
*/
function formatRelativeTime(timestamp: string, t: ReturnType<typeof useTranslations>): string {
const date = new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) {
return t('justNow')
} else if (diffMins < 60) {
return t('minutesAgo', { count: diffMins })
} else if (diffHours < 24) {
return t('hoursAgo', { count: diffHours })
} else {
return t('daysAgo', { count: diffDays })
}
}
// =============================================================================
// Component
// =============================================================================
export function ApprovalThreadItem({
approval,
isSelected,
onSelect,
}: ApprovalThreadItemProps) {
const t = useTranslations('approval')
const riskConfig = useMemo(() => getRiskConfig(approval.riskLevel), [approval.riskLevel])
const RiskIcon = riskConfig.icon
const relativeTime = useMemo(
() => formatRelativeTime(approval.requestedAt, t),
[approval.requestedAt, t]
)
// 提取動作類型摘要
const actionSummary = useMemo(() => {
// 截取 action 的前 30 個字元作為摘要
const action = approval.action || ''
return action.length > 30 ? action.slice(0, 30) + '...' : action
}, [approval.action])
return (
<button
onClick={onSelect}
className={cn(
'relative w-full text-left p-3 rounded-xl transition-all duration-200',
'border border-transparent',
'hover:bg-nothing-gray-50',
'focus:outline-none focus:ring-2 focus:ring-claw-blue/50',
isSelected && [
'bg-claw-blue/5',
'border-claw-blue/30',
'shadow-sm',
],
!isSelected && 'hover:border-nothing-gray-200'
)}
>
{/* 頂部: 風險指示器 + 時間 */}
<div className="flex items-center justify-between mb-2">
<div className={cn(
'flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-mono',
riskConfig.bgColor,
riskConfig.color
)}>
<RiskIcon className="w-3 h-3" />
<span className="uppercase">{approval.riskLevel}</span>
</div>
<span className="text-xs text-nothing-gray-400 font-mono">
{relativeTime}
</span>
</div>
{/* 中間: 動作描述 */}
<div className="mb-2">
<h4 className={cn(
'font-heading text-sm font-medium truncate',
isSelected ? 'text-claw-blue' : 'text-nothing-gray-900'
)}>
{approval.description}
</h4>
</div>
{/* 底部: 動作類型 + 狀態指示 */}
<div className="flex items-center justify-between">
<span className="text-xs text-nothing-gray-500 font-mono truncate max-w-[70%]">
{actionSummary}
</span>
<StatusOrb
status={riskConfig.orbStatus}
size="sm"
pulse={approval.riskLevel === 'critical'}
/>
</div>
{/* 選中指示線 */}
{isSelected && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-claw-blue rounded-r-full" />
)}
</button>
)
}

View File

@@ -0,0 +1,305 @@
'use client'
/**
* BatchModeSelector - 批次處理模式選擇器
* =======================================
* Phase 11: #50 批次處理功能
*
* Features:
* - 全部接受: 批次核准所有低/中風險
* - 逐一審核: 依序審核
* - 僅顯示 CRITICAL: 過濾高風險
*
* 安全限制:
* - CRITICAL + DESTRUCTIVE 禁止批次核准
*
* i18n: 100% next-intl
*/
import { useState, useMemo, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import {
useApprovalStore,
usePendingApprovals,
toFrontendApproval,
} from '@/stores/approval.store'
import { StatusOrb } from '@/components/ui/status-orb'
import {
CheckCircle2,
ListFilter,
AlertTriangle,
Loader2,
Shield,
} from 'lucide-react'
// ApprovalRequest type used implicitly via toFrontendApproval
// =============================================================================
// Types
// =============================================================================
export type BatchMode = 'sequential' | 'filter_critical' | 'bulk_approve'
interface BatchModeSelectorProps {
className?: string
/** 批次核准完成後的回調 */
onBulkComplete?: (succeeded: number, failed: number, skipped: number) => void
/** 簽核者資訊 */
signerId?: string
signerName?: string
}
interface BulkApproveResult {
approval_id: string
success: boolean
message: string
execution_triggered: boolean
}
interface BulkApproveResponse {
total: number
succeeded: number
failed: number
skipped: number
results: BulkApproveResult[]
}
// =============================================================================
// Component
// =============================================================================
export function BatchModeSelector({
className,
onBulkComplete,
signerId = 'user-001',
signerName = 'Demo User',
}: BatchModeSelectorProps) {
const t = useTranslations('approval')
const tBatch = useTranslations('approval.batch')
const pendingApprovals = usePendingApprovals()
const { fetchPending } = useApprovalStore()
const [mode, setMode] = useState<BatchMode>('sequential')
const [isProcessing, setIsProcessing] = useState(false)
// 轉換為前端格式
const approvals = useMemo(() => {
return pendingApprovals.map((a) => toFrontendApproval(a))
}, [pendingApprovals])
// 統計各風險等級數量
const stats = useMemo(() => {
const result = {
total: approvals.length,
low: 0,
medium: 0,
high: 0,
critical: 0,
canBulkApprove: 0,
}
approvals.forEach((a) => {
switch (a.riskLevel) {
case 'low':
result.low++
result.canBulkApprove++
break
case 'medium':
result.medium++
result.canBulkApprove++
break
case 'high':
result.high++
// HIGH 風險允許批次,但 DESTRUCTIVE 不允許
if (a.blastRadius.dataImpact !== 'DESTRUCTIVE') {
result.canBulkApprove++
}
break
case 'critical':
result.critical++
// CRITICAL 禁止批次
break
}
})
return result
}, [approvals])
// 批次核准處理
const handleBulkApprove = useCallback(async () => {
if (stats.canBulkApprove === 0) return
setIsProcessing(true)
try {
// 取得可批次核准的 ID 列表
const bulkApproveIds = approvals
.filter((a) => {
// 排除 CRITICAL
if (a.riskLevel === 'critical') return false
// 排除 DESTRUCTIVE
if (a.blastRadius.dataImpact === 'DESTRUCTIVE') return false
return true
})
.map((a) => a.id)
const response = await fetch('/api/v1/approvals/bulk-approve', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
approval_ids: bulkApproveIds,
signer_id: signerId,
signer_name: signerName,
comment: 'Bulk approved via ConversationalView',
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const result: BulkApproveResponse = await response.json()
// 刷新列表
await fetchPending()
// 回調通知
onBulkComplete?.(result.succeeded, result.failed, result.skipped)
} catch (error) {
console.error('Bulk approve failed:', error)
} finally {
setIsProcessing(false)
}
}, [approvals, stats.canBulkApprove, signerId, signerName, fetchPending, onBulkComplete])
return (
<div className={cn('p-4 bg-white rounded-xl border border-nothing-gray-200', className)}>
{/* 統計概覽 */}
<div className="flex items-center justify-between mb-4">
<h3 className="font-heading text-sm font-bold text-nothing-gray-900">
{tBatch('title')}
</h3>
<div className="flex items-center gap-2 text-xs font-mono">
<span className="text-nothing-gray-400">{stats.total} {t('pendingApprovals')}</span>
</div>
</div>
{/* 風險等級分佈 */}
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-nothing-gray-50">
<StatusOrb status="healthy" size="sm" />
<span className="text-xs font-mono text-nothing-gray-600">
LOW: {stats.low}
</span>
</div>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-claw-blue/5">
<StatusOrb status="healthy" size="sm" />
<span className="text-xs font-mono text-claw-blue">
MED: {stats.medium}
</span>
</div>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-status-warning/5">
<StatusOrb status="warning" size="sm" />
<span className="text-xs font-mono text-status-warning">
HIGH: {stats.high}
</span>
</div>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-status-critical/5">
<StatusOrb status="critical" size="sm" pulse />
<span className="text-xs font-mono text-status-critical">
CRIT: {stats.critical}
</span>
</div>
</div>
{/* 模式選擇按鈕 */}
<div className="space-y-2">
{/* 全部接受 (批次) */}
<button
onClick={handleBulkApprove}
disabled={stats.canBulkApprove === 0 || isProcessing}
className={cn(
'w-full flex items-center justify-between p-3 rounded-lg transition-all',
'border border-nothing-gray-200',
'hover:bg-status-healthy/5 hover:border-status-healthy/30',
'focus:outline-none focus:ring-2 focus:ring-status-healthy/50',
'disabled:opacity-50 disabled:cursor-not-allowed',
mode === 'bulk_approve' && 'bg-status-healthy/10 border-status-healthy/50'
)}
>
<div className="flex items-center gap-2">
{isProcessing ? (
<Loader2 className="w-4 h-4 text-status-healthy animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 text-status-healthy" />
)}
<span className="text-sm font-medium text-nothing-gray-900">
{tBatch('bulkApprove')}
</span>
</div>
<span className="text-xs font-mono text-nothing-gray-400">
{stats.canBulkApprove} {tBatch('eligible')}
</span>
</button>
{/* 逐一審核 */}
<button
onClick={() => setMode('sequential')}
className={cn(
'w-full flex items-center justify-between p-3 rounded-lg transition-all',
'border border-nothing-gray-200',
'hover:bg-nothing-gray-50',
'focus:outline-none focus:ring-2 focus:ring-claw-blue/50',
mode === 'sequential' && 'bg-claw-blue/5 border-claw-blue/30'
)}
>
<div className="flex items-center gap-2">
<ListFilter className="w-4 h-4 text-claw-blue" />
<span className="text-sm font-medium text-nothing-gray-900">
{tBatch('sequential')}
</span>
</div>
<span className="text-xs font-mono text-nothing-gray-400">
{stats.total} {tBatch('items')}
</span>
</button>
{/* 僅顯示 CRITICAL */}
<button
onClick={() => setMode('filter_critical')}
disabled={stats.critical === 0}
className={cn(
'w-full flex items-center justify-between p-3 rounded-lg transition-all',
'border border-nothing-gray-200',
'hover:bg-status-critical/5 hover:border-status-critical/30',
'focus:outline-none focus:ring-2 focus:ring-status-critical/50',
'disabled:opacity-50 disabled:cursor-not-allowed',
mode === 'filter_critical' && 'bg-status-critical/10 border-status-critical/50'
)}
>
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-status-critical" />
<span className="text-sm font-medium text-nothing-gray-900">
{tBatch('criticalOnly')}
</span>
</div>
<span className="text-xs font-mono text-status-critical">
{stats.critical} {tBatch('items')}
</span>
</button>
</div>
{/* 安全提示 */}
<div className="mt-4 flex items-start gap-2 p-2 rounded-lg bg-status-warning/5 border border-status-warning/20">
<Shield className="w-4 h-4 text-status-warning flex-shrink-0 mt-0.5" />
<p className="text-xs text-status-warning">
{tBatch('securityNote')}
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,345 @@
'use client'
/**
* ConversationalView - 對話式 AI 審核介面
* ========================================
* Phase 11: ChatGPT / GitHub Copilot 風格
*
* Features:
* - 左側: ApprovalThread 對話列表
* - 右側: ApprovalCard 詳情面板
* - 鍵盤快捷鍵: Y/N/方向鍵
* - 響應式: Desktop 雙欄 / Mobile 堆疊
*
* i18n: 100% next-intl
*/
import { useState, useCallback, useEffect, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import {
useApprovalStore,
usePendingApprovals,
toFrontendApproval,
} from '@/stores/approval.store'
import { useApprovalSSE } from '@/hooks/useApprovalSSE'
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
import { ApprovalCard } from './approval-card'
import { ApprovalThreadItem } from './approval-thread-item'
import { StatusOrb } from '@/components/ui/status-orb'
import {
MessageSquare,
ChevronLeft,
ChevronRight,
Keyboard,
Wifi,
WifiOff,
} from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
interface ConversationalViewProps {
className?: string
/** 簽核者 ID */
signerId?: string
/** 簽核者名稱 */
signerName?: string
}
// =============================================================================
// Component
// =============================================================================
export function ConversationalView({
className,
signerId = 'user-001',
signerName = 'Demo User',
}: ConversationalViewProps) {
const t = useTranslations('approval')
const tCommon = useTranslations('common')
// Store
const { signApproval, rejectApproval } = useApprovalStore()
const pendingApprovals = usePendingApprovals()
// SSE 連線狀態
const { isConnected, status: sseStatus } = useApprovalSSE({ autoConnect: true })
// 選中的 Approval
const [selectedId, setSelectedId] = useState<string | null>(null)
const [showShortcuts, setShowShortcuts] = useState(false)
// 轉換為前端格式
const approvals = useMemo(() => {
return pendingApprovals.map((a) => toFrontendApproval(a))
}, [pendingApprovals])
// 當前選中的 index
const selectedIndex = useMemo(() => {
if (!selectedId) return -1
return approvals.findIndex((a) => a.id === selectedId)
}, [selectedId, approvals])
// 選中的 Approval 詳情
const selectedApproval = useMemo(() => {
return approvals.find((a) => a.id === selectedId) || null
}, [selectedId, approvals])
// 自動選中第一個
useEffect(() => {
if (!selectedId && approvals.length > 0) {
setSelectedId(approvals[0].id)
}
// 如果選中的已被處理,選下一個
if (selectedId && !approvals.find((a) => a.id === selectedId)) {
if (approvals.length > 0) {
setSelectedId(approvals[0].id)
} else {
setSelectedId(null)
}
}
}, [approvals, selectedId])
// 導航函數
const goToPrev = useCallback(() => {
if (selectedIndex > 0) {
setSelectedId(approvals[selectedIndex - 1].id)
}
}, [selectedIndex, approvals])
const goToNext = useCallback(() => {
if (selectedIndex < approvals.length - 1) {
setSelectedId(approvals[selectedIndex + 1].id)
}
}, [selectedIndex, approvals])
// 簽核/拒絕處理
const handleApprove = useCallback(async (id: string) => {
await signApproval(id, signerId, signerName, 'Approved via Conversational UI')
}, [signApproval, signerId, signerName])
const handleReject = useCallback(async (id: string) => {
await rejectApproval(id, signerId, signerName, 'Rejected via Conversational UI')
}, [rejectApproval, signerId, signerName])
// 鍵盤快捷鍵 (Phase 11.4: Y/N 支援)
const { isYKeyHolding, yKeyProgress } = useKeyboardShortcuts({
holdDuration: 2000,
onApprove: useCallback(() => {
if (selectedId) {
handleApprove(selectedId)
}
}, [selectedId, handleApprove]),
onReject: useCallback(() => {
if (selectedId) {
handleReject(selectedId)
}
}, [selectedId, handleReject]),
onPrev: goToPrev,
onNext: goToNext,
onShowShortcuts: useCallback(() => setShowShortcuts((s) => !s), []),
onClose: useCallback(() => setShowShortcuts(false), []),
enabled: selectedApproval !== null,
})
return (
<div className={cn('flex h-full bg-nothing-gray-50', className)}>
{/* 左側: 對話列表 */}
<div className="w-80 flex-shrink-0 border-r border-nothing-gray-200 bg-white flex flex-col">
{/* 列表 Header */}
<div className="flex-shrink-0 p-4 border-b border-nothing-gray-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-claw-blue" />
<h2 className="font-heading text-base font-bold text-nothing-gray-900">
{t('pendingApprovals')}
</h2>
</div>
<div className="flex items-center gap-2">
{/* SSE 連線狀態 */}
<div
className={cn(
'flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-mono',
isConnected
? 'bg-status-healthy/10 text-status-healthy'
: 'bg-status-warning/10 text-status-warning'
)}
title={`SSE: ${sseStatus}`}
>
{isConnected ? (
<Wifi className="w-3 h-3" />
) : (
<WifiOff className="w-3 h-3" />
)}
<span>{approvals.length}</span>
</div>
{/* 快捷鍵提示 */}
<button
onClick={() => setShowShortcuts((s) => !s)}
className="p-1.5 rounded-lg hover:bg-nothing-gray-100 transition-colors"
title="快捷鍵 (?)"
>
<Keyboard className="w-4 h-4 text-nothing-gray-400" />
</button>
</div>
</div>
{/* 導航提示 */}
{approvals.length > 1 && (
<div className="mt-2 flex items-center justify-between text-xs text-nothing-gray-400">
<span className="font-mono">
{selectedIndex + 1} / {approvals.length}
</span>
<div className="flex items-center gap-1">
<button
onClick={goToPrev}
disabled={selectedIndex <= 0}
className={cn(
'p-1 rounded transition-colors',
selectedIndex <= 0
? 'text-nothing-gray-300 cursor-not-allowed'
: 'hover:bg-nothing-gray-100 text-nothing-gray-500'
)}
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={goToNext}
disabled={selectedIndex >= approvals.length - 1}
className={cn(
'p-1 rounded transition-colors',
selectedIndex >= approvals.length - 1
? 'text-nothing-gray-300 cursor-not-allowed'
: 'hover:bg-nothing-gray-100 text-nothing-gray-500'
)}
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
{/* 列表內容 */}
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{approvals.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-nothing-gray-400">
<StatusOrb status="healthy" size="lg" />
<p className="mt-4 text-sm font-mono">{t('noPendingApprovals')}</p>
</div>
) : (
approvals.map((approval) => (
<ApprovalThreadItem
key={approval.id}
approval={approval}
isSelected={approval.id === selectedId}
onSelect={() => setSelectedId(approval.id)}
/>
))
)}
</div>
</div>
{/* 右側: 詳情面板 */}
<div className="flex-1 flex flex-col overflow-hidden relative">
{/* Y 鍵長按進度指示器 */}
{isYKeyHolding && (
<div className="absolute top-0 left-0 right-0 h-1 bg-nothing-gray-200 z-10">
<div
className="h-full bg-status-healthy transition-all duration-100"
style={{ width: `${yKeyProgress}%` }}
/>
</div>
)}
{selectedApproval ? (
<div className="flex-1 overflow-y-auto p-6">
<ApprovalCard
request={selectedApproval}
onApprove={handleApprove}
onReject={handleReject}
holdDuration={2000}
/>
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-nothing-gray-400">
<MessageSquare className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p className="text-sm font-mono">{t('selectApproval')}</p>
</div>
</div>
)}
</div>
{/* 快捷鍵說明 Modal */}
{showShortcuts && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setShowShortcuts(false)}
>
<div
className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="font-heading text-lg font-bold text-nothing-gray-900 mb-4">
{tCommon('keyboardShortcuts')}
</h3>
<div className="space-y-3">
<ShortcutRow keys={['Y']} label={t('holdYToApprove')} highlight />
<ShortcutRow keys={['N']} label={t('pressNToReject')} />
<ShortcutRow keys={['↑', '←']} label={t('previousApproval')} />
<ShortcutRow keys={['↓', '→']} label={t('nextApproval')} />
<ShortcutRow keys={['?']} label={tCommon('showShortcuts')} />
<ShortcutRow keys={['Esc']} label={tCommon('close')} />
</div>
<p className="mt-4 text-xs text-nothing-gray-400 font-mono">
{t('holdToApproveHint')}
</p>
</div>
</div>
)}
</div>
)
}
// =============================================================================
// Sub-components
// =============================================================================
function ShortcutRow({
keys,
label,
highlight,
}: {
keys: string[]
label: string
highlight?: boolean
}) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
{keys.map((key) => (
<kbd
key={key}
className={cn(
'px-2 py-1 rounded text-xs font-mono border',
highlight
? 'bg-status-healthy/10 text-status-healthy border-status-healthy/30'
: 'bg-nothing-gray-100 text-nothing-gray-700 border-nothing-gray-200'
)}
>
{key}
</kbd>
))}
</div>
<span className={cn(
'text-sm',
highlight ? 'text-status-healthy font-medium' : 'text-nothing-gray-600'
)}>
{label}
</span>
</div>
)
}

View File

@@ -17,6 +17,11 @@ export {
export { LiveApprovalPanel } from './live-approval-panel'
// Phase 11: 對話式 AI UI
export { ConversationalView } from './conversational-view'
export { ApprovalThreadItem } from './approval-thread-item'
export { BatchModeSelector, type BatchMode } from './batch-mode-selector'
// =============================================================================
// Mock Data for Demo
// =============================================================================