Adds item-level search inside Settings so users can find concrete settings like provider auth, agent mode, terminal font size, tunnel options, notification events, and similar controls instead of only filtering top-level pages. Groups search results by Settings page and shows localized labels plus optional descriptions where useful. Supports keyboard navigation with Arrow Up/Down, Enter, and Escape, matching the existing autocomplete interaction style. Opens the correct Settings page or split-page draft state before scrolling to the matching control. Highlights the matched setting with a subtle token-based background so users can see where they landed without an aggressive outline. Adds explicit data-settings-item anchors across Settings pages and a centralized search registry with runtime/mobile availability guards. Updates Settings UI skill guidance so future Settings changes keep search registry entries, anchors, localization, and availability guards in sync.
423 lines
19 KiB
TypeScript
423 lines
19 KiB
TypeScript
import React from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { toast } from '@/components/ui';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import {
|
|
fetchMagicPromptOverrides,
|
|
getDefaultMagicPromptTemplate,
|
|
getMagicPromptDefinition,
|
|
resetAllMagicPromptOverrides,
|
|
resetMagicPromptOverride,
|
|
saveMagicPromptOverride,
|
|
type MagicPromptId,
|
|
} from '@/lib/magicPrompts';
|
|
import { useMagicPromptsStore } from '@/stores/useMagicPromptsStore';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
type PromptBlock = {
|
|
id: MagicPromptId;
|
|
titleKey: string;
|
|
};
|
|
|
|
type PromptPageConfig = {
|
|
titleKey: string;
|
|
descriptionKey: string;
|
|
blocks: PromptBlock[];
|
|
};
|
|
|
|
const PROMPT_PAGE_MAP: Record<string, PromptPageConfig> = {
|
|
'git.commit.generate': {
|
|
titleKey: 'settings.magicPrompts.page.group.gitCommitGenerate.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.gitCommitGenerate.description',
|
|
blocks: [
|
|
{ id: 'git.commit.generate.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'git.commit.generate.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'git.pr.generate': {
|
|
titleKey: 'settings.magicPrompts.page.group.gitPrGenerate.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.gitPrGenerate.description',
|
|
blocks: [
|
|
{ id: 'git.pr.generate.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'git.pr.generate.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'github.pr.review': {
|
|
titleKey: 'settings.magicPrompts.page.group.githubPrReview.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.githubPrReview.description',
|
|
blocks: [
|
|
{ id: 'github.pr.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'github.pr.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'github.issue.review': {
|
|
titleKey: 'settings.magicPrompts.page.group.githubIssueReview.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.githubIssueReview.description',
|
|
blocks: [
|
|
{ id: 'github.issue.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'github.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'github.pr.checks.review': {
|
|
titleKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.description',
|
|
blocks: [
|
|
{ id: 'github.pr.checks.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'github.pr.checks.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'github.pr.comments.review': {
|
|
titleKey: 'settings.magicPrompts.page.group.githubPrCommentsReview.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.githubPrCommentsReview.description',
|
|
blocks: [
|
|
{ id: 'github.pr.comments.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'github.pr.comments.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'github.pr.comment.single': {
|
|
titleKey: 'settings.magicPrompts.page.group.githubSinglePrCommentReview.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.githubSinglePrCommentReview.description',
|
|
blocks: [
|
|
{ id: 'github.pr.comment.single.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'github.pr.comment.single.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'git.conflict.resolve': {
|
|
titleKey: 'settings.magicPrompts.page.group.gitConflictResolve.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.gitConflictResolve.description',
|
|
blocks: [
|
|
{ id: 'git.conflict.resolve.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'git.conflict.resolve.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'git.integrate.cherrypick.resolve': {
|
|
titleKey: 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.description',
|
|
blocks: [
|
|
{ id: 'git.integrate.cherrypick.resolve.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'git.integrate.cherrypick.resolve.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'plan.improve': {
|
|
titleKey: 'settings.magicPrompts.page.group.planImprove.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.planImprove.description',
|
|
blocks: [
|
|
{ id: 'plan.improve.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'plan.improve.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'plan.todo': {
|
|
titleKey: 'settings.magicPrompts.page.group.planTodo.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.planTodo.description',
|
|
blocks: [
|
|
{ id: 'plan.todo.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'plan.todo.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'plan.implement': {
|
|
titleKey: 'settings.magicPrompts.page.group.planImplement.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.planImplement.description',
|
|
blocks: [
|
|
{ id: 'plan.implement.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'plan.implement.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'session.summary': {
|
|
titleKey: 'settings.magicPrompts.page.group.sessionSummary.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.sessionSummary.description',
|
|
blocks: [
|
|
{ id: 'session.summary.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'session.summary.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'session.review': {
|
|
titleKey: 'settings.magicPrompts.page.group.sessionWorkspaceReview.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.sessionWorkspaceReview.description',
|
|
blocks: [
|
|
{ id: 'session.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'session.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'session.plan': {
|
|
titleKey: 'settings.magicPrompts.page.group.sessionFeaturePlan.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.sessionFeaturePlan.description',
|
|
blocks: [
|
|
{ id: 'session.plan.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'session.plan.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'session.catchup': {
|
|
titleKey: 'settings.magicPrompts.page.group.sessionCatchUp.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.sessionCatchUp.description',
|
|
blocks: [
|
|
{ id: 'session.catchup.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'session.catchup.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'session.debug': {
|
|
titleKey: 'settings.magicPrompts.page.group.sessionDebug.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.sessionDebug.description',
|
|
blocks: [
|
|
{ id: 'session.debug.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'session.debug.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'session.weigh': {
|
|
titleKey: 'settings.magicPrompts.page.group.sessionWeigh.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.sessionWeigh.description',
|
|
blocks: [
|
|
{ id: 'session.weigh.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'session.weigh.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'session.explore': {
|
|
titleKey: 'settings.magicPrompts.page.group.sessionExplore.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.sessionExplore.description',
|
|
blocks: [
|
|
{ id: 'session.explore.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'session.explore.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
'session.fusion': {
|
|
titleKey: 'settings.magicPrompts.page.group.sessionFusion.title',
|
|
descriptionKey: 'settings.magicPrompts.page.group.sessionFusion.description',
|
|
blocks: [
|
|
{ id: 'session.fusion.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
|
{ id: 'session.fusion.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
|
],
|
|
},
|
|
};
|
|
|
|
const hasOwn = (input: Record<string, string>, key: string) => Object.prototype.hasOwnProperty.call(input, key);
|
|
const isVisiblePromptId = (id: MagicPromptId): boolean => id.endsWith('.visible');
|
|
|
|
export const MagicPromptsPage: React.FC = () => {
|
|
const { t } = useI18n();
|
|
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
|
|
const selectedPromptId = useMagicPromptsStore((state) => state.selectedPromptId);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [overrides, setOverrides] = React.useState<Record<string, string>>({});
|
|
const [drafts, setDrafts] = React.useState<Record<string, string>>({});
|
|
const [savingIds, setSavingIds] = React.useState<Record<string, boolean>>({});
|
|
const [resettingIds, setResettingIds] = React.useState<Record<string, boolean>>({});
|
|
const [resettingAll, setResettingAll] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
let active = true;
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const nextOverrides = await fetchMagicPromptOverrides();
|
|
if (!active) return;
|
|
setOverrides(nextOverrides);
|
|
} catch (error) {
|
|
console.warn('Failed to load magic prompts:', error);
|
|
toast.error(t('settings.magicPrompts.page.toast.loadFailed'));
|
|
} finally {
|
|
if (active) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
void load();
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [t]);
|
|
|
|
const pageConfig = PROMPT_PAGE_MAP[selectedPromptId] ?? PROMPT_PAGE_MAP['git.commit.generate'];
|
|
const getBaseline = React.useCallback((id: MagicPromptId) => {
|
|
return hasOwn(overrides, id) ? overrides[id] : getDefaultMagicPromptTemplate(id);
|
|
}, [overrides]);
|
|
|
|
const getDraft = React.useCallback((id: MagicPromptId) => {
|
|
return drafts[id] ?? getBaseline(id);
|
|
}, [drafts, getBaseline]);
|
|
|
|
const setDraft = React.useCallback((id: MagicPromptId, value: string) => {
|
|
setDrafts((current) => {
|
|
if (current[id] === value) {
|
|
return current;
|
|
}
|
|
return { ...current, [id]: value };
|
|
});
|
|
}, []);
|
|
|
|
const savePrompt = React.useCallback(async (id: MagicPromptId) => {
|
|
const value = getDraft(id);
|
|
if (isVisiblePromptId(id) && value.trim().length === 0) {
|
|
toast.error(t('settings.magicPrompts.page.toast.visiblePromptRequired'));
|
|
return;
|
|
}
|
|
setSavingIds((current) => ({ ...current, [id]: true }));
|
|
try {
|
|
const payload = value === getDefaultMagicPromptTemplate(id)
|
|
? await resetMagicPromptOverride(id)
|
|
: await saveMagicPromptOverride(id, value);
|
|
setOverrides(payload.overrides);
|
|
toast.success(t('settings.magicPrompts.page.toast.saved'));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
toast.error(t('settings.magicPrompts.page.toast.saveFailed'), { description: message });
|
|
} finally {
|
|
setSavingIds((current) => ({ ...current, [id]: false }));
|
|
}
|
|
}, [getDraft, t]);
|
|
|
|
const resetPrompt = React.useCallback(async (id: MagicPromptId) => {
|
|
setResettingIds((current) => ({ ...current, [id]: true }));
|
|
try {
|
|
const payload = await resetMagicPromptOverride(id);
|
|
setOverrides(payload.overrides);
|
|
setDrafts((current) => ({
|
|
...current,
|
|
[id]: getDefaultMagicPromptTemplate(id),
|
|
}));
|
|
toast.success(t('settings.magicPrompts.page.toast.resetSuccess'));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
toast.error(t('settings.magicPrompts.page.toast.resetFailed'), { description: message });
|
|
} finally {
|
|
setResettingIds((current) => ({ ...current, [id]: false }));
|
|
}
|
|
}, [t]);
|
|
|
|
const handleResetAll = React.useCallback(async () => {
|
|
setResettingAll(true);
|
|
try {
|
|
const payload = await resetAllMagicPromptOverrides();
|
|
setOverrides(payload.overrides);
|
|
setDrafts({});
|
|
toast.success(t('settings.magicPrompts.page.toast.resetAllSuccess'));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
toast.error(t('settings.magicPrompts.page.toast.resetAllFailed'), { description: message });
|
|
} finally {
|
|
setResettingAll(false);
|
|
}
|
|
}, [t]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="py-6 px-6 flex items-center gap-2 text-muted-foreground">
|
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label={t('settings.magicPrompts.page.loading.aria')} />
|
|
<span className="typography-ui">{t('settings.magicPrompts.page.loading.text')}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full overflow-auto">
|
|
<div className="mx-auto w-full max-w-4xl px-6 py-6 space-y-4">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="typography-ui-header font-semibold text-foreground">{tUnsafe(pageConfig.titleKey)}</h2>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent sideOffset={8} className="max-w-xs">
|
|
{tUnsafe(pageConfig.descriptionKey)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
data-settings-item="magic-prompts.reset-overrides"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
void handleResetAll();
|
|
}}
|
|
disabled={resettingAll || Object.keys(overrides).length === 0}
|
|
>
|
|
{resettingAll ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetAllOverrides')}
|
|
</Button>
|
|
</div>
|
|
|
|
{pageConfig.blocks.map((block, index) => {
|
|
const definition = getMagicPromptDefinition(block.id);
|
|
const baseline = getBaseline(block.id);
|
|
const draft = getDraft(block.id);
|
|
const isOverridden = hasOwn(overrides, block.id);
|
|
const isDirty = draft !== baseline;
|
|
const isInvalidEmptyVisiblePrompt = isVisiblePromptId(block.id) && draft.trim().length === 0;
|
|
const saving = savingIds[block.id] === true;
|
|
const resetting = resettingIds[block.id] === true;
|
|
|
|
return (
|
|
<section
|
|
key={block.id}
|
|
data-settings-item={isVisiblePromptId(block.id) ? 'magic-prompts.visible-prompt' : 'magic-prompts.instructions'}
|
|
className={index > 0 ? 'space-y-3 pt-5 border-t border-border' : 'space-y-3'}
|
|
>
|
|
<div className="space-y-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h3 className="typography-ui-label text-foreground">{tUnsafe(block.titleKey)}</h3>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent sideOffset={8} className="max-w-xs">
|
|
{definition.description}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
{definition.placeholders && definition.placeholders.length > 0 && (
|
|
<div className="typography-micro text-muted-foreground">
|
|
{t('settings.magicPrompts.page.placeholdersLabel')}{' '}
|
|
{definition.placeholders.map((item) => `{{${item.key}}}`).join(', ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Textarea
|
|
value={draft}
|
|
onChange={(event) => setDraft(block.id, event.target.value)}
|
|
className="min-h-[220px] font-mono text-sm"
|
|
/>
|
|
{isInvalidEmptyVisiblePrompt && (
|
|
<div className="typography-micro text-[var(--status-error)]">{t('settings.magicPrompts.page.validation.visiblePromptRequired')}</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="typography-micro text-muted-foreground">
|
|
{isDirty
|
|
? t('settings.magicPrompts.page.status.unsavedChanges')
|
|
: isOverridden
|
|
? t('settings.magicPrompts.page.status.usingSavedOverride')
|
|
: t('settings.magicPrompts.page.status.usingBuiltinDefault')}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
void resetPrompt(block.id);
|
|
}}
|
|
disabled={!isOverridden || saving || resetting}
|
|
>
|
|
{resetting ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetToDefault')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
void savePrompt(block.id);
|
|
}}
|
|
disabled={!isDirty || saving || resetting || isInvalidEmptyVisiblePrompt}
|
|
>
|
|
{saving ? t('settings.common.actions.saving') : t('settings.magicPrompts.page.actions.save')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|