diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 014885d9..fea8b17e 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ 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, diff --git a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx index bdeae59d..a2bfff92 100644 --- a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx +++ b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx @@ -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 = { + 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, 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(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 = () => { -
+