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 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<ChatInputProps> = ({ 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(
@@ -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 <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
@@ -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<OpenChamberVisualSettingsProps>
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<OpenChamberVisualSettingsProps>
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<OpenChamberVisualSettingsProps>
|| 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<OpenChamberVisualSettingsProps>
</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">
{shouldShow('stickyUserHeader') && (
<div
@@ -1014,6 +1022,29 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</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') && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
@@ -1,6 +1,7 @@
import React from 'react';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
interface CommitInputProps {
value: string;
@@ -8,6 +9,7 @@ interface CommitInputProps {
placeholder?: string;
disabled?: boolean;
hasTouchInput?: boolean;
isMobile?: boolean;
}
const MIN_HEIGHT = 38; // Single line height
@@ -19,8 +21,10 @@ export const CommitInput: React.FC<CommitInputProps> = ({
placeholder = 'Commit message',
disabled = false,
hasTouchInput = false,
isMobile = false,
}) => {
const textareaRef = React.useRef<HTMLTextAreaElement>(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<CommitInputProps> = ({
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',
@@ -105,6 +105,7 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
placeholder="Commit message"
disabled={commitAction !== null}
hasTouchInput={hasTouchInput}
isMobile={isMobile}
/>
{gitmojiEnabled && (
+1
View File
@@ -525,6 +525,7 @@ export interface SettingsPayload {
queueModeEnabled?: boolean;
gitmojiEnabled?: boolean;
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed' | 'changes';
inputSpellcheckEnabled?: boolean;
fontSize?: number;
terminalFontSize?: number;
padding?: number;
+1
View File
@@ -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;
+6
View File
@@ -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;
+7
View File
@@ -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<UIStore>()(
showTerminalQuickKeysOnDesktop: false,
persistChatDraft: true,
inputSpellcheckEnabled: false,
mermaidRenderingMode: 'svg',
userMessageRenderingMode: 'markdown',
stickyUserHeader: true,
@@ -1668,6 +1671,9 @@ export const useUIStore = create<UIStore>()(
setPersistChatDraft: (value) => {
set({ persistChatDraft: value });
},
setInputSpellcheckEnabled: (value) => {
set({ inputSpellcheckEnabled: value });
},
setMermaidRenderingMode: (value) => {
set({ mermaidRenderingMode: value });
},
@@ -1854,6 +1860,7 @@ export const useUIStore = create<UIStore>()(
summaryLength: state.summaryLength,
maxLastMessageLength: state.maxLastMessageLength,
persistChatDraft: state.persistChatDraft,
inputSpellcheckEnabled: state.inputSpellcheckEnabled,
mermaidRenderingMode: state.mermaidRenderingMode,
userMessageRenderingMode: state.userMessageRenderingMode,
stickyUserHeader: state.stickyUserHeader,
+3
View File
@@ -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') {