From e103c2c4299545d610312752b985ce788bed1102 Mon Sep 17 00:00:00 2001 From: "Dr. Zed" <142888684+DocterZed@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:35:43 +1300 Subject: [PATCH] Feat: spell check toggle for desktop (#626) * feat: (desktop) add spellcheck toggle for text inputs * fix: show spellcheck toggle only on desktop * fix: apply the preference consistently to chat and commit inputs --- packages/ui/src/components/chat/ChatInput.tsx | 4 +- .../sections/openchamber/OpenChamberPage.tsx | 2 +- .../openchamber/OpenChamberVisualSettings.tsx | 37 +++++++++++++++++-- .../src/components/views/git/CommitInput.tsx | 6 ++- .../components/views/git/CommitSection.tsx | 1 + packages/ui/src/lib/api/types.ts | 1 + packages/ui/src/lib/desktop.ts | 1 + packages/ui/src/lib/persistence.ts | 6 +++ packages/ui/src/stores/useUIStore.ts | 7 ++++ packages/web/server/index.js | 3 ++ 10 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 3ece1b5c..05671223 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -155,7 +155,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore(); const agents = getVisibleAgents(); const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]); - const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft, isExpandedInput, setExpandedInput } = useUIStore(); + const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore(); const { working } = useAssistantStatus(); const { currentTheme } = useThemeSystem(); const chatSearchDirectory = useChatSearchDirectory(); @@ -2682,7 +2682,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo disabled={!currentSessionId && !newSessionDraftOpen} autoCorrect={isMobile ? "on" : "off"} autoCapitalize={isMobile ? "sentences" : "off"} - spellCheck={isMobile} + spellCheck={isMobile || inputSpellcheckEnabled} fillContainer={isDesktopExpanded} outerClassName={cn('focus-within:ring-0', isDesktopExpanded && 'flex-1 min-h-0')} className={cn( diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index f1ff9147..dcd84da0 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -117,7 +117,7 @@ const VisualSectionContent: React.FC = () => { // Chat section: Default Tool Output, User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Justification activity, Activity header timestamps, Queue mode, Persist draft const ChatSectionContent: React.FC = () => { - return ; + return ; }; // Sessions section: Default model & agent, Session retention, Memory limits diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index ab59330d..2572c759 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -124,7 +124,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' return mode === 'markdown' ? 'markdown' : 'plain'; }; -export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'toolOutput' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'activityHeaderTimestamps' | 'terminalQuickKeys' | 'persistDraft'; +export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'toolOutput' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'activityHeaderTimestamps' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck'; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ @@ -169,6 +169,8 @@ export const OpenChamberVisualSettings: React.FC const setQueueMode = useMessageQueueStore(state => state.setQueueMode); const persistChatDraft = useUIStore(state => state.persistChatDraft); const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft); + const inputSpellcheckEnabled = useUIStore(state => state.inputSpellcheckEnabled); + const setInputSpellcheckEnabled = useUIStore(state => state.setInputSpellcheckEnabled); const isNavRailExpanded = useUIStore(state => state.isNavRailExpanded); const setNavRailExpanded = useUIStore(state => state.setNavRailExpanded); const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar); @@ -196,6 +198,11 @@ export const OpenChamberVisualSettings: React.FC void updateDesktopSettings({ stickyUserHeader: enabled }); }, [setStickyUserHeader]); + const handleInputSpellcheckChange = React.useCallback((enabled: boolean) => { + setInputSpellcheckEnabled(enabled); + void updateDesktopSettings({ inputSpellcheckEnabled: enabled }); + }, [setInputSpellcheckEnabled]); + const lightThemes = React.useMemo( () => availableThemes .filter((theme) => theme.metadata.variant === 'light') @@ -245,7 +252,8 @@ export const OpenChamberVisualSettings: React.FC || shouldShow('queueMode') || shouldShow('textJustificationActivity') || shouldShow('activityHeaderTimestamps') - || shouldShow('persistDraft'); + || shouldShow('persistDraft') + || (!isMobile && shouldShow('inputSpellcheck')); const selectedToolExpansionOption = TOOL_EXPANSION_OPTIONS.find((option) => option.value === toolCallExpansion); const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab; @@ -887,7 +895,7 @@ export const OpenChamberVisualSettings: React.FC )} - {(shouldShow('stickyUserHeader') || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('reasoning') || shouldShow('textJustificationActivity')) && ( + {(shouldShow('stickyUserHeader') || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning') || shouldShow('textJustificationActivity')) && (
{shouldShow('stickyUserHeader') && (
)} + {!isMobile && shouldShow('inputSpellcheck') && ( +
handleInputSpellcheckChange(!inputSpellcheckEnabled)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + handleInputSpellcheckChange(!inputSpellcheckEnabled); + } + }} + > + + Enable Spellcheck in Text Inputs +
+ )} + {shouldShow('reasoning') && (
= ({ placeholder = 'Commit message', disabled = false, hasTouchInput = false, + isMobile = false, }) => { const textareaRef = React.useRef(null); + const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled); // Auto-resize based on content (layout phase to avoid mount flicker) React.useLayoutEffect(() => { @@ -45,7 +49,7 @@ export const CommitInput: React.FC = ({ disabled={disabled} autoCorrect={hasTouchInput ? 'on' : 'off'} autoCapitalize={hasTouchInput ? 'sentences' : 'off'} - spellCheck={hasTouchInput ? true : false} + spellCheck={isMobile || inputSpellcheckEnabled} scrollbarClassName="hidden" className={cn( 'rounded-lg bg-transparent resize-none overflow-y-hidden', diff --git a/packages/ui/src/components/views/git/CommitSection.tsx b/packages/ui/src/components/views/git/CommitSection.tsx index 1e00eeb4..4199c6d3 100644 --- a/packages/ui/src/components/views/git/CommitSection.tsx +++ b/packages/ui/src/components/views/git/CommitSection.tsx @@ -105,6 +105,7 @@ export const CommitSection: React.FC = ({ placeholder="Commit message" disabled={commitAction !== null} hasTouchInput={hasTouchInput} + isMobile={isMobile} /> {gitmojiEnabled && ( diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 1344d7c7..35fa4357 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -525,6 +525,7 @@ export interface SettingsPayload { queueModeEnabled?: boolean; gitmojiEnabled?: boolean; toolCallExpansion?: 'collapsed' | 'activity' | 'detailed' | 'changes'; + inputSpellcheckEnabled?: boolean; fontSize?: number; terminalFontSize?: number; padding?: number; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 48ac4306..7f35105e 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -116,6 +116,7 @@ export type DesktopSettings = { gitModelId?: string; pwaAppName?: string; toolCallExpansion?: 'collapsed' | 'activity' | 'detailed' | 'changes'; + inputSpellcheckEnabled?: boolean; userMessageRenderingMode?: 'markdown' | 'plain'; stickyUserHeader?: boolean; fontSize?: number; diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 2aa396d6..7d59ce4d 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -374,6 +374,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { store.setToolCallExpansion(settings.toolCallExpansion); } } + if (typeof settings.inputSpellcheckEnabled === 'boolean' && settings.inputSpellcheckEnabled !== store.inputSpellcheckEnabled) { + store.setInputSpellcheckEnabled(settings.inputSpellcheckEnabled); + } if (typeof settings.userMessageRenderingMode === 'string' && (settings.userMessageRenderingMode === 'markdown' || settings.userMessageRenderingMode === 'plain')) { if (settings.userMessageRenderingMode !== store.userMessageRenderingMode) { @@ -741,6 +744,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { ) { result.toolCallExpansion = candidate.toolCallExpansion; } + if (typeof candidate.inputSpellcheckEnabled === 'boolean') { + result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled; + } if (typeof candidate.userMessageRenderingMode === 'string' && (candidate.userMessageRenderingMode === 'markdown' || candidate.userMessageRenderingMode === 'plain')) { result.userMessageRenderingMode = candidate.userMessageRenderingMode; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 673c4985..2114a649 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -550,6 +550,7 @@ interface UIStore { showTerminalQuickKeysOnDesktop: boolean; persistChatDraft: boolean; + inputSpellcheckEnabled: boolean; mermaidRenderingMode: MermaidRenderingMode; userMessageRenderingMode: UserMessageRenderingMode; stickyUserHeader: boolean; @@ -660,6 +661,7 @@ interface UIStore { setSummaryLength: (value: number) => void; setMaxLastMessageLength: (value: number) => void; setPersistChatDraft: (value: boolean) => void; + setInputSpellcheckEnabled: (value: boolean) => void; setMermaidRenderingMode: (value: MermaidRenderingMode) => void; setUserMessageRenderingMode: (value: UserMessageRenderingMode) => void; setStickyUserHeader: (value: boolean) => void; @@ -770,6 +772,7 @@ export const useUIStore = create()( showTerminalQuickKeysOnDesktop: false, persistChatDraft: true, + inputSpellcheckEnabled: false, mermaidRenderingMode: 'svg', userMessageRenderingMode: 'markdown', stickyUserHeader: true, @@ -1668,6 +1671,9 @@ export const useUIStore = create()( setPersistChatDraft: (value) => { set({ persistChatDraft: value }); }, + setInputSpellcheckEnabled: (value) => { + set({ inputSpellcheckEnabled: value }); + }, setMermaidRenderingMode: (value) => { set({ mermaidRenderingMode: value }); }, @@ -1854,6 +1860,7 @@ export const useUIStore = create()( summaryLength: state.summaryLength, maxLastMessageLength: state.maxLastMessageLength, persistChatDraft: state.persistChatDraft, + inputSpellcheckEnabled: state.inputSpellcheckEnabled, mermaidRenderingMode: state.mermaidRenderingMode, userMessageRenderingMode: state.userMessageRenderingMode, stickyUserHeader: state.stickyUserHeader, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index ac1fc63c..b9613908 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -2094,6 +2094,9 @@ const sanitizeSettingsUpdate = (payload) => { result.toolCallExpansion = mode; } } + if (typeof candidate.inputSpellcheckEnabled === 'boolean') { + result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled; + } if (typeof candidate.userMessageRenderingMode === 'string') { const mode = candidate.userMessageRenderingMode.trim(); if (mode === 'markdown' || mode === 'plain') {