feat(chat): persist more composer input history with global or session scope; recallable with up/down arrow keys (#3035)

* feat(chat): persist input history

* feat(settings): configure input history scope

* fix(web): keep input history validation packaged

* fix(chat): preserve input history across tabs

* fix(settings): restore prompt history limit

* fix(settings): keep history deletion warning visible

* fix(i18n): restore Turkish Git empty state translations

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Matt Visnovsky
2026-09-05 19:19:03 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 29480383cc
commit 1d6b15bc04
49 changed files with 3353 additions and 458 deletions
@@ -204,6 +204,8 @@ const ChatSectionContent: React.FC = () => {
'splitAssistantMessageActions',
'subagentReadOnlyBanner',
'diffLayout',
'inputHistoryScope',
'inputHistoryLimit',
'dotfiles',
'fileViewerPreview',
'followUpBehavior',
@@ -61,9 +61,18 @@ import {
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { TerminalShellOption } from '@/lib/api/types';
import {
MAX_INPUT_HISTORY_LIMIT,
MIN_INPUT_HISTORY_LIMIT,
isInputHistoryLimit,
type InputHistoryScope,
} from '@/lib/inputHistoryScope';
import { isTerminalShell } from '@/lib/terminalShell';
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
import {
useInputHistoryStore,
} from '@/stores/useInputHistoryStore';
interface Option<T extends string> {
id: T;
@@ -279,11 +288,22 @@ const LARGE_TEXT_PASTE_BEHAVIOR_OPTIONS: Option<LargeTextPasteBehavior>[] = [
},
];
const INPUT_HISTORY_SCOPE_OPTIONS: Option<InputHistoryScope>[] = [
{
id: 'global',
labelKey: 'settings.openchamber.visual.option.inputHistoryScope.global.label',
},
{
id: 'session',
labelKey: 'settings.openchamber.visual.option.inputHistoryScope.session.label',
},
];
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
return mode === 'markdown' ? 'markdown' : 'plain';
};
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'enterToSend' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs';
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'inputHistoryScope' | 'inputHistoryLimit' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'enterToSend' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs';
const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
{ id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' },
@@ -374,6 +394,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setFileEditorKeymap = useUIStore(state => state.setFileEditorKeymap);
const followUpBehavior = useMessageQueueStore(state => state.followUpBehavior);
const setFollowUpBehavior = useMessageQueueStore(state => state.setFollowUpBehavior);
const inputHistoryScope = useInputHistoryStore(state => state.scope);
const inputHistoryLimit = useInputHistoryStore(state => state.entryLimit);
const applyInputHistoryScope = useInputHistoryStore(state => state.applyScope);
const applyInputHistoryLimit = useInputHistoryStore(state => state.applyEntryLimit);
const persistChatDraft = useUIStore(state => state.persistChatDraft);
const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft);
const inputSpellcheckEnabled = useUIStore(state => state.inputSpellcheckEnabled);
@@ -560,6 +584,20 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
void updateDesktopSettings({ messageStreamTransport: mode });
}, [setMessageStreamTransport]);
const handleInputHistoryScopeChange = React.useCallback((scope: InputHistoryScope) => {
applyInputHistoryScope(scope);
void updateDesktopSettings({ inputHistoryScope: scope });
}, [applyInputHistoryScope]);
const handleInputHistoryLimitChange = React.useCallback((value: number) => {
const nextLimit = Math.round(value);
if (!isInputHistoryLimit(nextLimit)) {
return;
}
applyInputHistoryLimit(nextLimit);
void updateDesktopSettings({ inputHistoryLimit: nextLimit });
}, [applyInputHistoryLimit]);
const handleActivityRenderModeChange = React.useCallback((mode: 'collapsed' | 'summary') => {
setActivityRenderMode(mode);
void updateDesktopSettings({ activityRenderMode: mode });
@@ -667,6 +705,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('fileViewerPreview')
|| shouldShow('reasoning')
|| shouldShow('followUpBehavior')
|| shouldShow('inputHistoryScope')
|| shouldShow('inputHistoryLimit')
|| shouldShow('persistDraft')
|| shouldShow('largeTextPaste')
|| shouldShow('showToolFileIcons')
@@ -679,7 +719,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const showBehaviorMessageOptions = shouldShow('userMessageRendering')
|| shouldShow('mermaidRendering')
|| (shouldShow('diffLayout') && !isVSCode)
|| shouldShow('followUpBehavior');
|| shouldShow('followUpBehavior')
|| shouldShow('inputHistoryScope')
|| shouldShow('inputHistoryLimit');
const showBehaviorFeatureCheckboxes = shouldShow('sessionAssist')
|| (shouldShow('sessionGoal') && !isVSCode)
|| shouldShow('subagentReadOnlyBanner')
@@ -1755,6 +1797,51 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</SettingsRadioGroup>
</SettingsControlGroup>
)}
{shouldShow('inputHistoryScope') && (
<SettingsControlGroup
title={t('settings.openchamber.visual.field.inputHistoryScope')}
info={t('settings.openchamber.visual.field.inputHistoryScopeDescription')}
settingsItem="chat.input-history-scope"
>
<SettingsRadioGroup aria-label={t('settings.openchamber.visual.section.inputHistoryScopeAria')}>
{INPUT_HISTORY_SCOPE_OPTIONS.map((option) => (
<SettingsRadioOption
key={option.id}
selected={inputHistoryScope === option.id}
onSelect={() => handleInputHistoryScopeChange(option.id)}
label={tUnsafe(option.labelKey)}
ariaLabel={tUnsafe(option.labelKey)}
/>
))}
</SettingsRadioGroup>
</SettingsControlGroup>
)}
{shouldShow('inputHistoryLimit') && (
<SettingsControlGroup
title={t('settings.openchamber.visual.field.inputHistoryLimit')}
description={t('settings.openchamber.visual.field.inputHistoryLimitDescription')}
contentClassName={SETTINGS_CONTROL_CLUSTER_CLASS}
settingsItem="chat.input-history-limit"
>
<div className={SETTINGS_NUMBER_STEPPER_ROW_CLASS}>
<NumberInput
value={inputHistoryLimit}
onValueChange={handleInputHistoryLimitChange}
min={MIN_INPUT_HISTORY_LIMIT}
max={MAX_INPUT_HISTORY_LIMIT}
step={1}
className={SETTINGS_NUMBER_INPUT_CLASS}
deferExternalValueWhileFocused
aria-label={t('settings.openchamber.visual.field.inputHistoryLimitAria')}
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>
{t('settings.openchamber.visual.field.inputHistoryLimitUnit')}
</span>
</div>
</SettingsControlGroup>
)}
</SettingsTwoColumn>
</SettingsSection>
)}