Files
superwork/vscode/webview-ui/src/components/SettingsModal.tsx
T

1349 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 type { ClaudeProfile } from '../hooks/useIssues'
import { X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { SelectMenu } from './ui/select-menu'
type GroupKey = 'auth' | 'network' | 'youtrack' | 'prompts' | 'hooks'
interface SubmitValues {
host: string
token: string
webhookPort: number
webhookSecret: string
brainstormPrompt: string
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
testPrompt: string
prototypePrompt: string
devBranch: string
releaseBranchPrefix: string
jenkinsJob: string
jenkinsProdHost: string
worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
pycharmCommand: string
worktreePostCreateScript: string
worktreePreRemoveScript: string
implTabPreCreateScript: string
implTabPostCloseScript: string
splitNewTerminals: boolean
defaultBrainstormProfilePath: string
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
defaultAskProfilePath: string
defaultRewriteProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: 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
initialWebhookSecret: string
initialBrainstormPrompt: string
initialImplementPlanPrompt: string
initialAutoReview: boolean
initialReviewPrompt: string
initialTestPrompt: string
initialPrototypePrompt: string
initialDevBranch: string
initialReleaseBranchPrefix: string
initialJenkinsJob: string
initialJenkinsProdHost: string
initialWorktreeDirectory: string
initialWorktreeOpenWith: 'vscode' | 'pycharm'
initialPycharmCommand: string
initialWorktreePostCreateScript: string
initialWorktreePreRemoveScript: string
initialImplTabPreCreateScript: string
initialImplTabPostCloseScript: string
initialSplitNewTerminals: boolean
initialDefaultBrainstormProfilePath: string
initialDefaultImplementProfilePath: string
initialDefaultTestProfilePath: string
initialDefaultPrototypeProfilePath: string
initialDefaultAskProfilePath: string
initialDefaultRewriteProfilePath: string
initialConflictResolutionProfilePath: string
initialCommitProfilePath: string
initialPrDiffSummaryProfilePath: 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)]'
const promptTextareaClass = `${inputClass} field-sizing-content min-h-16 resize-y font-mono`
interface FieldProps {
label: string
hint?: React.ReactNode
error?: string
children: React.ReactNode
}
function Field({ label, hint, error, children }: FieldProps) {
return (
<label className="mb-3 block text-xs">
<span className="mb-1 block opacity-80">{label}</span>
{children}
{hint && (
<span className="mt-1 block text-[10px] opacity-60">{hint}</span>
)}
{error && (
<span className="mt-1 block text-[10px] text-state-red">{error}</span>
)}
</label>
)
}
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<ErrorKey, GroupKey> = {
host: 'auth',
token: 'auth',
webhookPort: 'network',
}
export function SettingsModal({
open,
host: initialHost,
errorMessage,
canCancel,
initialTokenSaved,
initialWebhookPort,
initialWebhookSecret,
initialBrainstormPrompt,
initialImplementPlanPrompt,
initialAutoReview,
initialReviewPrompt,
initialTestPrompt,
initialPrototypePrompt,
initialDevBranch,
initialReleaseBranchPrefix,
initialJenkinsJob,
initialJenkinsProdHost,
initialWorktreeDirectory,
initialWorktreeOpenWith,
initialPycharmCommand,
initialWorktreePostCreateScript,
initialWorktreePreRemoveScript,
initialImplTabPreCreateScript,
initialImplTabPostCloseScript,
initialSplitNewTerminals,
initialDefaultBrainstormProfilePath,
initialDefaultImplementProfilePath,
initialDefaultTestProfilePath,
initialDefaultPrototypeProfilePath,
initialDefaultAskProfilePath,
initialDefaultRewriteProfilePath,
initialConflictResolutionProfilePath,
initialCommitProfilePath,
initialPrDiffSummaryProfilePath,
initialCodexModel,
initialCodexReasoningEffort,
initialProfilesDirectory,
initialYoutrackBaseUrl,
initialYoutrackProjectShortName,
initialYoutrackCloseCommand,
initialYoutrackTokenSaved,
profiles,
youtrackProjects,
onListYouTrackProjects,
onSubmit,
onCancel,
}: SettingsModalProps): ReactElement | null {
const [activeGroup, setActiveGroup] = useState<GroupKey>('auth')
const [host, setHost] = useState(initialHost)
const [token, setToken] = useState('')
const [webhookPort, setWebhookPort] = useState<string>(String(initialWebhookPort))
const [webhookSecret, setWebhookSecret] = useState(initialWebhookSecret)
const [brainstormPrompt, setBrainstormPrompt] = useState(initialBrainstormPrompt)
const [implementPlanPrompt, setImplementPlanPrompt] = useState(initialImplementPlanPrompt)
const [autoReview, setAutoReview] = useState(initialAutoReview)
const [reviewPrompt, setReviewPrompt] = useState(initialReviewPrompt)
const [testPrompt, setTestPrompt] = useState(initialTestPrompt)
const [prototypePrompt, setPrototypePrompt] = useState(initialPrototypePrompt)
const [devBranch, setDevBranch] = useState(initialDevBranch)
const [releaseBranchPrefix, setReleaseBranchPrefix] = useState(initialReleaseBranchPrefix)
const [jenkinsJob, setJenkinsJob] = useState(initialJenkinsJob)
const [jenkinsProdHost, setJenkinsProdHost] = useState(initialJenkinsProdHost)
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 [splitNewTerminals, setSplitNewTerminals] = useState(initialSplitNewTerminals)
const [defaultBrainstormProfilePath, setDefaultBrainstormProfilePath] = useState(initialDefaultBrainstormProfilePath)
const [defaultImplementProfilePath, setDefaultImplementProfilePath] = useState(initialDefaultImplementProfilePath)
const [defaultTestProfilePath, setDefaultTestProfilePath] = useState(initialDefaultTestProfilePath)
const [defaultPrototypeProfilePath, setDefaultPrototypeProfilePath] = useState(initialDefaultPrototypeProfilePath)
const [defaultAskProfilePath, setDefaultAskProfilePath] = useState(initialDefaultAskProfilePath)
const [defaultRewriteProfilePath, setDefaultRewriteProfilePath] = useState(initialDefaultRewriteProfilePath)
const [conflictResolutionProfilePath, setConflictResolutionProfilePath] = useState(initialConflictResolutionProfilePath)
const [commitProfilePath, setCommitProfilePath] = useState(initialCommitProfilePath)
const [prDiffSummaryProfilePath, setPrDiffSummaryProfilePath] = useState(initialPrDiffSummaryProfilePath)
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<Partial<Record<ErrorKey, string>>>({})
// 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))
setWebhookSecret(initialWebhookSecret)
setBrainstormPrompt(initialBrainstormPrompt)
setImplementPlanPrompt(initialImplementPlanPrompt)
setAutoReview(initialAutoReview)
setReviewPrompt(initialReviewPrompt)
setTestPrompt(initialTestPrompt)
setPrototypePrompt(initialPrototypePrompt)
setDevBranch(initialDevBranch)
setReleaseBranchPrefix(initialReleaseBranchPrefix)
setJenkinsJob(initialJenkinsJob)
setJenkinsProdHost(initialJenkinsProdHost)
setWorktreeDirectory(initialWorktreeDirectory)
setWorktreeOpenWith(initialWorktreeOpenWith)
setPycharmCommand(initialPycharmCommand)
setWorktreePostCreateScript(initialWorktreePostCreateScript)
setWorktreePreRemoveScript(initialWorktreePreRemoveScript)
setImplTabPreCreateScript(initialImplTabPreCreateScript)
setImplTabPostCloseScript(initialImplTabPostCloseScript)
setSplitNewTerminals(initialSplitNewTerminals)
setDefaultBrainstormProfilePath(initialDefaultBrainstormProfilePath)
setDefaultImplementProfilePath(initialDefaultImplementProfilePath)
setDefaultTestProfilePath(initialDefaultTestProfilePath)
setDefaultPrototypeProfilePath(initialDefaultPrototypeProfilePath)
setDefaultAskProfilePath(initialDefaultAskProfilePath)
setDefaultRewriteProfilePath(initialDefaultRewriteProfilePath)
setConflictResolutionProfilePath(initialConflictResolutionProfilePath)
setCommitProfilePath(initialCommitProfilePath)
setPrDiffSummaryProfilePath(initialPrDiffSummaryProfilePath)
setCodexModel(initialCodexModel)
setCodexReasoningEffort(initialCodexReasoningEffort)
setProfilesDirectory(initialProfilesDirectory)
setYoutrackBaseUrl(initialYoutrackBaseUrl)
setYoutrackProjectShortName(initialYoutrackProjectShortName)
setYoutrackCloseCommand(initialYoutrackCloseCommand)
setYoutrackToken('')
setErrors({})
}, [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<Record<ErrorKey, string>> = {}
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,
webhookSecret: webhookSecret.trim(),
brainstormPrompt,
implementPlanPrompt,
autoReview,
reviewPrompt,
testPrompt,
prototypePrompt,
devBranch: devBranch.trim(),
releaseBranchPrefix: releaseBranchPrefix.trim(),
jenkinsJob: jenkinsJob.trim(),
jenkinsProdHost: jenkinsProdHost.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(),
splitNewTerminals,
// 四个会话默认 profile:空串 = 落到 DEFAULT_PROFILE_PATH
defaultBrainstormProfilePath: defaultBrainstormProfilePath.trim(),
defaultImplementProfilePath: defaultImplementProfilePath.trim(),
defaultTestProfilePath: defaultTestProfilePath.trim(),
defaultPrototypeProfilePath: defaultPrototypeProfilePath.trim(),
defaultAskProfilePath: defaultAskProfilePath.trim(),
defaultRewriteProfilePath: defaultRewriteProfilePath.trim(),
// 冲突解决 profile:空串 = 用 DEFAULT_PROFILE_PATH
conflictResolutionProfilePath: conflictResolutionProfilePath.trim(),
// 提交代码 profile:空串 = 未配置 → 提交严格失败
commitProfilePath: commitProfilePath.trim(),
prDiffSummaryProfilePath: prDiffSummaryProfilePath.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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<form
onSubmit={handleSubmit}
role="dialog"
aria-modal="true"
className="relative flex h-[min(600px,80vh)] w-[min(900px,90vw)] flex-col rounded-md border border-[var(--vscode-panel-border)] bg-[var(--vscode-editor-background)] shadow-xl"
>
{/* Header */}
<div className="flex items-center gap-2 border-b border-[var(--vscode-panel-border)] px-4 py-2">
<h2 className="text-sm font-medium">设置</h2>
{canCancel && onCancel && (
<button
type="button"
onClick={onCancel}
aria-label="关闭"
className="ml-auto grid size-6 place-items-center rounded opacity-60 hover:opacity-100"
>
<X className="size-4" />
</button>
)}
</div>
{/* Body */}
<div className="flex min-h-0 flex-1 flex-row">
{/* Left rail */}
<div className="flex w-40 shrink-0 flex-col border-r border-[var(--vscode-panel-border)] py-2">
{GROUPS.map((g) => {
const active = g.key === activeGroup
return (
<button
key={g.key}
type="button"
onClick={() => setActiveGroup(g.key)}
className={[
'px-3 py-1.5 text-left text-xs',
active
? 'bg-white/10 text-[var(--vscode-foreground)]'
: 'opacity-80 hover:bg-white/5',
].join(' ')}
>
{g.label}
</button>
)
})}
</div>
{/* Right pane */}
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{errorMessage && (
<div className="mb-4 rounded border border-state-red/60 bg-state-red/10 px-3 py-2 text-xs text-state-red">
{errorMessage}
</div>
)}
{activeGroup === 'auth' && (
<>
<Field label="Gitea Host" error={errors.host}>
<input
type="text"
value={host}
onChange={e => setHost(e.target.value)}
placeholder="gitea.example.com"
className={inputClass}
/>
</Field>
<Field
label="Personal Access Token"
error={errors.token}
hint={(
<>
在
{' '}
{tokenPageUrl
? (
<a
href={tokenPageUrl}
target="_blank"
rel="noreferrer"
className="text-[var(--vscode-textLink-foreground)] underline"
>
{tokenPageUrl}
</a>
)
: <span className="opacity-60">填写 Host 后这里会显示生成 token 的链接</span>}
{' '}
生成一个 token
{initialTokenSaved && (
<>
<br />
已保存,留空则保留
</>
)}
</>
)}
>
<input
type="password"
value={token}
onChange={e => setToken(e.target.value)}
placeholder={initialTokenSaved ? '••••••••' : '例如 1a2b3c4d…'}
className={inputClass}
/>
</Field>
<Field
label="开发分支"
hint={(
<>
日常推送的分支,留空默认
{' '}
<code>main</code>
。发布按钮从这条分支切发布分支、统计未发布提交。
</>
)}
>
<input
type="text"
value={devBranch}
onChange={e => setDevBranch(e.target.value)}
placeholder="main"
className={inputClass}
/>
</Field>
<Field
label="发布分支前缀"
hint="线上跑的发布分支名前缀,默认 release/,按钮会自动取最新的一个"
>
<input
type="text"
value={releaseBranchPrefix}
onChange={e => setReleaseBranchPrefix(e.target.value)}
placeholder="release/"
className={inputClass}
/>
</Field>
<Field
label="Jenkins 任务名"
hint={(
<>
留空则不显示「部署到正式服」;触发时执行
{' '}
<code>opencli jks build &lt;任务名&gt; -p REF=… -p REMOTE_HOST=…</code>
</>
)}
>
<input
type="text"
value={jenkinsJob}
onChange={e => setJenkinsJob(e.target.value)}
placeholder="留空 = 不提供部署"
className={inputClass}
/>
</Field>
<Field label="正式服 REMOTE_HOST 值">
<input
type="text"
value={jenkinsProdHost}
onChange={e => setJenkinsProdHost(e.target.value)}
placeholder="正式服"
className={inputClass}
/>
</Field>
<Field
label="头脑风暴默认配置文件"
hint={(
<>
新建工单 / 续聊的头脑风暴会话默认用此 profile;工单详情面板里的「头脑风暴配置文件」优先级更高。留空用 official。
</>
)}
>
<SelectMenu
value={defaultBrainstormProfilePath}
onChange={setDefaultBrainstormProfilePath}
variant="input"
className={inputClass}
options={[
{ value: '', label: '(默认)' },
...profiles.map(p => ({ value: p.path, label: p.name })),
...(defaultBrainstormProfilePath
&& !profiles.some(p => p.path === defaultBrainstormProfilePath)
? [{ value: defaultBrainstormProfilePath, label: `自定义:${defaultBrainstormProfilePath}` }]
: []),
]}
/>
</Field>
<Field
label="实施默认配置文件"
hint={(
<>
实施会话默认用此 profile;工单详情面板里的「实施配置文件」优先级更高。留空用 official。
</>
)}
>
<SelectMenu
value={defaultImplementProfilePath}
onChange={setDefaultImplementProfilePath}
variant="input"
className={inputClass}
options={[
{ value: '', label: '(默认)' },
...profiles.map(p => ({ value: p.path, label: p.name })),
...(defaultImplementProfilePath
&& !profiles.some(p => p.path === defaultImplementProfilePath)
? [{ value: defaultImplementProfilePath, label: `自定义:${defaultImplementProfilePath}` }]
: []),
]}
/>
</Field>
<Field
label="测试默认配置文件"
hint={(
<>
测试会话默认用此 profile;工单详情面板里的「测试配置文件」优先级更高。留空用 official。
</>
)}
>
<SelectMenu
value={defaultTestProfilePath}
onChange={setDefaultTestProfilePath}
variant="input"
className={inputClass}
options={[
{ value: '', label: '(默认)' },
...profiles.map(p => ({ value: p.path, label: p.name })),
...(defaultTestProfilePath
&& !profiles.some(p => p.path === defaultTestProfilePath)
? [{ value: defaultTestProfilePath, label: `自定义:${defaultTestProfilePath}` }]
: []),
]}
/>
</Field>
<Field
label="原型默认配置文件"
hint={(
<>
原型会话默认用此 profile;工单详情面板里的「原型配置文件」优先级更高。留空用 official。
</>
)}
>
<SelectMenu
value={defaultPrototypeProfilePath}
onChange={setDefaultPrototypeProfilePath}
variant="input"
className={inputClass}
options={[
{ value: '', label: '(默认)' },
...profiles.map(p => ({ value: p.path, label: p.name })),
...(defaultPrototypeProfilePath
&& !profiles.some(p => p.path === defaultPrototypeProfilePath)
? [{ value: defaultPrototypeProfilePath, label: `自定义:${defaultPrototypeProfilePath}` }]
: []),
]}
/>
</Field>
<Field
label="问答默认模型"
hint="代码阅读里问 AI 默认使用此模型:代码块下面的问答、预览顶部问这个文件或文件夹、「了解一块代码」都用它;留空沿用官方默认。输入框旁可临时切换,不会修改这里的默认值;重启或修改此设置后恢复默认。"
>
<SelectMenu
value={defaultAskProfilePath}
onChange={setDefaultAskProfilePath}
variant="input"
className={inputClass}
options={[
{ value: '', label: '(默认)' },
...profiles.map(p => ({ value: p.path, label: p.name })),
...(defaultAskProfilePath
&& !profiles.some(p => p.path === defaultAskProfilePath)
? [{ value: defaultAskProfilePath, label: `不可用:${defaultAskProfilePath}` }]
: []),
]}
/>
</Field>
<Field
label="重写注释默认模型"
hint="代码阅读里重写注释默认使用此模型:点按钮重写、在输入框里接着说都用它;留空沿用官方默认。输入框旁可临时切换,不会修改这里的默认值;重启或修改此设置后恢复默认。"
>
<SelectMenu
value={defaultRewriteProfilePath}
onChange={setDefaultRewriteProfilePath}
variant="input"
className={inputClass}
options={[
{ value: '', label: '(默认)' },
...profiles.map(p => ({ value: p.path, label: p.name })),
...(defaultRewriteProfilePath
&& !profiles.some(p => p.path === defaultRewriteProfilePath)
? [{ value: defaultRewriteProfilePath, label: `不可用:${defaultRewriteProfilePath}` }]
: []),
]}
/>
</Field>
<Field
label="冲突解决配置文件"
hint={(
<>
拖到「完成」时若 PR 与 main/dev 冲突,自动开的冲突解决会话使用此 profile;留空用默认 official。
</>
)}
>
<SelectMenu
value={conflictResolutionProfilePath}
onChange={setConflictResolutionProfilePath}
variant="input"
className={inputClass}
options={[
{ value: '', label: '(默认)' },
...profiles.map(p => ({ value: p.path, label: p.name })),
...(conflictResolutionProfilePath
&& !profiles.some(p => p.path === conflictResolutionProfilePath)
? [{ value: conflictResolutionProfilePath, label: `自定义:${conflictResolutionProfilePath}` }]
: []),
]}
/>
</Field>
<Field
label="提交代码 profile"
hint={(
<>
顶部工具栏「提交代码」使用此 profile。留空则提交失败,不会回退默认。
</>
)}
>
<SelectMenu
value={commitProfilePath}
onChange={setCommitProfilePath}
variant="input"
className={inputClass}
options={[
{ value: '', label: '(未配置)' },
...profiles.map(p => ({ value: p.path, label: p.name })),
...(commitProfilePath
&& !profiles.some(p => p.path === commitProfilePath)
? [{ value: commitProfilePath, label: `自定义:${commitProfilePath}` }]
: []),
]}
/>
</Field>
<Field
label="改动说明 profile"
hint={(
<>
「改动」tab 单文件说明和工单 PR 变更摘要使用此 profile。留空则生成失败,不会回退默认。
</>
)}
>
<SelectMenu
value={prDiffSummaryProfilePath}
onChange={setPrDiffSummaryProfilePath}
variant="input"
className={inputClass}
options={[
{ value: '', label: '(未配置)' },
...profiles.map(p => ({ value: p.path, label: p.name })),
...(prDiffSummaryProfilePath
&& !profiles.some(p => p.path === prDiffSummaryProfilePath)
? [{ value: prDiffSummaryProfilePath, label: `自定义:${prDiffSummaryProfilePath}` }]
: []),
]}
/>
</Field>
<Field
label="codex 模型"
hint={(
<>
审查会话使用的 codex 模型(
<code>-c model=</code>
)。留空则用 codex
{' '}
<code>config.toml</code>
{' '}
默认。
</>
)}
>
<input
type="text"
value={codexModel}
onChange={e => setCodexModel(e.target.value)}
placeholder="留空用 codex 默认(如 gpt-5.6-sol)"
className={inputClass}
/>
</Field>
<Field
label="codex 思考级别"
hint={(
<>
审查会话的
{' '}
<code>model_reasoning_effort</code>
;留空则用 codex 默认。
</>
)}
>
<input
type="text"
value={codexReasoningEffort}
onChange={e => setCodexReasoningEffort(e.target.value)}
placeholder="留空用 codex 默认(minimal/low/medium/high/xhigh)"
className={inputClass}
/>
</Field>
</>
)}
{activeGroup === 'network' && (
<>
<Field
label="本地端口"
error={errors.webhookPort}
hint={(
<>
扩展 HTTP server 绑定的端口。在 gitea 仓库 Webhooks 配置里把目标 URL 指向
{' '}
<code>{'http://<你的本机/公网域名>:<此端口>/webhook'}</code>
,事件需勾选:
<b>工单</b>
(issue opened/edited)、
<b>合并请求</b>
(PR opened/synchronize/closed)、
<b>工单评论</b>
(codex 审查回流)。
</>
)}
>
<input
type="number"
min={1}
max={65535}
value={webhookPort}
onChange={e => setWebhookPort(e.target.value)}
className={inputClass}
/>
</Field>
<Field
label="Webhook Secret"
hint={(
<>
与 gitea Webhooks 配置里的
{' '}
<b>密钥文本</b>
{' '}
保持一致,用于校验
{' '}
<code>X-Gitea-Signature</code>
。webhook 走公网隧道时必填,否则任何人都能伪造事件;留空 = 不校验。
</>
)}
>
<input
type="text"
value={webhookSecret}
onChange={e => setWebhookSecret(e.target.value)}
placeholder="留空表示不校验签名"
className={inputClass}
/>
</Field>
<Field
label="开启自动审查"
hint="PR 创建后自动用 codex exec review 跑审查并把结果灌到实施会话终端"
>
<label className="inline-flex cursor-pointer items-center gap-2 text-xs">
<input
type="checkbox"
checked={autoReview}
onChange={e => setAutoReview(e.target.checked)}
/>
<span className="opacity-80">启用</span>
</label>
</Field>
<Field
label="Claude profiles 目录"
hint={(
<>
claude --settings 用的 .json 所在目录。留空自动探测(优先
{' '}
<code>~/Sources/cruldra-profile/claude-config/profiles</code>
)。支持
{' '}
<code>~</code>
。
</>
)}
>
<input
type="text"
value={profilesDirectory}
onChange={e => setProfilesDirectory(e.target.value)}
placeholder="~/Sources/cruldra-profile/claude-config/profiles"
className={inputClass}
/>
</Field>
</>
)}
{activeGroup === 'youtrack' && (
<>
<Field
label="YouTrack Base URL"
hint="例如 https://ziwuxian.youtrack.cloud。留空 = 关闭 YouTrack 源。"
>
<input
type="text"
value={youtrackBaseUrl}
onChange={e => setYoutrackBaseUrl(e.target.value)}
placeholder="https://xxx.youtrack.cloud"
className={inputClass}
/>
</Field>
<Field
label="Permanent Token"
hint={(
<>
在 YouTrack「个人资料 → Account Security → New Token」生成。
{initialYoutrackTokenSaved && (
<>
<br />
已保存,留空则保留
</>
)}
</>
)}
>
<input
type="password"
value={youtrackToken}
onChange={e => setYoutrackToken(e.target.value)}
placeholder={initialYoutrackTokenSaved ? '••••••••' : 'perm-xxxx…'}
className={inputClass}
/>
</Field>
<Field
label="关联项目"
hint="填 shortName(如 LXF),或点「加载项目」从下拉选择。"
>
<div className="flex gap-2">
<input
type="text"
value={youtrackProjectShortName}
onChange={e => setYoutrackProjectShortName(e.target.value)}
placeholder="LXF"
className={inputClass}
/>
<button
type="button"
onClick={() => onListYouTrackProjects(youtrackBaseUrl.trim(), youtrackToken.trim())}
className="shrink-0 rounded border border-[var(--vscode-button-border,transparent)] bg-[var(--vscode-button-secondaryBackground)] px-2 py-1 text-xs text-[var(--vscode-button-secondaryForeground)] hover:opacity-90"
>
{youtrackProjects.status === 'loading' ? '加载中…' : '加载项目'}
</button>
</div>
{youtrackProjects.status === 'ready' && youtrackProjects.projects.length > 0 && (
<SelectMenu
value={youtrackProjectShortName}
onChange={setYoutrackProjectShortName}
className={`${inputClass} mt-2`}
variant="input"
options={[
{ value: '', label: '— 选择项目 —' },
...youtrackProjects.projects.map(p => ({
value: p.shortName,
label: `${p.shortName} · ${p.name}`,
})),
]}
/>
)}
{youtrackProjects.status === 'error' && (
<span className="mt-1 block text-[10px] text-state-red">
加载失败:
{youtrackProjects.error}
</span>
)}
</Field>
<Field
label="关闭命令(可选)"
hint="卡片拖到「完成」时对 YouTrack 工单执行的命令,如 State Fixed。留空 = 自动探测项目里第一个「已解决」状态。"
>
<input
type="text"
value={youtrackCloseCommand}
onChange={e => setYoutrackCloseCommand(e.target.value)}
placeholder="留空 = 自动探测"
className={inputClass}
/>
</Field>
</>
)}
{activeGroup === 'prompts' && (
<>
<Field
label="头脑风暴提示词"
hint={(
<>
Handlebars 模板。
<code>{'{{#if issueNumber}}'}</code>
分支 = 对已有工单续聊(
<code>{'{{issueNumber}}'}</code>
,里面再用
<code>{'{{#if prototypeFile}}'}</code>
嵌原型段落、
<code>{'{{#if prototypeSessionId}}'}</code>
控制原型会话 id 那行);
<code>{'{{else}}'}</code>
分支 = 新建工单(
<code>{'{{userRequest}}'}</code>
、
<code>{'{{nonce}}'}</code>
)。工单 state JSON 的字段(
<code>{'{{planFile}}'}</code>
、
<code>{'{{branch}}'}</code>
等)都能直接用。
</>
)}
>
<textarea
value={brainstormPrompt}
onChange={e => setBrainstormPrompt(e.target.value)}
className={promptTextareaClass}
placeholder="新建工单 + 续聊 + 原型段落,一份模板按条件分叉"
/>
</Field>
<Field
label="实施提示词"
hint={(
<>
可用变量(Handlebars):
<code>{'{{planFile}}'}</code>
、
<code>{'{{issueNumber}}'}</code>
</>
)}
>
<textarea
value={implementPlanPrompt}
onChange={e => setImplementPlanPrompt(e.target.value)}
className={promptTextareaClass}
/>
</Field>
<Field
label="审查提示词"
hint={(
<>
可用变量(Handlebars):
<code>{'{{prNumber}}'}</code>
</>
)}
>
<textarea
value={reviewPrompt}
onChange={e => setReviewPrompt(e.target.value)}
className={promptTextareaClass}
/>
</Field>
<Field
label="测试提示词"
hint={(
<>
可用变量(Handlebars):
<code>{'{{issueNumber}}'}</code>
、
<code>{'{{prRef}}'}</code>
(worktree 里是分支,主仓库里是已合并的 PR)
</>
)}
>
<textarea
value={testPrompt}
onChange={e => setTestPrompt(e.target.value)}
className={promptTextareaClass}
/>
</Field>
<Field
label="原型提示词"
hint={(
<>
可用变量(Handlebars):
<code>{'{{issueNumber}}'}</code>
</>
)}
>
<textarea
value={prototypePrompt}
onChange={e => setPrototypePrompt(e.target.value)}
className={promptTextareaClass}
/>
</Field>
</>
)}
{activeGroup === 'hooks' && (
<>
<Field
label="新开终端分屏"
hint="关掉后,会话终端和看板在同一组里只加标签,不再劈成左右两栏。小屏建议关。"
>
<label className="inline-flex cursor-pointer items-center gap-2 text-xs">
<input
type="checkbox"
checked={splitNewTerminals}
onChange={e => setSplitNewTerminals(e.target.checked)}
/>
<span className="opacity-80">分到右侧一栏</span>
</label>
</Field>
<Field
label="工作树目录"
hint={(
<>
实施 Plan 时创建 worktree 的目录模板。占位符
{' '}
<code>$project_root</code>
(工作区绝对路径)、
<code>$project_name</code>
(工作区目录名)、
<code>$feature_name</code>
(spec/plan 的 slug);开头
{' '}
<code>~</code>
{' '}
展开为主目录。留空 = 默认
{' '}
<code>~/Sources/worktree/$project_name/$feature_name</code>
。
</>
)}
>
<input
type="text"
value={worktreeDirectory}
onChange={e => setWorktreeDirectory(e.target.value)}
placeholder="~/Sources/worktree/$project_name/$feature_name"
className={inputClass}
/>
</Field>
<Field
label="工作树打开方式"
hint={(
<>
点击工单详情里的工作树时用哪个编辑器打开。PyCharm 需
{' '}
<code>pycharm</code>
{' '}
命令在 PATH 中(JetBrains Toolbox 里启用命令行启动器)。
</>
)}
>
<SelectMenu
value={worktreeOpenWith}
onChange={v => setWorktreeOpenWith(v as 'vscode' | 'pycharm')}
className={inputClass}
variant="input"
options={[
{ value: 'vscode', label: 'VS Code' },
{ value: 'pycharm', label: 'PyCharm' },
]}
/>
</Field>
{worktreeOpenWith === 'pycharm' && (
<Field
label="PyCharm 启动命令"
hint={(
<>
可填绝对路径或
{' '}
<code>~</code>
{' '}
开头路径(如
{' '}
<code>~/.local/share/JetBrains/Toolbox/scripts/pycharm</code>
);GUI 启动的 VS Code 可能找不到裸
{' '}
<code>pycharm</code>
,填全路径最稳。
</>
)}
>
<input
type="text"
value={pycharmCommand}
onChange={e => setPycharmCommand(e.target.value)}
placeholder="pycharm"
className={inputClass}
/>
</Field>
)}
<Field
label="worktree 创建后脚本"
hint={(
<>
工单进入"实施"后,
<code>git worktree add</code>
{' '}
成功立刻执行。留空 = 默认
{' '}
<code>.spx/worktree-post-create.sh</code>
;文件不存在 = 静默跳过,不报错。
<br />
环境变量:
<code>WORKTREE_PATH</code>
、
<code>WORKSPACE_ROOT</code>
、
<code>BRANCH</code>
、
<code>ISSUE_NUMBER</code>
、
<code>MAIN_BRANCH</code>
。超时 30s。
</>
)}
>
<input
type="text"
value={worktreePostCreateScript}
onChange={e => setWorktreePostCreateScript(e.target.value)}
placeholder=".spx/worktree-post-create.sh"
className={inputClass}
/>
</Field>
<Field
label="worktree 删除前脚本"
hint={(
<>
工单关闭 / 手动删 worktree 时,
<code>git worktree remove</code>
{' '}
之前执行,便于关闭 IDE / 归档日志。留空 = 默认
{' '}
<code>.spx/worktree-pre-remove.sh</code>
。失败不阻塞删除。
</>
)}
>
<input
type="text"
value={worktreePreRemoveScript}
onChange={e => setWorktreePreRemoveScript(e.target.value)}
placeholder=".spx/worktree-pre-remove.sh"
className={inputClass}
/>
</Field>
<Field
label="实施 tab 创建前脚本"
hint={(
<>
实施 cc tab 创建之前执行;常用于启动 IDE(pycharm 等)。
留空 = 默认
{' '}
<code>.spx/impl-tab-pre-create.sh</code>
。tab 复用 fast path 不触发。
</>
)}
>
<input
type="text"
value={implTabPreCreateScript}
onChange={e => setImplTabPreCreateScript(e.target.value)}
placeholder=".spx/impl-tab-pre-create.sh"
className={inputClass}
/>
</Field>
<Field
label="实施 tab 关闭后脚本"
hint={(
<>
实施 cc tab 被关闭后执行;常用于关闭 pre-create 启动的 IDE 进程。
留空 = 默认
{' '}
<code>.spx/impl-tab-post-close.sh</code>
。
</>
)}
>
<input
type="text"
value={implTabPostCloseScript}
onChange={e => setImplTabPostCloseScript(e.target.value)}
placeholder=".spx/impl-tab-post-close.sh"
className={inputClass}
/>
</Field>
</>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-[var(--vscode-panel-border)] p-3">
{canCancel && onCancel && (
<button
type="button"
onClick={onCancel}
className="rounded border border-[var(--vscode-button-border,transparent)] bg-[var(--vscode-button-secondaryBackground)] px-3 py-1.5 text-xs text-[var(--vscode-button-secondaryForeground)] hover:bg-[var(--vscode-button-secondaryHoverBackground)]"
>
取消
</button>
)}
<button
type="submit"
className="rounded bg-[var(--vscode-button-background)] px-3 py-1.5 text-xs text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)]"
>
保存
</button>
</div>
</form>
</div>
)
}