/** * Overlay modal for editing Superpowers settings. Sits on top of the kanban * without unmounting it — matches the LogModal / NewIssueModal pattern: * - Backdrop click is a no-op (only ESC closes, and only when `canCancel`). * - Two-column layout: vertical group rail on the left, fields on the right. * * Replaces the old full-screen `SetupForm.tsx`. */ import type { ReactElement } from 'react' import { useEffect, useState } from 'react' import { X } from 'lucide-react' import type { ClaudeProfile } from '../hooks/useIssues' type GroupKey = 'auth' | 'network' | 'youtrack' | 'prompts' | 'hooks' interface SubmitValues { host: string token: string webhookPort: number brainstormPrompt: string implementPlanPrompt: string autoReview: boolean reviewPrompt: string devBranch: string autoBuildBranch: string worktreeDirectory: string worktreeOpenWith: 'vscode' | 'pycharm' pycharmCommand: string worktreePostCreateScript: string worktreePreRemoveScript: string implTabPreCreateScript: string implTabPostCloseScript: string conflictResolutionProfilePath: string commitProfilePath: string codexModel: string codexReasoningEffort: string profilesDirectory: string youtrackBaseUrl: string youtrackProjectShortName: string youtrackCloseCommand: string youtrackToken: string } /** Project option for the YouTrack project dropdown. */ export interface YouTrackProjectOption { id: string name: string shortName: string } export interface YouTrackProjectsState { status: 'idle' | 'loading' | 'ready' | 'error' projects: YouTrackProjectOption[] error?: string } export interface SettingsModalProps { open: boolean host: string errorMessage?: string canCancel?: boolean initialTokenSaved: boolean initialWebhookPort: number initialBrainstormPrompt: string initialImplementPlanPrompt: string initialAutoReview: boolean initialReviewPrompt: string initialDevBranch: string initialAutoBuildBranch: string initialWorktreeDirectory: string initialWorktreeOpenWith: 'vscode' | 'pycharm' initialPycharmCommand: string initialWorktreePostCreateScript: string initialWorktreePreRemoveScript: string initialImplTabPreCreateScript: string initialImplTabPostCloseScript: string initialConflictResolutionProfilePath: string initialCommitProfilePath: string initialCodexModel: string initialCodexReasoningEffort: string initialProfilesDirectory: string initialYoutrackBaseUrl: string initialYoutrackProjectShortName: string initialYoutrackCloseCommand: string initialYoutrackTokenSaved: boolean profiles: ClaudeProfile[] youtrackProjects: YouTrackProjectsState onListYouTrackProjects: (baseUrl: string, token: string) => void onSubmit: (values: SubmitValues) => void onCancel?: () => void } const inputClass = 'w-full rounded border border-[var(--vscode-input-border,transparent)] bg-[var(--vscode-input-background)] px-2 py-1 text-xs text-[var(--vscode-input-foreground)] outline-none focus:border-[var(--vscode-focusBorder)]' interface FieldProps { label: string hint?: React.ReactNode error?: string children: React.ReactNode } function Field({ label, hint, error, children }: FieldProps) { return ( ) } const GROUPS: Array<{ key: GroupKey, label: string }> = [ { key: 'auth', label: '认证' }, { key: 'network', label: '网络' }, { key: 'youtrack', label: 'YouTrack' }, { key: 'prompts', label: '提示词' }, { key: 'hooks', label: '钩子' }, ] type ErrorKey = 'host' | 'token' | 'webhookPort' /** Map a field name to the group it lives in, so we can auto-switch to the * first invalid field's group on save. */ const FIELD_TO_GROUP: Record = { host: 'auth', token: 'auth', webhookPort: 'network', } export function SettingsModal({ open, host: initialHost, errorMessage, canCancel, initialTokenSaved, initialWebhookPort, initialBrainstormPrompt, initialImplementPlanPrompt, initialAutoReview, initialReviewPrompt, initialDevBranch, initialAutoBuildBranch, initialWorktreeDirectory, initialWorktreeOpenWith, initialPycharmCommand, initialWorktreePostCreateScript, initialWorktreePreRemoveScript, initialImplTabPreCreateScript, initialImplTabPostCloseScript, initialConflictResolutionProfilePath, initialCommitProfilePath, initialCodexModel, initialCodexReasoningEffort, initialProfilesDirectory, initialYoutrackBaseUrl, initialYoutrackProjectShortName, initialYoutrackCloseCommand, initialYoutrackTokenSaved, profiles, youtrackProjects, onListYouTrackProjects, onSubmit, onCancel, }: SettingsModalProps): ReactElement | null { const [activeGroup, setActiveGroup] = useState('auth') const [host, setHost] = useState(initialHost) const [token, setToken] = useState('') const [webhookPort, setWebhookPort] = useState(String(initialWebhookPort)) const [brainstormPrompt, setBrainstormPrompt] = useState(initialBrainstormPrompt) const [implementPlanPrompt, setImplementPlanPrompt] = useState(initialImplementPlanPrompt) const [autoReview, setAutoReview] = useState(initialAutoReview) const [reviewPrompt, setReviewPrompt] = useState(initialReviewPrompt) const [devBranch, setDevBranch] = useState(initialDevBranch) const [autoBuildBranch, setAutoBuildBranch] = useState(initialAutoBuildBranch) const [worktreeDirectory, setWorktreeDirectory] = useState(initialWorktreeDirectory) const [worktreeOpenWith, setWorktreeOpenWith] = useState(initialWorktreeOpenWith) const [pycharmCommand, setPycharmCommand] = useState(initialPycharmCommand) const [worktreePostCreateScript, setWorktreePostCreateScript] = useState(initialWorktreePostCreateScript) const [worktreePreRemoveScript, setWorktreePreRemoveScript] = useState(initialWorktreePreRemoveScript) const [implTabPreCreateScript, setImplTabPreCreateScript] = useState(initialImplTabPreCreateScript) const [implTabPostCloseScript, setImplTabPostCloseScript] = useState(initialImplTabPostCloseScript) const [conflictResolutionProfilePath, setConflictResolutionProfilePath] = useState(initialConflictResolutionProfilePath) const [commitProfilePath, setCommitProfilePath] = useState(initialCommitProfilePath) const [codexModel, setCodexModel] = useState(initialCodexModel) const [codexReasoningEffort, setCodexReasoningEffort] = useState(initialCodexReasoningEffort) const [profilesDirectory, setProfilesDirectory] = useState(initialProfilesDirectory) const [youtrackBaseUrl, setYoutrackBaseUrl] = useState(initialYoutrackBaseUrl) const [youtrackProjectShortName, setYoutrackProjectShortName] = useState(initialYoutrackProjectShortName) const [youtrackCloseCommand, setYoutrackCloseCommand] = useState(initialYoutrackCloseCommand) const [youtrackToken, setYoutrackToken] = useState('') const [errors, setErrors] = useState>>({}) // Reset local state whenever the modal opens with fresh server values. // We intentionally don't depend on initialBrainstormPrompt/initialImplementPlanPrompt // to mid-edit; this effect just primes form state on each (re)open. useEffect(() => { if (!open) return setActiveGroup('auth') setHost(initialHost) setToken('') setWebhookPort(String(initialWebhookPort)) setBrainstormPrompt(initialBrainstormPrompt) setImplementPlanPrompt(initialImplementPlanPrompt) setAutoReview(initialAutoReview) setReviewPrompt(initialReviewPrompt) setDevBranch(initialDevBranch) setAutoBuildBranch(initialAutoBuildBranch) setWorktreeDirectory(initialWorktreeDirectory) setWorktreeOpenWith(initialWorktreeOpenWith) setPycharmCommand(initialPycharmCommand) setWorktreePostCreateScript(initialWorktreePostCreateScript) setWorktreePreRemoveScript(initialWorktreePreRemoveScript) setImplTabPreCreateScript(initialImplTabPreCreateScript) setImplTabPostCloseScript(initialImplTabPostCloseScript) setConflictResolutionProfilePath(initialConflictResolutionProfilePath) setCommitProfilePath(initialCommitProfilePath) setCodexModel(initialCodexModel) setCodexReasoningEffort(initialCodexReasoningEffort) setProfilesDirectory(initialProfilesDirectory) setYoutrackBaseUrl(initialYoutrackBaseUrl) setYoutrackProjectShortName(initialYoutrackProjectShortName) setYoutrackCloseCommand(initialYoutrackCloseCommand) setYoutrackToken('') setErrors({}) // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) // ESC closes when allowed. useEffect(() => { if (!open) return function onKey(e: KeyboardEvent): void { if (e.key === 'Escape' && canCancel && onCancel) { e.preventDefault() onCancel() } } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [open, canCancel, onCancel]) if (!open) return null const trimmedHost = host.trim() const tokenPageUrl = trimmedHost ? `https://${trimmedHost}/user/settings/applications` : '' function handleSubmit(event: React.FormEvent): void { event.preventDefault() const nextErrors: Partial> = {} const trimmedToken = token.trim() if (!trimmedHost) nextErrors.host = 'Host 不能为空' // When a token is already saved server-side, leaving the field blank // means "keep the existing token" — don't flag it as an error. Only // require a new token when nothing is saved yet. if (!trimmedToken && !initialTokenSaved) nextErrors.token = 'Token 不能为空' const portNum = Number.parseInt(webhookPort, 10) if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) nextErrors.webhookPort = '端口必须是 1–65535 的整数' if (Object.keys(nextErrors).length > 0) { setErrors(nextErrors) // Auto-jump to the group containing the first invalid field, so the // inline error is visible without the user hunting for it. const order: ErrorKey[] = ['host', 'token', 'webhookPort'] for (const key of order) { if (nextErrors[key]) { setActiveGroup(FIELD_TO_GROUP[key]) break } } return } setErrors({}) onSubmit({ host: trimmedHost, // Pass raw token (may be empty string when keeping existing). The // extension side detects empty + existing-saved as "preserve". token: trimmedToken, webhookPort: portNum, brainstormPrompt, implementPlanPrompt, autoReview, reviewPrompt, devBranch: devBranch.trim(), autoBuildBranch: autoBuildBranch.trim(), // worktree 目录模板:trim 后留空 = 用默认模板(getSettings 读时回退) worktreeDirectory: worktreeDirectory.trim(), worktreeOpenWith, // pycharm 启动命令:trim 后留空 = 用默认 `pycharm`(getSettings 读时回退) pycharmCommand: pycharmCommand.trim(), // 钩子路径保持原样 trim(前后空格无意义;空串 = 用默认 .spx/*.sh) worktreePostCreateScript: worktreePostCreateScript.trim(), worktreePreRemoveScript: worktreePreRemoveScript.trim(), implTabPreCreateScript: implTabPreCreateScript.trim(), implTabPostCloseScript: implTabPostCloseScript.trim(), // 冲突解决 profile:空串 = 用 DEFAULT_PROFILE_PATH conflictResolutionProfilePath: conflictResolutionProfilePath.trim(), // 提交代码 profile:空串 = 未配置 → 提交严格失败 commitProfilePath: commitProfilePath.trim(), // codex 模型 / 思考级别:trim 后留空 = 用 codex config.toml 默认 codexModel: codexModel.trim(), codexReasoningEffort: codexReasoningEffort.trim(), // profiles 目录:trim 后留空 = 自动探测 profilesDirectory: profilesDirectory.trim(), youtrackBaseUrl: youtrackBaseUrl.trim(), youtrackProjectShortName: youtrackProjectShortName.trim(), youtrackCloseCommand: youtrackCloseCommand.trim(), // Raw (may be empty = keep existing), mirroring the gitea token rule. youtrackToken: youtrackToken.trim(), }) } return (
{/* Header */}

设置

{canCancel && onCancel && ( )}
{/* Body */}
{/* Left rail */}
{GROUPS.map((g) => { const active = g.key === activeGroup return ( ) })}
{/* Right pane */}
{errorMessage && (
{errorMessage}
)} {activeGroup === 'auth' && ( <> setHost(e.target.value)} placeholder="gitea.example.com" className={inputClass} /> 在 {' '} {tokenPageUrl ? ( {tokenPageUrl} ) : 填写 Host 后这里会显示生成 token 的链接} {' '} 生成一个 token {initialTokenSaved && ( <>
已保存,留空则保留 )} )} > setToken(e.target.value)} placeholder={initialTokenSaved ? '••••••••' : '例如 1a2b3c4d…'} className={inputClass} />
日常推送的分支,留空默认 {' '} main 。同步按钮把这条分支的最新提交快进到下面的构建分支。 )} > setDevBranch(e.target.value)} placeholder="main" className={inputClass} /> gitea webhook → Jenkins 监听的分支。留空表示与开发分支相同,同步按钮会一直禁用。 )} > setAutoBuildBranch(e.target.value)} placeholder="留空 = 与开发分支相同" className={inputClass} /> 拖到「完成」时若 PR 与 main/dev 冲突,自动开的冲突解决会话使用此 profile;留空用默认 official。 )} > 顶部工具栏「提交代码」使用此 profile。留空则提交失败,不会回退默认。 )} > 审查会话使用的 codex 模型( -c model= )。留空则用 codex {' '} config.toml {' '} 默认。 )} > setCodexModel(e.target.value)} placeholder="留空用 codex 默认(如 gpt-5.6-sol)" className={inputClass} /> 审查会话的 {' '} model_reasoning_effort ;留空则用 codex 默认。 )} > setCodexReasoningEffort(e.target.value)} placeholder="留空用 codex 默认(minimal/low/medium/high/xhigh)" className={inputClass} /> )} {activeGroup === 'network' && ( <> 扩展 HTTP server 绑定的端口。在 gitea 仓库 Webhooks 配置里把目标 URL 指向 {' '} {'http://<你的本机/公网域名>:<此端口>/webhook'} ,事件需勾选:工单(issue opened/edited)、合并请求(PR opened/synchronize/closed)、工单评论(codex 审查回流)。 )} > setWebhookPort(e.target.value)} className={inputClass} /> claude --settings 用的 .json 所在目录。留空自动探测(优先 {' '} ~/Sources/cruldra-profile/claude-config/profiles )。支持 {' '} ~ 。 )} > setProfilesDirectory(e.target.value)} placeholder="~/Sources/cruldra-profile/claude-config/profiles" className={inputClass} /> )} {activeGroup === 'youtrack' && ( <> setYoutrackBaseUrl(e.target.value)} placeholder="https://xxx.youtrack.cloud" className={inputClass} /> 在 YouTrack「个人资料 → Account Security → New Token」生成。 {initialYoutrackTokenSaved && ( <>
已保存,留空则保留 )} )} > setYoutrackToken(e.target.value)} placeholder={initialYoutrackTokenSaved ? '••••••••' : 'perm-xxxx…'} className={inputClass} />
setYoutrackProjectShortName(e.target.value)} placeholder="LXF" className={inputClass} />
{youtrackProjects.status === 'ready' && youtrackProjects.projects.length > 0 && ( )} {youtrackProjects.status === 'error' && ( 加载失败: {youtrackProjects.error} )}
setYoutrackCloseCommand(e.target.value)} placeholder="留空 = 自动探测" className={inputClass} /> )} {activeGroup === 'prompts' && ( <> 可用占位符: {'{userRequest}'}{'{nonce}'} 。同时承担:建工单初始指令 + 会话内 spec/plan 路径写回 issue body 的持续约定。 )} >