feat(settings): add response style presets

This commit is contained in:
Bohdan Triapitsyn
2026-05-01 01:27:31 +03:00
parent bcbf34b1d1
commit c924e85a29
12 changed files with 407 additions and 21 deletions
@@ -72,6 +72,8 @@ import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
import { usePermissionStore } from '@/stores/permissionStore';
import { extractGitChangedFiles } from './changedFiles';
import { useI18n } from '@/lib/i18n';
import { fetchResponseStyleInstruction } from '@/lib/responseStyle';
import { getSyncMessages } from '@/sync/sync-refs';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
const EMPTY_QUEUE: QueuedMessage[] = [];
@@ -86,6 +88,10 @@ const VS_CODE_DROP_DATA_TYPES = [
'text/plain',
];
const hasUserMessages = (sessionId: string, directory?: string) => {
return getSyncMessages(sessionId, directory).some((message) => message.role === 'user');
};
const FILE_URI_PREFIX = 'file://';
const encodeFilePath = (filepath: string): string => {
@@ -1547,6 +1553,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
}
const currentSessionDirectory = currentSessionId
? useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory
: currentDirectory;
const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false);
if (shouldAddResponseStyle) {
const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null);
if (responseStyleInstruction) {
additionalParts.push({
text: responseStyleInstruction,
synthetic: true,
});
}
}
// Collect all attachments for error recovery
const allAttachments = [
...primaryAttachments,
@@ -3,9 +3,24 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RiInformationLine } from '@remixicon/react';
import {
getResponseStylePresetInstructions,
isResponseStylePreset,
RESPONSE_STYLE_PRESETS,
type ResponseStylePreset,
} from '@/lib/responseStyle';
import type { DesktopSettings } from '@/lib/desktop';
const AGENTS_MD_PATH = '~/.config/opencode/AGENTS.md';
@@ -18,14 +33,49 @@ const normalizeAgentsMdContent = (content: string) => {
return content.length > 0 && !content.endsWith('\n') ? `${content}\n` : content;
};
const saveBehaviorSetting = async (globalBehaviorPrompt: string, fallbackError: string) => {
type ResponseStyleValue = ResponseStylePreset | 'custom';
type BehaviorSettingsState = {
prompt: string;
responseStyleEnabled: boolean;
responseStylePreset: ResponseStyleValue;
responseStyleCustomInstructions: string;
};
const DEFAULT_BEHAVIOR_SETTINGS: BehaviorSettingsState = {
prompt: '',
responseStyleEnabled: false,
responseStylePreset: 'concise',
responseStyleCustomInstructions: '',
};
const getResponseStylePreview = (preset: ResponseStyleValue, customInstructions: string) => {
return preset === 'custom' ? customInstructions : getResponseStylePresetInstructions(preset);
};
const sanitizeResponseStylePreset = (value: unknown): ResponseStyleValue => {
if (value === 'custom') return 'custom';
return isResponseStylePreset(value) ? value : 'concise';
};
const RESPONSE_STYLE_OPTION_LABEL_KEYS: Record<ResponseStylePreset, I18nKey> = {
concise: 'settings.behavior.page.responseStyle.option.concise',
detailed: 'settings.behavior.page.responseStyle.option.detailed',
mentor: 'settings.behavior.page.responseStyle.option.mentor',
pushback: 'settings.behavior.page.responseStyle.option.pushback',
noFiller: 'settings.behavior.page.responseStyle.option.noFiller',
matchEnergy: 'settings.behavior.page.responseStyle.option.matchEnergy',
warmPeer: 'settings.behavior.page.responseStyle.option.warmPeer',
};
const saveBehaviorSetting = async (settings: Partial<DesktopSettings>, fallbackError: string) => {
const response = await fetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ globalBehaviorPrompt }),
body: JSON.stringify(settings),
});
if (!response.ok) {
@@ -36,9 +86,17 @@ const saveBehaviorSetting = async (globalBehaviorPrompt: string, fallbackError:
export const BehaviorPage: React.FC = () => {
const { t } = useI18n();
const [prompt, setPrompt] = React.useState('');
const [responseStyleEnabled, setResponseStyleEnabled] = React.useState(DEFAULT_BEHAVIOR_SETTINGS.responseStyleEnabled);
const [responseStylePreset, setResponseStylePreset] = React.useState<ResponseStyleValue>(DEFAULT_BEHAVIOR_SETTINGS.responseStylePreset);
const [responseStyleCustomInstructions, setResponseStyleCustomInstructions] = React.useState(DEFAULT_BEHAVIOR_SETTINGS.responseStyleCustomInstructions);
const [isLoading, setIsLoading] = React.useState(true);
const [isSaving, setIsSaving] = React.useState(false);
const [initialPrompt, setInitialPrompt] = React.useState('');
const lastSavedResponseStyleRef = React.useRef<{
enabled: boolean;
preset: ResponseStyleValue;
custom: string;
} | null>(null);
React.useEffect(() => {
const abort = new AbortController();
@@ -58,23 +116,39 @@ export const BehaviorPage: React.FC = () => {
}),
]);
let settingsPrompt = '';
let nextSettings: BehaviorSettingsState = DEFAULT_BEHAVIOR_SETTINGS;
if (settingsRes.ok) {
const data = await settingsRes.json();
nextSettings = {
...nextSettings,
responseStyleEnabled: data.responseStyleEnabled === true,
responseStylePreset: sanitizeResponseStylePreset(data.responseStylePreset),
responseStyleCustomInstructions: typeof data.responseStyleCustomInstructions === 'string'
? data.responseStyleCustomInstructions
: '',
};
if (typeof data.globalBehaviorPrompt === 'string') {
settingsPrompt = data.globalBehaviorPrompt;
nextSettings = { ...nextSettings, prompt: data.globalBehaviorPrompt };
}
}
if (!settingsPrompt.trim() && agentsMdRes.ok) {
if (!nextSettings.prompt.trim() && agentsMdRes.ok) {
const agentsData = await agentsMdRes.json();
if (typeof agentsData.content === 'string') {
settingsPrompt = agentsData.content;
nextSettings = { ...nextSettings, prompt: agentsData.content };
}
}
setPrompt(settingsPrompt);
setInitialPrompt(settingsPrompt);
setPrompt(nextSettings.prompt);
setResponseStyleEnabled(nextSettings.responseStyleEnabled);
setResponseStylePreset(nextSettings.responseStylePreset);
setResponseStyleCustomInstructions(nextSettings.responseStyleCustomInstructions);
setInitialPrompt(nextSettings.prompt);
lastSavedResponseStyleRef.current = {
enabled: nextSettings.responseStyleEnabled,
preset: nextSettings.responseStylePreset,
custom: nextSettings.responseStyleCustomInstructions,
};
} catch (error) {
if ((error as Error).name !== 'AbortError') {
console.warn('Failed to load behavior settings:', error);
@@ -88,7 +162,43 @@ export const BehaviorPage: React.FC = () => {
return () => abort.abort();
}, []);
const isDirty = prompt !== initialPrompt;
React.useEffect(() => {
if (isLoading) return;
const last = lastSavedResponseStyleRef.current;
if (
last &&
last.enabled === responseStyleEnabled &&
last.preset === responseStylePreset &&
last.custom === responseStyleCustomInstructions
) {
return;
}
const next = {
enabled: responseStyleEnabled,
preset: responseStylePreset,
custom: responseStyleCustomInstructions,
};
const timer = setTimeout(async () => {
try {
await saveBehaviorSetting({
responseStyleEnabled: next.enabled,
responseStylePreset: next.preset,
responseStyleCustomInstructions: next.custom,
}, t('settings.behavior.page.toast.saveFailed'));
lastSavedResponseStyleRef.current = next;
} catch (error) {
const message = error instanceof Error ? error.message : t('settings.behavior.page.toast.saveFailed');
toast.error(message);
}
}, 400);
return () => clearTimeout(timer);
}, [responseStyleEnabled, responseStylePreset, responseStyleCustomInstructions, isLoading, t]);
const responseStylePreview = getResponseStylePreview(responseStylePreset, responseStyleCustomInstructions);
const isPromptDirty = prompt !== initialPrompt;
const handleSave = async () => {
setIsSaving(true);
@@ -107,7 +217,9 @@ export const BehaviorPage: React.FC = () => {
throw new Error(await readApiError(response, t('settings.behavior.page.toast.saveFailed')));
}
await saveBehaviorSetting(content, t('settings.behavior.page.toast.saveFailed'));
await saveBehaviorSetting({
globalBehaviorPrompt: content,
}, t('settings.behavior.page.toast.saveFailed'));
setPrompt(content);
setInitialPrompt(content);
@@ -154,7 +266,7 @@ export const BehaviorPage: React.FC = () => {
</div>
</div>
<section className="px-2 pb-2 pt-0">
<section className="px-2 pb-2 pt-0 space-y-3">
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
@@ -164,19 +276,83 @@ export const BehaviorPage: React.FC = () => {
outerClassName="min-h-[160px] max-h-[70vh]"
className="w-full font-mono typography-meta bg-transparent"
/>
<Button
onClick={handleSave}
disabled={isSaving || !isPromptDirty || isLoading}
size="xs"
className="!font-normal"
>
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
</Button>
</section>
</div>
<div className="px-2 py-1">
<Button
onClick={handleSave}
disabled={isSaving || !isDirty || isLoading}
size="xs"
className="!font-normal"
>
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
</Button>
<div>
<div className="mb-1 px-1">
<div className="flex items-center gap-1.5">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.behavior.page.section.responseStyle')}
</h3>
<Tooltip>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.behavior.page.responseStyle.tooltip')}
</TooltipContent>
</Tooltip>
</div>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<label className="flex items-center gap-2 typography-ui-label text-foreground">
<Checkbox
checked={responseStyleEnabled}
onChange={setResponseStyleEnabled}
disabled={isLoading}
ariaLabel={t('settings.behavior.page.responseStyle.enableAria')}
/>
{t('settings.behavior.page.responseStyle.enable')}
</label>
<Select<ResponseStyleValue>
value={responseStylePreset}
onValueChange={(value) => setResponseStylePreset(value)}
disabled={isLoading || !responseStyleEnabled}
>
<SelectTrigger className="w-full sm:w-56" size="lg">
<SelectValue>
{(value) => {
if (value === 'custom') return t('settings.behavior.page.responseStyle.option.custom');
if (isResponseStylePreset(value)) return t(RESPONSE_STYLE_OPTION_LABEL_KEYS[value]);
return null;
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{RESPONSE_STYLE_PRESETS.map((preset) => (
<SelectItem key={preset} value={preset}>
{t(RESPONSE_STYLE_OPTION_LABEL_KEYS[preset])}
</SelectItem>
))}
<SelectItem value="custom">
{t('settings.behavior.page.responseStyle.option.custom')}
</SelectItem>
</SelectContent>
</Select>
<Textarea
value={responseStylePreview}
onChange={(event) => setResponseStyleCustomInstructions(event.target.value)}
placeholder={t('settings.behavior.page.responseStyle.customPlaceholder')}
rows={5}
disabled={isLoading || !responseStyleEnabled || responseStylePreset !== 'custom'}
outerClassName="min-h-[120px]"
className="w-full font-mono typography-meta bg-transparent"
/>
</section>
</div>
</div>
</ScrollableOverlay>
);