mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Update all components, hooks, and tests to use the new per-session chatStore API where state is keyed by sessionId under `sessions` and all action methods require sessionId as the first parameter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
229 lines
9.8 KiB
TypeScript
229 lines
9.8 KiB
TypeScript
import { useState, useRef, useEffect } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { useSettingsStore } from '../../stores/settingsStore'
|
|
import { useChatStore } from '../../stores/chatStore'
|
|
import { useSessionStore } from '../../stores/sessionStore'
|
|
import { useTabStore } from '../../stores/tabStore'
|
|
import { useTranslation } from '../../i18n'
|
|
import type { PermissionMode } from '../../types/settings'
|
|
|
|
const MODE_ICONS: Record<PermissionMode, string> = {
|
|
default: 'verified_user',
|
|
acceptEdits: 'bolt',
|
|
plan: 'architecture',
|
|
bypassPermissions: 'gavel',
|
|
dontAsk: 'gavel',
|
|
}
|
|
|
|
type Props = {
|
|
workDir?: string
|
|
/** Controlled mode: override current value */
|
|
value?: PermissionMode
|
|
/** Controlled mode: called on change instead of updating global store */
|
|
onChange?: (mode: PermissionMode) => void
|
|
}
|
|
|
|
export function PermissionModeSelector({ workDir: workDirProp, value, onChange }: Props = {}) {
|
|
const t = useTranslation()
|
|
const { permissionMode: storeMode, setPermissionMode } = useSettingsStore()
|
|
const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode)
|
|
const activeTabId = useTabStore((s) => s.activeTabId)
|
|
const sessions = useSessionStore((s) => s.sessions)
|
|
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
|
const [open, setOpen] = useState(false)
|
|
const [confirmDialog, setConfirmDialog] = useState(false)
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
|
|
const isControlled = value !== undefined
|
|
const currentMode = isControlled ? value : storeMode
|
|
|
|
const PERMISSION_ITEMS: Array<{
|
|
value: PermissionMode
|
|
label: string
|
|
description: string
|
|
icon: string
|
|
color?: string
|
|
}> = [
|
|
{
|
|
value: 'default',
|
|
label: t('permMode.askPermissions'),
|
|
description: t('permMode.askPermDesc'),
|
|
icon: 'verified_user',
|
|
},
|
|
{
|
|
value: 'acceptEdits',
|
|
label: t('permMode.autoAccept'),
|
|
description: t('permMode.autoAcceptDesc'),
|
|
icon: 'bolt',
|
|
},
|
|
{
|
|
value: 'plan',
|
|
label: t('permMode.planMode'),
|
|
description: t('permMode.planModeDesc'),
|
|
icon: 'architecture',
|
|
color: 'text-[var(--color-text-tertiary)]',
|
|
},
|
|
{
|
|
value: 'bypassPermissions',
|
|
label: t('permMode.bypass'),
|
|
description: t('permMode.bypassDesc'),
|
|
icon: 'gavel',
|
|
color: 'text-[var(--color-error)]',
|
|
},
|
|
]
|
|
|
|
const MODE_LABELS: Record<PermissionMode, string> = {
|
|
default: t('permMode.label.default'),
|
|
acceptEdits: t('permMode.label.acceptEdits'),
|
|
plan: t('permMode.label.plan'),
|
|
bypassPermissions: t('permMode.label.bypassPermissions'),
|
|
dontAsk: t('permMode.label.dontAsk'),
|
|
}
|
|
|
|
const activeSession = sessions.find((s) => s.id === activeSessionId)
|
|
const workDir = workDirProp || activeSession?.workDir || '~'
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const handleClick = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
}
|
|
const handleEsc = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setOpen(false)
|
|
}
|
|
document.addEventListener('mousedown', handleClick)
|
|
document.addEventListener('keydown', handleEsc)
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClick)
|
|
document.removeEventListener('keydown', handleEsc)
|
|
}
|
|
}, [open])
|
|
|
|
return (
|
|
<div ref={ref} className="relative">
|
|
<button
|
|
onClick={() => setOpen(!open)}
|
|
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs font-medium text-[var(--color-text-secondary)] transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">{MODE_ICONS[currentMode]}</span>
|
|
<span>{MODE_LABELS[currentMode]}</span>
|
|
<span className="material-symbols-outlined text-[12px]">expand_more</span>
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute left-0 bottom-full mb-2 w-[320px] rounded-xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] z-50 py-2">
|
|
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
|
|
{t('permMode.executionPermissions')}
|
|
</div>
|
|
{PERMISSION_ITEMS.map((item) => (
|
|
<button
|
|
key={item.value}
|
|
onClick={() => {
|
|
if (item.value === 'bypassPermissions') {
|
|
setOpen(false)
|
|
setConfirmDialog(true)
|
|
return
|
|
}
|
|
if (isControlled) {
|
|
onChange?.(item.value)
|
|
} else {
|
|
void setPermissionMode(item.value)
|
|
if (activeTabId) setSessionPermissionMode(activeTabId, item.value)
|
|
}
|
|
setOpen(false)
|
|
}}
|
|
className={`
|
|
w-full flex items-start gap-3 px-4 py-3 text-left transition-colors
|
|
hover:bg-[var(--color-surface-hover)]
|
|
${item.value === currentMode ? 'bg-[var(--color-surface-selected)]' : ''}
|
|
`}
|
|
>
|
|
<span className={`material-symbols-outlined text-[20px] mt-0.5 ${item.color || 'text-[var(--color-text-secondary)]'}`}>
|
|
{item.icon}
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{item.label}</div>
|
|
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{item.description}</div>
|
|
</div>
|
|
{item.value === currentMode && (
|
|
<span className="material-symbols-outlined text-[16px] text-[var(--color-brand)] mt-0.5" style={{ fontVariationSettings: "'FILL' 1" }}>
|
|
check_circle
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Bypass confirmation dialog */}
|
|
{confirmDialog && createPortal(
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 pl-[var(--sidebar-width)]" onClick={() => setConfirmDialog(false)}>
|
|
<div
|
|
className="w-[420px] rounded-2xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 px-5 py-4 bg-[var(--color-error)]/8 border-b border-[var(--color-error)]/15">
|
|
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-[var(--color-error)]/12">
|
|
<span className="material-symbols-outlined text-[22px] text-[var(--color-error)]">warning</span>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-bold text-[var(--color-text-primary)]">{t('permMode.enableBypassTitle')}</div>
|
|
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('permMode.enableBypassSubtitle')}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="px-5 py-4">
|
|
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed mb-3" dangerouslySetInnerHTML={{ __html: t('permMode.enableBypassBody') }} />
|
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-surface-container)] border border-[var(--color-border)]" title={workDir}>
|
|
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)] shrink-0">folder</span>
|
|
<code className="text-xs font-[var(--font-mono)] text-[var(--color-text-primary)] truncate">{workDir}</code>
|
|
</div>
|
|
<ul className="mt-3 space-y-1.5 text-xs text-[var(--color-text-secondary)]">
|
|
<li className="flex items-start gap-2">
|
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)] mt-0.5">check</span>
|
|
{t('permMode.permReadWrite')}
|
|
</li>
|
|
<li className="flex items-start gap-2">
|
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)] mt-0.5">check</span>
|
|
{t('permMode.permShell')}
|
|
</li>
|
|
<li className="flex items-start gap-2">
|
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)] mt-0.5">check</span>
|
|
{t('permMode.permPackages')}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
|
|
<button
|
|
onClick={() => setConfirmDialog(false)}
|
|
className="px-4 py-2 text-xs font-semibold text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
if (isControlled) {
|
|
onChange?.('bypassPermissions')
|
|
} else {
|
|
void setPermissionMode('bypassPermissions')
|
|
if (activeTabId) setSessionPermissionMode(activeTabId, 'bypassPermissions')
|
|
}
|
|
setConfirmDialog(false)
|
|
}}
|
|
className="px-4 py-2 text-xs font-semibold text-white bg-[var(--color-error)] hover:opacity-90 rounded-lg transition-colors"
|
|
>
|
|
{t('permMode.enableBypassBtn')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
)
|
|
}
|