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
This commit is contained in:
Dr. Zed
2026-03-11 00:35:43 +02:00
committed by GitHub
parent ebe069d372
commit e103c2c429
10 changed files with 61 additions and 7 deletions
@@ -155,7 +155,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore(); const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
const agents = getVisibleAgents(); const agents = getVisibleAgents();
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]); 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 { working } = useAssistantStatus();
const { currentTheme } = useThemeSystem(); const { currentTheme } = useThemeSystem();
const chatSearchDirectory = useChatSearchDirectory(); const chatSearchDirectory = useChatSearchDirectory();
@@ -2682,7 +2682,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
disabled={!currentSessionId && !newSessionDraftOpen} disabled={!currentSessionId && !newSessionDraftOpen}
autoCorrect={isMobile ? "on" : "off"} autoCorrect={isMobile ? "on" : "off"}
autoCapitalize={isMobile ? "sentences" : "off"} autoCapitalize={isMobile ? "sentences" : "off"}
spellCheck={isMobile} spellCheck={isMobile || inputSpellcheckEnabled}
fillContainer={isDesktopExpanded} fillContainer={isDesktopExpanded}
outerClassName={cn('focus-within:ring-0', isDesktopExpanded && 'flex-1 min-h-0')} outerClassName={cn('focus-within:ring-0', isDesktopExpanded && 'flex-1 min-h-0')}
className={cn( className={cn(
@@ -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 // 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 = () => { const ChatSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'mermaidRendering', 'userMessageRendering', 'stickyUserHeader', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'reasoning', 'textJustificationActivity', 'activityHeaderTimestamps', 'queueMode', 'persistDraft']} />; return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'mermaidRendering', 'userMessageRendering', 'stickyUserHeader', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'reasoning', 'textJustificationActivity', 'activityHeaderTimestamps', 'queueMode', 'persistDraft', 'inputSpellcheck']} />;
}; };
// Sessions section: Default model & agent, Session retention, Memory limits // Sessions section: Default model & agent, Session retention, Memory limits
@@ -124,7 +124,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? '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 { interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */ /** Which settings to show. If undefined, shows all. */
@@ -169,6 +169,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setQueueMode = useMessageQueueStore(state => state.setQueueMode); const setQueueMode = useMessageQueueStore(state => state.setQueueMode);
const persistChatDraft = useUIStore(state => state.persistChatDraft); const persistChatDraft = useUIStore(state => state.persistChatDraft);
const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft); 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 isNavRailExpanded = useUIStore(state => state.isNavRailExpanded);
const setNavRailExpanded = useUIStore(state => state.setNavRailExpanded); const setNavRailExpanded = useUIStore(state => state.setNavRailExpanded);
const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar); const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar);
@@ -196,6 +198,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
void updateDesktopSettings({ stickyUserHeader: enabled }); void updateDesktopSettings({ stickyUserHeader: enabled });
}, [setStickyUserHeader]); }, [setStickyUserHeader]);
const handleInputSpellcheckChange = React.useCallback((enabled: boolean) => {
setInputSpellcheckEnabled(enabled);
void updateDesktopSettings({ inputSpellcheckEnabled: enabled });
}, [setInputSpellcheckEnabled]);
const lightThemes = React.useMemo( const lightThemes = React.useMemo(
() => availableThemes () => availableThemes
.filter((theme) => theme.metadata.variant === 'light') .filter((theme) => theme.metadata.variant === 'light')
@@ -245,7 +252,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('queueMode') || shouldShow('queueMode')
|| shouldShow('textJustificationActivity') || shouldShow('textJustificationActivity')
|| shouldShow('activityHeaderTimestamps') || shouldShow('activityHeaderTimestamps')
|| shouldShow('persistDraft'); || shouldShow('persistDraft')
|| (!isMobile && shouldShow('inputSpellcheck'));
const selectedToolExpansionOption = TOOL_EXPANSION_OPTIONS.find((option) => option.value === toolCallExpansion); const selectedToolExpansionOption = TOOL_EXPANSION_OPTIONS.find((option) => option.value === toolCallExpansion);
const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab; const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab;
@@ -887,7 +895,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div> </div>
)} )}
{(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')) && (
<section className="p-2 space-y-0.5"> <section className="p-2 space-y-0.5">
{shouldShow('stickyUserHeader') && ( {shouldShow('stickyUserHeader') && (
<div <div
@@ -1014,6 +1022,29 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div> </div>
)} )}
{!isMobile && shouldShow('inputSpellcheck') && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={inputSpellcheckEnabled}
onClick={() => handleInputSpellcheckChange(!inputSpellcheckEnabled)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
handleInputSpellcheckChange(!inputSpellcheckEnabled);
}
}}
>
<Checkbox
checked={inputSpellcheckEnabled}
onChange={handleInputSpellcheckChange}
ariaLabel="Enable spellcheck in text inputs"
/>
<span className="typography-ui-label text-foreground">Enable Spellcheck in Text Inputs</span>
</div>
)}
{shouldShow('reasoning') && ( {shouldShow('reasoning') && (
<div <div
className="group flex cursor-pointer items-center gap-2 py-1.5" className="group flex cursor-pointer items-center gap-2 py-1.5"
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
interface CommitInputProps { interface CommitInputProps {
value: string; value: string;
@@ -8,6 +9,7 @@ interface CommitInputProps {
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
hasTouchInput?: boolean; hasTouchInput?: boolean;
isMobile?: boolean;
} }
const MIN_HEIGHT = 38; // Single line height const MIN_HEIGHT = 38; // Single line height
@@ -19,8 +21,10 @@ export const CommitInput: React.FC<CommitInputProps> = ({
placeholder = 'Commit message', placeholder = 'Commit message',
disabled = false, disabled = false,
hasTouchInput = false, hasTouchInput = false,
isMobile = false,
}) => { }) => {
const textareaRef = React.useRef<HTMLTextAreaElement>(null); const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
// Auto-resize based on content (layout phase to avoid mount flicker) // Auto-resize based on content (layout phase to avoid mount flicker)
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
@@ -45,7 +49,7 @@ export const CommitInput: React.FC<CommitInputProps> = ({
disabled={disabled} disabled={disabled}
autoCorrect={hasTouchInput ? 'on' : 'off'} autoCorrect={hasTouchInput ? 'on' : 'off'}
autoCapitalize={hasTouchInput ? 'sentences' : 'off'} autoCapitalize={hasTouchInput ? 'sentences' : 'off'}
spellCheck={hasTouchInput ? true : false} spellCheck={isMobile || inputSpellcheckEnabled}
scrollbarClassName="hidden" scrollbarClassName="hidden"
className={cn( className={cn(
'rounded-lg bg-transparent resize-none overflow-y-hidden', 'rounded-lg bg-transparent resize-none overflow-y-hidden',
@@ -105,6 +105,7 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
placeholder="Commit message" placeholder="Commit message"
disabled={commitAction !== null} disabled={commitAction !== null}
hasTouchInput={hasTouchInput} hasTouchInput={hasTouchInput}
isMobile={isMobile}
/> />
{gitmojiEnabled && ( {gitmojiEnabled && (
+1
View File
@@ -525,6 +525,7 @@ export interface SettingsPayload {
queueModeEnabled?: boolean; queueModeEnabled?: boolean;
gitmojiEnabled?: boolean; gitmojiEnabled?: boolean;
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed' | 'changes'; toolCallExpansion?: 'collapsed' | 'activity' | 'detailed' | 'changes';
inputSpellcheckEnabled?: boolean;
fontSize?: number; fontSize?: number;
terminalFontSize?: number; terminalFontSize?: number;
padding?: number; padding?: number;
+1
View File
@@ -116,6 +116,7 @@ export type DesktopSettings = {
gitModelId?: string; gitModelId?: string;
pwaAppName?: string; pwaAppName?: string;
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed' | 'changes'; toolCallExpansion?: 'collapsed' | 'activity' | 'detailed' | 'changes';
inputSpellcheckEnabled?: boolean;
userMessageRenderingMode?: 'markdown' | 'plain'; userMessageRenderingMode?: 'markdown' | 'plain';
stickyUserHeader?: boolean; stickyUserHeader?: boolean;
fontSize?: number; fontSize?: number;
+6
View File
@@ -374,6 +374,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
store.setToolCallExpansion(settings.toolCallExpansion); store.setToolCallExpansion(settings.toolCallExpansion);
} }
} }
if (typeof settings.inputSpellcheckEnabled === 'boolean' && settings.inputSpellcheckEnabled !== store.inputSpellcheckEnabled) {
store.setInputSpellcheckEnabled(settings.inputSpellcheckEnabled);
}
if (typeof settings.userMessageRenderingMode === 'string' if (typeof settings.userMessageRenderingMode === 'string'
&& (settings.userMessageRenderingMode === 'markdown' || settings.userMessageRenderingMode === 'plain')) { && (settings.userMessageRenderingMode === 'markdown' || settings.userMessageRenderingMode === 'plain')) {
if (settings.userMessageRenderingMode !== store.userMessageRenderingMode) { if (settings.userMessageRenderingMode !== store.userMessageRenderingMode) {
@@ -741,6 +744,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
) { ) {
result.toolCallExpansion = candidate.toolCallExpansion; result.toolCallExpansion = candidate.toolCallExpansion;
} }
if (typeof candidate.inputSpellcheckEnabled === 'boolean') {
result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled;
}
if (typeof candidate.userMessageRenderingMode === 'string' if (typeof candidate.userMessageRenderingMode === 'string'
&& (candidate.userMessageRenderingMode === 'markdown' || candidate.userMessageRenderingMode === 'plain')) { && (candidate.userMessageRenderingMode === 'markdown' || candidate.userMessageRenderingMode === 'plain')) {
result.userMessageRenderingMode = candidate.userMessageRenderingMode; result.userMessageRenderingMode = candidate.userMessageRenderingMode;
+7
View File
@@ -550,6 +550,7 @@ interface UIStore {
showTerminalQuickKeysOnDesktop: boolean; showTerminalQuickKeysOnDesktop: boolean;
persistChatDraft: boolean; persistChatDraft: boolean;
inputSpellcheckEnabled: boolean;
mermaidRenderingMode: MermaidRenderingMode; mermaidRenderingMode: MermaidRenderingMode;
userMessageRenderingMode: UserMessageRenderingMode; userMessageRenderingMode: UserMessageRenderingMode;
stickyUserHeader: boolean; stickyUserHeader: boolean;
@@ -660,6 +661,7 @@ interface UIStore {
setSummaryLength: (value: number) => void; setSummaryLength: (value: number) => void;
setMaxLastMessageLength: (value: number) => void; setMaxLastMessageLength: (value: number) => void;
setPersistChatDraft: (value: boolean) => void; setPersistChatDraft: (value: boolean) => void;
setInputSpellcheckEnabled: (value: boolean) => void;
setMermaidRenderingMode: (value: MermaidRenderingMode) => void; setMermaidRenderingMode: (value: MermaidRenderingMode) => void;
setUserMessageRenderingMode: (value: UserMessageRenderingMode) => void; setUserMessageRenderingMode: (value: UserMessageRenderingMode) => void;
setStickyUserHeader: (value: boolean) => void; setStickyUserHeader: (value: boolean) => void;
@@ -770,6 +772,7 @@ export const useUIStore = create<UIStore>()(
showTerminalQuickKeysOnDesktop: false, showTerminalQuickKeysOnDesktop: false,
persistChatDraft: true, persistChatDraft: true,
inputSpellcheckEnabled: false,
mermaidRenderingMode: 'svg', mermaidRenderingMode: 'svg',
userMessageRenderingMode: 'markdown', userMessageRenderingMode: 'markdown',
stickyUserHeader: true, stickyUserHeader: true,
@@ -1668,6 +1671,9 @@ export const useUIStore = create<UIStore>()(
setPersistChatDraft: (value) => { setPersistChatDraft: (value) => {
set({ persistChatDraft: value }); set({ persistChatDraft: value });
}, },
setInputSpellcheckEnabled: (value) => {
set({ inputSpellcheckEnabled: value });
},
setMermaidRenderingMode: (value) => { setMermaidRenderingMode: (value) => {
set({ mermaidRenderingMode: value }); set({ mermaidRenderingMode: value });
}, },
@@ -1854,6 +1860,7 @@ export const useUIStore = create<UIStore>()(
summaryLength: state.summaryLength, summaryLength: state.summaryLength,
maxLastMessageLength: state.maxLastMessageLength, maxLastMessageLength: state.maxLastMessageLength,
persistChatDraft: state.persistChatDraft, persistChatDraft: state.persistChatDraft,
inputSpellcheckEnabled: state.inputSpellcheckEnabled,
mermaidRenderingMode: state.mermaidRenderingMode, mermaidRenderingMode: state.mermaidRenderingMode,
userMessageRenderingMode: state.userMessageRenderingMode, userMessageRenderingMode: state.userMessageRenderingMode,
stickyUserHeader: state.stickyUserHeader, stickyUserHeader: state.stickyUserHeader,
+3
View File
@@ -2094,6 +2094,9 @@ const sanitizeSettingsUpdate = (payload) => {
result.toolCallExpansion = mode; result.toolCallExpansion = mode;
} }
} }
if (typeof candidate.inputSpellcheckEnabled === 'boolean') {
result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled;
}
if (typeof candidate.userMessageRenderingMode === 'string') { if (typeof candidate.userMessageRenderingMode === 'string') {
const mode = candidate.userMessageRenderingMode.trim(); const mode = candidate.userMessageRenderingMode.trim();
if (mode === 'markdown' || mode === 'plain') { if (mode === 'markdown' || mode === 'plain') {