* feat: add collapsible reasoning traces with animated labels
* feat(ui): redesign reasoning blocks with merged collapsible Thought view
- Replace per-part reasoning blocks with a single merged block per turn
(VSCode Copilot pattern), controlled by new `groupReasoningBlocks` store flag
- `ReasoningTimelineBlock` redesigned: chevron toggle, summary preview on
collapsed header, 'Thinking'/'Justification' label when expanded, BusyDots
while streaming, auto-scroll to bottom during live streaming
- Short texts (< 120 chars) render inline without a toggle
- Summary now strips markdown and truncates at a word boundary with ellipsis
- New `MergedReasoningPart` component merges all reasoning parts for a message
into one block at the position of the first reasoning part
- `defaultExpanded` prop lets callers override initial expand state
- Remove `.thinking-dot` CSS animation (replaced by BusyDots component)
- Fix reasoning markdown font-size: use `--text-markdown` instead of `--text-meta`
* refactor(ui): scope working phrases inside useAssistantStatus and simplify reasoning status
- Move WORKING_PHRASES array and getRandomWorkingPhrase() inside the hook
so they are no longer exported (were only consumed by ReasoningPart which
no longer needs them)
- Change the 'reasoning' activity status text from a random working phrase
to the deterministic string 'thinking' — matches the new UI label
* test(ui): expand ReasoningPart tests for new collapsible and summary behavior
- Update baseline test to use text long enough to trigger the collapsible
path (short texts now render inline) and assert on the correct aria markup
- Add test for 'Justification' label when pre-expanded via defaultExpanded
- Add test for 'Thinking' label for the thinking variant when expanded
- Add test verifying summary is a word-boundary-truncated excerpt ending with
an ellipsis character
* i18n: rename 'Reasoning Traces' to 'Thinking Blocks' and add thought key
- Rename settings label from 'Show Reasoning Traces' → 'Show Thinking Blocks'
across all supported locales (en, es, ko, pl, pt-BR, uk, zh-CN)
- Add `chat.reasoningTrace.thought` key to all locales (used by merged
reasoning block header in completed state)
* feat(ui): add collapsibleThinkingBlocks setting with full persistence wiring
- New boolean store field `collapsibleThinkingBlocks` (default true) with
`setCollapsibleThinkingBlocks` action; persisted to localStorage
- Threaded through DesktopSettings, SettingsPayload (API types), desktop
persistence (sanitize + apply), web appearance persistence, appearance
auto-save watcher, and server-side settings-helpers sanitize/format
- Server defaults to true when the field is absent in formatSettingsResponse
- MessageBody reads the flag: false → render reasoning as plain AssistantTextPart;
true → existing collapsible/merged block path
* feat(settings): expose Collapsible Reasoning Blocks toggle in visual settings
Add a checkbox under the 'Show Thinking Blocks' row (visible only when
showReasoningTraces is enabled) that toggles the collapsibleThinkingBlocks
preference. Follows the existing toggle pattern: div role=button, keyboard
handler for Enter/Space, Checkbox primitive, aria-pressed attribute.
* i18n: revert showReasoningTraces label rename and add collapsibleThinkingBlocks strings
- Revert 'Show Reasoning Traces' → 'Show Thinking Blocks' rename (the
collapsibleThinkingBlocks toggle is now a separate control, so the parent
label stays as 'Reasoning Traces' for clarity)
- Add `collapsibleThinkingBlocks` / `collapsibleThinkingBlocksAria` strings
across all seven supported locales (en, es, ko, pl, pt-BR, uk, zh-CN)
* test(server): add settings-helpers coverage for collapsibleThinkingBlocks
- Verify sanitizeSettingsUpdate accepts boolean true/false and rejects
non-boolean values (string, number)
- Verify formatSettingsResponse forwards the value correctly for both true
and false, and defaults to true when the field is absent
* fix(ui): respect defaultExpanded prop and remove dead alwaysShowActions from ReasoningTimelineBlock
The useEffect on [isStreaming] was firing on mount and immediately calling
setIsExpanded(false) (since isStreaming is false for completed blocks),
overriding any defaultExpanded={true} passed by callers. The fix uses a
prevIsStreamingRef so the effect only collapses the block on a true→false
transition and is a no-op on initial mount.
Also removes alwaysShowActions from ReasoningTimelineBlockProps — the new
header design always shows the chevron, making the prop obsolete. The prop
was already absent from the component destructuring (a dead type entry) and
was silently ignored at runtime. Removed it from ReasoningPartProps,
MergedReasoningPartProps, and the two call-sites in MessageBody as well.
* chore: remove unused reasoningpresentation module and test
* fix(ui): polish collapsible reasoning block UI
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
753 lines
31 KiB
JavaScript
753 lines
31 KiB
JavaScript
export const createSettingsHelpers = (dependencies) => {
|
|
const {
|
|
normalizePathForPersistence,
|
|
normalizeDirectoryPath,
|
|
normalizeTunnelBootstrapTtlMs,
|
|
normalizeTunnelSessionTtlMs,
|
|
normalizeTunnelProvider,
|
|
normalizeTunnelMode,
|
|
normalizeOptionalPath,
|
|
normalizeManagedRemoteTunnelHostname,
|
|
normalizeManagedRemoteTunnelPresets,
|
|
normalizeManagedRemoteTunnelPresetTokens,
|
|
sanitizeTypographySizesPartial,
|
|
normalizeStringArray,
|
|
sanitizeModelRefs,
|
|
sanitizeSkillCatalogs,
|
|
sanitizeProjects,
|
|
} = dependencies;
|
|
|
|
const PWA_APP_NAME_MAX_LENGTH = 64;
|
|
const STT_SERVER_URL_MAX_LENGTH = 2048;
|
|
const STT_MODEL_MAX_LENGTH = 256;
|
|
const STT_LANGUAGE_MAX_LENGTH = 64;
|
|
const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']);
|
|
const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']);
|
|
|
|
const normalizePwaAppName = (value, fallback = '') => {
|
|
if (typeof value !== 'string') {
|
|
return fallback;
|
|
}
|
|
const normalized = value.trim().replace(/\s+/g, ' ');
|
|
if (!normalized) {
|
|
return fallback;
|
|
}
|
|
return normalized.slice(0, PWA_APP_NAME_MAX_LENGTH);
|
|
};
|
|
|
|
const normalizePwaOrientation = (value, fallback = 'system') => {
|
|
if (typeof value !== 'string') {
|
|
return fallback;
|
|
}
|
|
const normalized = value.trim();
|
|
if (PWA_ORIENTATION_VALUES.has(normalized)) {
|
|
return normalized;
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
const normalizeMobileKeyboardMode = (value, fallback = 'native') => {
|
|
if (typeof value !== 'string') {
|
|
return fallback;
|
|
}
|
|
const normalized = value.trim();
|
|
if (MOBILE_KEYBOARD_MODE_VALUES.has(normalized)) {
|
|
return normalized;
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
const sanitizeSettingsUpdate = (payload) => {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return {};
|
|
}
|
|
|
|
const candidate = payload;
|
|
const result = {};
|
|
|
|
if (typeof candidate.themeId === 'string' && candidate.themeId.length > 0) {
|
|
result.themeId = candidate.themeId;
|
|
}
|
|
if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) {
|
|
result.themeVariant = candidate.themeVariant;
|
|
}
|
|
if (typeof candidate.useSystemTheme === 'boolean') {
|
|
result.useSystemTheme = candidate.useSystemTheme;
|
|
}
|
|
if (typeof candidate.lightThemeId === 'string' && candidate.lightThemeId.length > 0) {
|
|
result.lightThemeId = candidate.lightThemeId;
|
|
}
|
|
if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) {
|
|
result.darkThemeId = candidate.darkThemeId;
|
|
}
|
|
if (typeof candidate.splashBgLight === 'string' && candidate.splashBgLight.trim().length > 0) {
|
|
result.splashBgLight = candidate.splashBgLight.trim();
|
|
}
|
|
if (typeof candidate.splashFgLight === 'string' && candidate.splashFgLight.trim().length > 0) {
|
|
result.splashFgLight = candidate.splashFgLight.trim();
|
|
}
|
|
if (typeof candidate.splashBgDark === 'string' && candidate.splashBgDark.trim().length > 0) {
|
|
result.splashBgDark = candidate.splashBgDark.trim();
|
|
}
|
|
if (typeof candidate.splashFgDark === 'string' && candidate.splashFgDark.trim().length > 0) {
|
|
result.splashFgDark = candidate.splashFgDark.trim();
|
|
}
|
|
if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) {
|
|
const normalized = normalizePathForPersistence(candidate.lastDirectory);
|
|
if (typeof normalized === 'string' && normalized.length > 0) {
|
|
result.lastDirectory = normalized;
|
|
}
|
|
}
|
|
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
|
|
const normalized = normalizePathForPersistence(candidate.homeDirectory);
|
|
if (typeof normalized === 'string' && normalized.length > 0) {
|
|
result.homeDirectory = normalized;
|
|
}
|
|
}
|
|
|
|
// Absolute path to the opencode CLI binary (optional override).
|
|
// Accept empty-string to clear (we persist an empty string sentinel so the running
|
|
// process can reliably drop a previously applied OPENCODE_BINARY override).
|
|
if (typeof candidate.opencodeBinary === 'string') {
|
|
const normalized = normalizeDirectoryPath(candidate.opencodeBinary).trim();
|
|
result.opencodeBinary = normalized;
|
|
}
|
|
if (typeof candidate.desktopLanAccessEnabled === 'boolean') {
|
|
result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled;
|
|
}
|
|
if (Array.isArray(candidate.projects)) {
|
|
const projects = sanitizeProjects(candidate.projects);
|
|
if (projects) {
|
|
result.projects = projects;
|
|
}
|
|
}
|
|
if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
|
|
result.activeProjectId = candidate.activeProjectId;
|
|
}
|
|
|
|
if (Array.isArray(candidate.approvedDirectories)) {
|
|
result.approvedDirectories = normalizeStringArray(
|
|
candidate.approvedDirectories
|
|
.map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry))
|
|
.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
);
|
|
}
|
|
if (Array.isArray(candidate.securityScopedBookmarks)) {
|
|
result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks);
|
|
}
|
|
if (Array.isArray(candidate.pinnedDirectories)) {
|
|
result.pinnedDirectories = normalizeStringArray(
|
|
candidate.pinnedDirectories
|
|
.map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry))
|
|
.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
);
|
|
}
|
|
|
|
|
|
if (typeof candidate.uiFont === 'string' && candidate.uiFont.length > 0) {
|
|
result.uiFont = candidate.uiFont;
|
|
}
|
|
if (typeof candidate.monoFont === 'string' && candidate.monoFont.length > 0) {
|
|
result.monoFont = candidate.monoFont;
|
|
}
|
|
if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) {
|
|
result.markdownDisplayMode = candidate.markdownDisplayMode;
|
|
}
|
|
if (typeof candidate.githubClientId === 'string') {
|
|
const trimmed = candidate.githubClientId.trim();
|
|
if (trimmed.length > 0) {
|
|
result.githubClientId = trimmed;
|
|
}
|
|
}
|
|
if (typeof candidate.githubScopes === 'string') {
|
|
const trimmed = candidate.githubScopes.trim();
|
|
if (trimmed.length > 0) {
|
|
result.githubScopes = trimmed;
|
|
}
|
|
}
|
|
if (typeof candidate.showReasoningTraces === 'boolean') {
|
|
result.showReasoningTraces = candidate.showReasoningTraces;
|
|
}
|
|
if (typeof candidate.collapsibleThinkingBlocks === 'boolean') {
|
|
result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks;
|
|
}
|
|
if (typeof candidate.showTextJustificationActivity === 'boolean') {
|
|
result.showTextJustificationActivity = candidate.showTextJustificationActivity;
|
|
}
|
|
if (typeof candidate.showDeletionDialog === 'boolean') {
|
|
result.showDeletionDialog = candidate.showDeletionDialog;
|
|
}
|
|
if (typeof candidate.nativeNotificationsEnabled === 'boolean') {
|
|
result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled;
|
|
}
|
|
if (typeof candidate.notificationMode === 'string') {
|
|
const mode = candidate.notificationMode.trim();
|
|
if (mode === 'always' || mode === 'hidden-only') {
|
|
result.notificationMode = mode;
|
|
}
|
|
}
|
|
if (typeof candidate.notifyOnSubtasks === 'boolean') {
|
|
result.notifyOnSubtasks = candidate.notifyOnSubtasks;
|
|
}
|
|
if (typeof candidate.notifyOnCompletion === 'boolean') {
|
|
result.notifyOnCompletion = candidate.notifyOnCompletion;
|
|
}
|
|
if (typeof candidate.notifyOnError === 'boolean') {
|
|
result.notifyOnError = candidate.notifyOnError;
|
|
}
|
|
if (typeof candidate.notifyOnQuestion === 'boolean') {
|
|
result.notifyOnQuestion = candidate.notifyOnQuestion;
|
|
}
|
|
if (candidate.notificationTemplates && typeof candidate.notificationTemplates === 'object') {
|
|
result.notificationTemplates = candidate.notificationTemplates;
|
|
}
|
|
if (typeof candidate.summarizeLastMessage === 'boolean') {
|
|
result.summarizeLastMessage = candidate.summarizeLastMessage;
|
|
}
|
|
if (typeof candidate.summaryThreshold === 'number' && Number.isFinite(candidate.summaryThreshold)) {
|
|
result.summaryThreshold = Math.max(0, Math.round(candidate.summaryThreshold));
|
|
}
|
|
if (typeof candidate.summaryLength === 'number' && Number.isFinite(candidate.summaryLength)) {
|
|
result.summaryLength = Math.max(10, Math.round(candidate.summaryLength));
|
|
}
|
|
if (typeof candidate.maxLastMessageLength === 'number' && Number.isFinite(candidate.maxLastMessageLength)) {
|
|
result.maxLastMessageLength = Math.max(10, Math.round(candidate.maxLastMessageLength));
|
|
}
|
|
if (typeof candidate.usageAutoRefresh === 'boolean') {
|
|
result.usageAutoRefresh = candidate.usageAutoRefresh;
|
|
}
|
|
if (typeof candidate.usageRefreshIntervalMs === 'number' && Number.isFinite(candidate.usageRefreshIntervalMs)) {
|
|
result.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(candidate.usageRefreshIntervalMs)));
|
|
}
|
|
if (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') {
|
|
result.usageDisplayMode = candidate.usageDisplayMode;
|
|
}
|
|
if (Array.isArray(candidate.usageDropdownProviders)) {
|
|
result.usageDropdownProviders = normalizeStringArray(candidate.usageDropdownProviders);
|
|
}
|
|
if (typeof candidate.autoDeleteEnabled === 'boolean') {
|
|
result.autoDeleteEnabled = candidate.autoDeleteEnabled;
|
|
}
|
|
if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) {
|
|
const normalizedDays = Math.max(1, Math.min(365, Math.round(candidate.autoDeleteAfterDays)));
|
|
result.autoDeleteAfterDays = normalizedDays;
|
|
}
|
|
if (candidate.tunnelBootstrapTtlMs === null) {
|
|
result.tunnelBootstrapTtlMs = null;
|
|
} else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) {
|
|
result.tunnelBootstrapTtlMs = normalizeTunnelBootstrapTtlMs(candidate.tunnelBootstrapTtlMs);
|
|
}
|
|
if (typeof candidate.tunnelSessionTtlMs === 'number' && Number.isFinite(candidate.tunnelSessionTtlMs)) {
|
|
result.tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(candidate.tunnelSessionTtlMs);
|
|
}
|
|
if (typeof candidate.tunnelProvider === 'string') {
|
|
const provider = normalizeTunnelProvider(candidate.tunnelProvider);
|
|
if (provider) {
|
|
result.tunnelProvider = provider;
|
|
}
|
|
}
|
|
if (typeof candidate.tunnelMode === 'string') {
|
|
result.tunnelMode = normalizeTunnelMode(candidate.tunnelMode);
|
|
}
|
|
if (candidate.managedLocalTunnelConfigPath === null) {
|
|
result.managedLocalTunnelConfigPath = null;
|
|
} else if (typeof candidate.managedLocalTunnelConfigPath === 'string') {
|
|
const trimmed = candidate.managedLocalTunnelConfigPath.trim();
|
|
result.managedLocalTunnelConfigPath = trimmed.length > 0 ? normalizeOptionalPath(trimmed) : null;
|
|
}
|
|
if (typeof candidate.managedRemoteTunnelHostname === 'string') {
|
|
const hostname = normalizeManagedRemoteTunnelHostname(candidate.managedRemoteTunnelHostname);
|
|
result.managedRemoteTunnelHostname = hostname;
|
|
}
|
|
if (candidate.managedRemoteTunnelToken === null) {
|
|
result.managedRemoteTunnelToken = null;
|
|
} else if (typeof candidate.managedRemoteTunnelToken === 'string') {
|
|
result.managedRemoteTunnelToken = candidate.managedRemoteTunnelToken.trim();
|
|
}
|
|
const managedRemoteTunnelPresets = normalizeManagedRemoteTunnelPresets(candidate.managedRemoteTunnelPresets);
|
|
if (managedRemoteTunnelPresets) {
|
|
result.managedRemoteTunnelPresets = managedRemoteTunnelPresets;
|
|
}
|
|
const managedRemoteTunnelPresetTokens = normalizeManagedRemoteTunnelPresetTokens(candidate.managedRemoteTunnelPresetTokens);
|
|
if (managedRemoteTunnelPresetTokens) {
|
|
result.managedRemoteTunnelPresetTokens = managedRemoteTunnelPresetTokens;
|
|
}
|
|
if (typeof candidate.managedRemoteTunnelSelectedPresetId === 'string') {
|
|
const id = candidate.managedRemoteTunnelSelectedPresetId.trim();
|
|
result.managedRemoteTunnelSelectedPresetId = id || undefined;
|
|
}
|
|
|
|
const typography = sanitizeTypographySizesPartial(candidate.typographySizes);
|
|
if (typography) {
|
|
result.typographySizes = typography;
|
|
}
|
|
|
|
if (typeof candidate.defaultModel === 'string') {
|
|
const trimmed = candidate.defaultModel.trim();
|
|
result.defaultModel = trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
if (typeof candidate.defaultVariant === 'string') {
|
|
const trimmed = candidate.defaultVariant.trim();
|
|
result.defaultVariant = trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
if (typeof candidate.defaultAgent === 'string') {
|
|
const trimmed = candidate.defaultAgent.trim();
|
|
result.defaultAgent = trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
if (typeof candidate.defaultGitIdentityId === 'string') {
|
|
const trimmed = candidate.defaultGitIdentityId.trim();
|
|
result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
if (typeof candidate.queueModeEnabled === 'boolean') {
|
|
result.queueModeEnabled = candidate.queueModeEnabled;
|
|
}
|
|
if (typeof candidate.autoCreateWorktree === 'boolean') {
|
|
result.autoCreateWorktree = candidate.autoCreateWorktree;
|
|
}
|
|
if (typeof candidate.gitmojiEnabled === 'boolean') {
|
|
result.gitmojiEnabled = candidate.gitmojiEnabled;
|
|
}
|
|
if (typeof candidate.defaultFileViewerPreview === 'boolean') {
|
|
result.defaultFileViewerPreview = candidate.defaultFileViewerPreview;
|
|
}
|
|
if (typeof candidate.zenModel === 'string') {
|
|
const trimmed = candidate.zenModel.trim();
|
|
result.zenModel = trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
if (typeof candidate.gitProviderId === 'string') {
|
|
const trimmed = candidate.gitProviderId.trim();
|
|
result.gitProviderId = trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
if (typeof candidate.gitModelId === 'string') {
|
|
const trimmed = candidate.gitModelId.trim();
|
|
result.gitModelId = trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
if (typeof candidate.pwaAppName === 'string') {
|
|
result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined);
|
|
}
|
|
if (typeof candidate.pwaOrientation === 'string') {
|
|
result.pwaOrientation = normalizePwaOrientation(candidate.pwaOrientation, undefined);
|
|
}
|
|
if (typeof candidate.mobileKeyboardMode === 'string') {
|
|
const mode = normalizeMobileKeyboardMode(candidate.mobileKeyboardMode, undefined);
|
|
if (mode) {
|
|
result.mobileKeyboardMode = mode;
|
|
}
|
|
}
|
|
if (typeof candidate.toolCallExpansion === 'string') {
|
|
const mode = candidate.toolCallExpansion.trim();
|
|
if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed' || mode === 'changes') {
|
|
result.toolCallExpansion = mode;
|
|
}
|
|
}
|
|
if (typeof candidate.inputSpellcheckEnabled === 'boolean') {
|
|
result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled;
|
|
}
|
|
if (typeof candidate.showToolFileIcons === 'boolean') {
|
|
result.showToolFileIcons = candidate.showToolFileIcons;
|
|
}
|
|
if (typeof candidate.showExpandedBashTools === 'boolean') {
|
|
result.showExpandedBashTools = candidate.showExpandedBashTools;
|
|
}
|
|
if (typeof candidate.showExpandedEditTools === 'boolean') {
|
|
result.showExpandedEditTools = candidate.showExpandedEditTools;
|
|
}
|
|
if (typeof candidate.timeFormatPreference === 'string') {
|
|
const mode = candidate.timeFormatPreference.trim();
|
|
if (mode === 'auto' || mode === '12h' || mode === '24h') {
|
|
result.timeFormatPreference = mode;
|
|
}
|
|
}
|
|
if (typeof candidate.weekStartPreference === 'string') {
|
|
const mode = candidate.weekStartPreference.trim();
|
|
if (mode === 'auto' || mode === 'sunday' || mode === 'monday') {
|
|
result.weekStartPreference = mode;
|
|
}
|
|
}
|
|
if (typeof candidate.chatRenderMode === 'string') {
|
|
const mode = candidate.chatRenderMode.trim();
|
|
if (mode === 'sorted' || mode === 'live') {
|
|
result.chatRenderMode = mode;
|
|
}
|
|
}
|
|
if (typeof candidate.messageStreamTransport === 'string') {
|
|
const mode = candidate.messageStreamTransport.trim();
|
|
if (mode === 'auto' || mode === 'ws' || mode === 'sse') {
|
|
result.messageStreamTransport = mode;
|
|
}
|
|
}
|
|
if (typeof candidate.activityRenderMode === 'string') {
|
|
const mode = candidate.activityRenderMode.trim();
|
|
if (mode === 'collapsed' || mode === 'summary') {
|
|
result.activityRenderMode = mode;
|
|
}
|
|
}
|
|
if (typeof candidate.mermaidRenderingMode === 'string') {
|
|
const mode = candidate.mermaidRenderingMode.trim();
|
|
if (mode === 'svg' || mode === 'ascii') {
|
|
result.mermaidRenderingMode = mode;
|
|
}
|
|
}
|
|
if (typeof candidate.userMessageRenderingMode === 'string') {
|
|
const mode = candidate.userMessageRenderingMode.trim();
|
|
if (mode === 'markdown' || mode === 'plain') {
|
|
result.userMessageRenderingMode = mode;
|
|
}
|
|
}
|
|
if (typeof candidate.stickyUserHeader === 'boolean') {
|
|
result.stickyUserHeader = candidate.stickyUserHeader;
|
|
}
|
|
if (typeof candidate.showSplitAssistantMessageActions === 'boolean') {
|
|
result.showSplitAssistantMessageActions = candidate.showSplitAssistantMessageActions;
|
|
}
|
|
if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) {
|
|
result.fontSize = Math.max(50, Math.min(200, Math.round(candidate.fontSize)));
|
|
}
|
|
if (typeof candidate.terminalFontSize === 'number' && Number.isFinite(candidate.terminalFontSize)) {
|
|
result.terminalFontSize = Math.max(9, Math.min(52, Math.round(candidate.terminalFontSize)));
|
|
}
|
|
if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) {
|
|
result.padding = Math.max(50, Math.min(200, Math.round(candidate.padding)));
|
|
}
|
|
if (typeof candidate.cornerRadius === 'number' && Number.isFinite(candidate.cornerRadius)) {
|
|
result.cornerRadius = Math.max(0, Math.min(32, Math.round(candidate.cornerRadius)));
|
|
}
|
|
if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) {
|
|
result.inputBarOffset = Math.max(0, Math.min(100, Math.round(candidate.inputBarOffset)));
|
|
}
|
|
|
|
const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64);
|
|
if (favoriteModels) {
|
|
result.favoriteModels = favoriteModels;
|
|
}
|
|
|
|
const recentModels = sanitizeModelRefs(candidate.recentModels, 16);
|
|
if (recentModels) {
|
|
result.recentModels = recentModels;
|
|
}
|
|
if (typeof candidate.diffLayoutPreference === 'string') {
|
|
const mode = candidate.diffLayoutPreference.trim();
|
|
if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') {
|
|
result.diffLayoutPreference = mode;
|
|
}
|
|
}
|
|
if (typeof candidate.diffViewMode === 'string') {
|
|
const mode = candidate.diffViewMode.trim();
|
|
if (mode === 'single' || mode === 'stacked') {
|
|
result.diffViewMode = mode;
|
|
}
|
|
}
|
|
if (typeof candidate.gitChangesViewMode === 'string') {
|
|
const mode = candidate.gitChangesViewMode.trim();
|
|
if (mode === 'flat' || mode === 'tree') {
|
|
result.gitChangesViewMode = mode;
|
|
}
|
|
}
|
|
if (typeof candidate.directoryShowHidden === 'boolean') {
|
|
result.directoryShowHidden = candidate.directoryShowHidden;
|
|
}
|
|
if (typeof candidate.filesViewShowGitignored === 'boolean') {
|
|
result.filesViewShowGitignored = candidate.filesViewShowGitignored;
|
|
}
|
|
if (typeof candidate.openInAppId === 'string') {
|
|
const trimmed = candidate.openInAppId.trim();
|
|
if (trimmed.length > 0) {
|
|
result.openInAppId = trimmed;
|
|
}
|
|
}
|
|
|
|
// Message limit — single setting for fetch / trim / Load More chunk
|
|
if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) {
|
|
result.messageLimit = Math.max(10, Math.min(500, Math.round(candidate.messageLimit)));
|
|
}
|
|
|
|
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
|
if (skillCatalogs) {
|
|
result.skillCatalogs = skillCatalogs;
|
|
}
|
|
|
|
// Usage model selections - which models appear in dropdown
|
|
if (candidate.usageSelectedModels && typeof candidate.usageSelectedModels === 'object') {
|
|
const sanitized = {};
|
|
for (const [providerId, models] of Object.entries(candidate.usageSelectedModels)) {
|
|
if (typeof providerId === 'string' && Array.isArray(models)) {
|
|
const validModels = models.filter((m) => typeof m === 'string' && m.length > 0);
|
|
if (validModels.length > 0) {
|
|
sanitized[providerId] = validModels;
|
|
}
|
|
}
|
|
}
|
|
if (Object.keys(sanitized).length > 0) {
|
|
result.usageSelectedModels = sanitized;
|
|
}
|
|
}
|
|
|
|
// Usage page collapsed families - for "Other Models" section
|
|
if (candidate.usageCollapsedFamilies && typeof candidate.usageCollapsedFamilies === 'object') {
|
|
const sanitized = {};
|
|
for (const [providerId, families] of Object.entries(candidate.usageCollapsedFamilies)) {
|
|
if (typeof providerId === 'string' && Array.isArray(families)) {
|
|
const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0);
|
|
if (validFamilies.length > 0) {
|
|
sanitized[providerId] = validFamilies;
|
|
}
|
|
}
|
|
}
|
|
if (Object.keys(sanitized).length > 0) {
|
|
result.usageCollapsedFamilies = sanitized;
|
|
}
|
|
}
|
|
|
|
// Header dropdown expanded families (inverted - stores EXPANDED, default all collapsed)
|
|
if (candidate.usageExpandedFamilies && typeof candidate.usageExpandedFamilies === 'object') {
|
|
const sanitized = {};
|
|
for (const [providerId, families] of Object.entries(candidate.usageExpandedFamilies)) {
|
|
if (typeof providerId === 'string' && Array.isArray(families)) {
|
|
const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0);
|
|
if (validFamilies.length > 0) {
|
|
sanitized[providerId] = validFamilies;
|
|
}
|
|
}
|
|
}
|
|
if (Object.keys(sanitized).length > 0) {
|
|
result.usageExpandedFamilies = sanitized;
|
|
}
|
|
}
|
|
|
|
// Custom model groups configuration
|
|
if (candidate.usageModelGroups && typeof candidate.usageModelGroups === 'object') {
|
|
const sanitized = {};
|
|
for (const [providerId, config] of Object.entries(candidate.usageModelGroups)) {
|
|
if (typeof providerId !== 'string') continue;
|
|
|
|
const providerConfig = {};
|
|
|
|
// customGroups: array of {id, label, models, order}
|
|
if (Array.isArray(config.customGroups)) {
|
|
const validGroups = config.customGroups
|
|
.filter((g) => g && typeof g.id === 'string' && typeof g.label === 'string')
|
|
.map((g) => ({
|
|
id: g.id.slice(0, 64),
|
|
label: g.label.slice(0, 128),
|
|
models: Array.isArray(g.models)
|
|
? g.models.filter((m) => typeof m === 'string').slice(0, 500)
|
|
: [],
|
|
order: typeof g.order === 'number' ? g.order : 0,
|
|
}));
|
|
if (validGroups.length > 0) {
|
|
providerConfig.customGroups = validGroups;
|
|
}
|
|
}
|
|
|
|
// modelAssignments: Record<modelName, groupId>
|
|
if (config.modelAssignments && typeof config.modelAssignments === 'object') {
|
|
const assignments = {};
|
|
for (const [model, groupId] of Object.entries(config.modelAssignments)) {
|
|
if (typeof model === 'string' && typeof groupId === 'string') {
|
|
assignments[model] = groupId;
|
|
}
|
|
}
|
|
if (Object.keys(assignments).length > 0) {
|
|
providerConfig.modelAssignments = assignments;
|
|
}
|
|
}
|
|
|
|
// renamedGroups: Record<groupId, label>
|
|
if (config.renamedGroups && typeof config.renamedGroups === 'object') {
|
|
const renamed = {};
|
|
for (const [groupId, label] of Object.entries(config.renamedGroups)) {
|
|
if (typeof groupId === 'string' && typeof label === 'string') {
|
|
renamed[groupId] = label.slice(0, 128);
|
|
}
|
|
}
|
|
if (Object.keys(renamed).length > 0) {
|
|
providerConfig.renamedGroups = renamed;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(providerConfig).length > 0) {
|
|
sanitized[providerId] = providerConfig;
|
|
}
|
|
}
|
|
if (Object.keys(sanitized).length > 0) {
|
|
result.usageModelGroups = sanitized;
|
|
}
|
|
}
|
|
|
|
// Usage reporting opt-out (default: true/enabled)
|
|
if (typeof candidate.reportUsage === 'boolean') {
|
|
result.reportUsage = candidate.reportUsage;
|
|
}
|
|
|
|
// Global behavior prompt — synced to ~/.config/opencode/AGENTS.md
|
|
if (typeof candidate.globalBehaviorPrompt === 'string') {
|
|
const value = candidate.globalBehaviorPrompt;
|
|
if (value.length <= 1024 * 1024) {
|
|
result.globalBehaviorPrompt = value;
|
|
}
|
|
}
|
|
|
|
if (typeof candidate.responseStyleEnabled === 'boolean') {
|
|
result.responseStyleEnabled = candidate.responseStyleEnabled;
|
|
}
|
|
|
|
if (
|
|
typeof candidate.responseStylePreset === 'string' &&
|
|
['concise', 'detailed', 'mentor', 'pushback', 'noFiller', 'matchEnergy', 'warmPeer', 'custom'].includes(candidate.responseStylePreset)
|
|
) {
|
|
result.responseStylePreset = candidate.responseStylePreset;
|
|
}
|
|
|
|
if (typeof candidate.responseStyleCustomInstructions === 'string') {
|
|
const value = candidate.responseStyleCustomInstructions;
|
|
if (value.length <= 50_000) {
|
|
result.responseStyleCustomInstructions = value;
|
|
}
|
|
}
|
|
|
|
if (typeof candidate.sttProvider === 'string') {
|
|
const provider = candidate.sttProvider.trim();
|
|
if (provider === 'browser' || provider === 'server' || provider === 'wasm') {
|
|
result.sttProvider = provider;
|
|
}
|
|
}
|
|
if (typeof candidate.sttServerUrl === 'string') {
|
|
const trimmed = candidate.sttServerUrl.trim();
|
|
if (trimmed.length <= STT_SERVER_URL_MAX_LENGTH) {
|
|
result.sttServerUrl = trimmed;
|
|
}
|
|
}
|
|
if (typeof candidate.sttModel === 'string') {
|
|
const trimmed = candidate.sttModel.trim();
|
|
if (trimmed.length <= STT_MODEL_MAX_LENGTH) {
|
|
result.sttModel = trimmed;
|
|
}
|
|
}
|
|
if (typeof candidate.wasmSttModel === 'string') {
|
|
const trimmed = candidate.wasmSttModel.trim();
|
|
if (trimmed.length <= 256) {
|
|
result.wasmSttModel = trimmed;
|
|
}
|
|
}
|
|
if (typeof candidate.sttLanguage === 'string') {
|
|
const trimmed = candidate.sttLanguage.trim();
|
|
if (trimmed.length <= STT_LANGUAGE_MAX_LENGTH) {
|
|
result.sttLanguage = trimmed;
|
|
}
|
|
}
|
|
if (typeof candidate.sttSilenceThresholdDb === 'number' && Number.isFinite(candidate.sttSilenceThresholdDb)) {
|
|
result.sttSilenceThresholdDb = Math.max(-100, Math.min(0, candidate.sttSilenceThresholdDb));
|
|
}
|
|
if (typeof candidate.sttSilenceHoldMs === 'number' && Number.isFinite(candidate.sttSilenceHoldMs)) {
|
|
result.sttSilenceHoldMs = Math.max(250, Math.min(10000, Math.round(candidate.sttSilenceHoldMs)));
|
|
}
|
|
if (typeof candidate.sttTranscribeOnStop === 'boolean') {
|
|
result.sttTranscribeOnStop = candidate.sttTranscribeOnStop;
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const mergePersistedSettings = (current, changes) => {
|
|
const baseApproved = Array.isArray(changes.approvedDirectories)
|
|
? changes.approvedDirectories
|
|
: Array.isArray(current.approvedDirectories)
|
|
? current.approvedDirectories
|
|
: [];
|
|
|
|
const additionalApproved = [];
|
|
if (typeof changes.lastDirectory === 'string' && changes.lastDirectory.length > 0) {
|
|
additionalApproved.push(changes.lastDirectory);
|
|
}
|
|
if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) {
|
|
additionalApproved.push(changes.homeDirectory);
|
|
}
|
|
const projectEntries = Array.isArray(changes.projects)
|
|
? changes.projects
|
|
: Array.isArray(current.projects)
|
|
? current.projects
|
|
: [];
|
|
projectEntries.forEach((project) => {
|
|
if (project && typeof project.path === 'string' && project.path.length > 0) {
|
|
additionalApproved.push(project.path);
|
|
}
|
|
});
|
|
const approvedSource = [...baseApproved, ...additionalApproved];
|
|
|
|
const baseBookmarks = Array.isArray(changes.securityScopedBookmarks)
|
|
? changes.securityScopedBookmarks
|
|
: Array.isArray(current.securityScopedBookmarks)
|
|
? current.securityScopedBookmarks
|
|
: [];
|
|
|
|
const nextTypographySizes = changes.typographySizes
|
|
? {
|
|
...(current.typographySizes || {}),
|
|
...changes.typographySizes
|
|
}
|
|
: current.typographySizes;
|
|
|
|
const next = {
|
|
...current,
|
|
...changes,
|
|
approvedDirectories: Array.from(
|
|
new Set(
|
|
approvedSource.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
)
|
|
),
|
|
securityScopedBookmarks: Array.from(
|
|
new Set(
|
|
baseBookmarks.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
)
|
|
),
|
|
typographySizes: nextTypographySizes
|
|
};
|
|
|
|
return next;
|
|
};
|
|
|
|
const formatSettingsResponse = (settings) => {
|
|
const sanitized = sanitizeSettingsUpdate(settings);
|
|
delete sanitized.managedRemoteTunnelToken;
|
|
const approved = normalizeStringArray(settings.approvedDirectories);
|
|
const bookmarks = normalizeStringArray(settings.securityScopedBookmarks);
|
|
const hasManagedRemoteTunnelToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0;
|
|
const pwaAppName = normalizePwaAppName(settings?.pwaAppName, '');
|
|
const pwaOrientation = normalizePwaOrientation(settings?.pwaOrientation, 'system');
|
|
const mobileKeyboardMode = normalizeMobileKeyboardMode(settings?.mobileKeyboardMode, 'native');
|
|
|
|
return {
|
|
...sanitized,
|
|
hasManagedRemoteTunnelToken,
|
|
...(pwaAppName ? { pwaAppName } : {}),
|
|
pwaOrientation,
|
|
mobileKeyboardMode,
|
|
approvedDirectories: approved,
|
|
securityScopedBookmarks: bookmarks,
|
|
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
|
|
typographySizes: sanitizeTypographySizesPartial(settings.typographySizes),
|
|
showReasoningTraces:
|
|
typeof settings.showReasoningTraces === 'boolean'
|
|
? settings.showReasoningTraces
|
|
: typeof sanitized.showReasoningTraces === 'boolean'
|
|
? sanitized.showReasoningTraces
|
|
: false,
|
|
collapsibleThinkingBlocks:
|
|
typeof settings.collapsibleThinkingBlocks === 'boolean'
|
|
? settings.collapsibleThinkingBlocks
|
|
: typeof sanitized.collapsibleThinkingBlocks === 'boolean'
|
|
? sanitized.collapsibleThinkingBlocks
|
|
: true,
|
|
};
|
|
};
|
|
|
|
return {
|
|
normalizePwaAppName,
|
|
normalizePwaOrientation,
|
|
normalizeMobileKeyboardMode,
|
|
sanitizeSettingsUpdate,
|
|
mergePersistedSettings,
|
|
formatSettingsResponse,
|
|
};
|
|
};
|