feat(settings): add response style presets
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user