feat: Add ability to navigate through message history with arrows and persist message draft setting (#335)

* feat(chat): add message history navigation in ChatInput

* feat: Adds settng to persist draft of messages to local storage to save work if page refreshes or crashes
This commit is contained in:
gsxdsm
2026-02-06 11:14:09 +02:00
committed by GitHub
parent 9dbd3dbd46
commit af0dc11773
4 changed files with 193 additions and 5 deletions
+163 -2
View File
@@ -33,6 +33,7 @@ import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { toast } from '@/components/ui';
import { useFileStore } from '@/stores/fileStore';
import { useMessageStore } from '@/stores/messageStore';
import { isVSCodeRuntime } from '@/lib/desktop';
import { isIMECompositionEvent } from '@/lib/ime';
import { StopIcon } from '@/components/icons/StopIcon';
@@ -55,8 +56,27 @@ interface ChatInputProps {
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
const CHAT_INPUT_DRAFT_KEY = 'openchamber_chat_input_draft';
// Helper to safely read from localStorage
const getStoredDraft = (): string => {
try {
return localStorage.getItem(CHAT_INPUT_DRAFT_KEY) ?? '';
} catch {
return '';
}
};
export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
const [message, setMessage] = React.useState('');
// Track if we restored a draft on mount (for text selection)
const initialDraftRef = React.useRef<string | null>(null);
const [message, setMessage] = React.useState(() => {
const draft = getStoredDraft();
if (draft) {
initialDraftRef.current = draft;
}
return draft;
});
const [isDragging, setIsDragging] = React.useState(false);
const [showFileMention, setShowFileMention] = React.useState(false);
const [mentionQuery, setMentionQuery] = React.useState('');
@@ -70,6 +90,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null);
const [mobileControlsOpen, setMobileControlsOpen] = React.useState(false);
const [mobileControlsPanel, setMobileControlsPanel] = React.useState<MobileControlsPanel>(null);
// Message history navigation state (up/down arrow to recall previous messages)
const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent
const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const dropZoneRef = React.useRef<HTMLDivElement>(null);
const mentionRef = React.useRef<FileMentionHandle>(null);
@@ -95,7 +118,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
const agents = getVisibleAgents();
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius } = useUIStore();
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft } = useUIStore();
const { working } = useAssistantStatus();
const { currentTheme } = useThemeSystem();
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
@@ -131,6 +154,95 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
const hasDrafts = draftCount > 0;
// User message history for up/down arrow navigation
// Get raw messages from store (stable reference)
const sessionMessages = useMessageStore(
React.useCallback(
(state) => (currentSessionId ? state.messages.get(currentSessionId) : undefined),
[currentSessionId]
)
);
// Derive user message history with useMemo to avoid infinite re-renders
const userMessageHistory = React.useMemo(() => {
if (!sessionMessages) return [];
return sessionMessages
.filter((m) => m.info.role === 'user')
.map((m) => {
const textPart = m.parts.find((p) => p.type === 'text');
if (textPart && 'text' in textPart) {
return String(textPart.text);
}
return '';
})
.filter((text) => text.length > 0)
.reverse(); // Most recent first
}, [sessionMessages]);
// Handle initial draft restoration and text selection
const hasHandledInitialDraftRef = React.useRef(false);
React.useEffect(() => {
if (hasHandledInitialDraftRef.current) return;
hasHandledInitialDraftRef.current = true;
const draft = initialDraftRef.current;
if (!draft) return;
if (!persistChatDraft) {
// Setting disabled - clear the restored draft
setMessage('');
try {
localStorage.removeItem(CHAT_INPUT_DRAFT_KEY);
} catch {
// Ignore
}
} else {
// Setting enabled - select all text
requestAnimationFrame(() => {
textareaRef.current?.select();
});
}
}, [persistChatDraft]);
// Handle session switching: clear draft if persist disabled, select if enabled
const prevSessionIdRef = React.useRef(currentSessionId);
React.useEffect(() => {
if (prevSessionIdRef.current !== currentSessionId) {
prevSessionIdRef.current = currentSessionId;
if (!persistChatDraft) {
// Clear draft when switching sessions if persist is disabled
setMessage('');
} else if (message) {
// Select text if there's any draft when switching sessions
requestAnimationFrame(() => {
textareaRef.current?.select();
});
}
}
}, [currentSessionId, persistChatDraft, message]);
// Persist chat input draft to localStorage (only if setting enabled)
React.useEffect(() => {
if (!persistChatDraft) {
// Clear stored draft when setting is disabled
try {
localStorage.removeItem(CHAT_INPUT_DRAFT_KEY);
} catch {
// Ignore
}
return;
}
try {
if (message) {
localStorage.setItem(CHAT_INPUT_DRAFT_KEY, message);
} else {
localStorage.removeItem(CHAT_INPUT_DRAFT_KEY);
}
} catch {
// Ignore localStorage errors
}
}, [message, persistChatDraft]);
// Session activity for auto-send on idle
const { phase: sessionPhase } = useCurrentSessionActivity();
const prevSessionPhaseRef = React.useRef(sessionPhase);
@@ -378,6 +490,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
clearQueue(currentSessionId);
}
setMessage('');
// Reset message history navigation state
setHistoryIndex(-1);
setDraftMessage('');
if (attachedFiles.length > 0) {
clearAttachedFiles();
}
@@ -584,6 +699,52 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
return;
}
// Handle ArrowUp/ArrowDown for message history navigation
// ArrowUp: only when cursor at start (position 0) or input is empty
// ArrowDown: also works when cursor at end (to cycle forward through history)
const isAnyAutocompleteOpen = showCommandAutocomplete || showAgentAutocomplete || showSkillAutocomplete || showFileMention;
const cursorAtStart = textareaRef.current?.selectionStart === 0 && textareaRef.current?.selectionEnd === 0;
const cursorAtEnd = textareaRef.current?.selectionStart === message.length && textareaRef.current?.selectionEnd === message.length;
const canNavigateHistoryUp = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtStart);
const canNavigateHistoryDown = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtEnd);
if (e.key === 'ArrowUp' && canNavigateHistoryUp && userMessageHistory.length > 0) {
e.preventDefault();
if (historyIndex === -1) {
// Entering history mode - save current input as draft
setDraftMessage(message);
setHistoryIndex(0);
setMessage(userMessageHistory[0]);
} else if (historyIndex < userMessageHistory.length - 1) {
// Navigate to older message
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
setMessage(userMessageHistory[newIndex]);
}
// Move cursor to start after history navigation
requestAnimationFrame(() => {
textareaRef.current?.setSelectionRange(0, 0);
});
// If at oldest message, do nothing
return;
}
if (e.key === 'ArrowDown' && canNavigateHistoryDown && historyIndex >= 0) {
e.preventDefault();
if (historyIndex === 0) {
// Exit history mode - restore draft
setHistoryIndex(-1);
setMessage(draftMessage);
setDraftMessage('');
} else {
// Navigate to newer message
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
setMessage(userMessageHistory[newIndex]);
}
return;
}
// Handle Enter/Ctrl+Enter based on queue mode
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
e.preventDefault();
@@ -93,9 +93,9 @@ const VisualSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'terminalFontSize', 'spacing', 'cornerRadius', 'inputBarOffset', 'terminalQuickKeys']} />;
};
// Chat section: Default Tool Output, Diff layout, Show reasoning traces, Queue mode
// Chat section: Default Tool Output, Diff layout, Show reasoning traces, Queue mode, Persist draft
const ChatSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'diffLayout', 'dotfiles', 'reasoning', 'textJustificationActivity', 'queueMode']} />;
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'diffLayout', 'dotfiles', 'reasoning', 'textJustificationActivity', 'queueMode', 'persistDraft']} />;
};
// Sessions section: Default model & agent, Session retention, Memory limits
@@ -82,7 +82,7 @@ const DIFF_VIEW_MODE_OPTIONS: Option<'single' | 'stacked'>[] = [
},
];
export type VisibleSetting = 'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'toolOutput' | 'diffLayout' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys';
export type VisibleSetting = 'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'toolOutput' | 'diffLayout' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys' | 'persistDraft';
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
@@ -116,6 +116,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setShowTerminalQuickKeysOnDesktop = useUIStore(state => state.setShowTerminalQuickKeysOnDesktop);
const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled);
const setQueueMode = useMessageQueueStore(state => state.setQueueMode);
const persistChatDraft = useUIStore(state => state.persistChatDraft);
const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft);
const {
themeMode,
setThemeMode,
@@ -756,6 +758,23 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</p>
</div>
)}
{shouldShow('persistDraft') && (
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={persistChatDraft}
onChange={setPersistChatDraft}
/>
<span className="typography-ui-header font-semibold text-foreground">
Persist chat input draft
</span>
</label>
<p className="typography-meta text-muted-foreground pl-5">
Save your typed message across page reloads and session switches.
</p>
</div>
)}
</div>
);
};
+8
View File
@@ -74,6 +74,7 @@ interface UIStore {
notifyOnSubtasks: boolean;
showTerminalQuickKeysOnDesktop: boolean;
persistChatDraft: boolean;
setTheme: (theme: 'light' | 'dark' | 'system') => void;
toggleSidebar: () => void;
@@ -133,6 +134,7 @@ interface UIStore {
setNotificationMode: (mode: 'always' | 'hidden-only') => void;
setShowTerminalQuickKeysOnDesktop: (value: boolean) => void;
setNotifyOnSubtasks: (value: boolean) => void;
setPersistChatDraft: (value: boolean) => void;
openMultiRunLauncher: () => void;
openMultiRunLauncherWithPrompt: (prompt: string) => void;
}
@@ -196,6 +198,7 @@ export const useUIStore = create<UIStore>()(
notifyOnSubtasks: true,
showTerminalQuickKeysOnDesktop: false,
persistChatDraft: true,
setTheme: (theme) => {
set({ theme });
@@ -681,6 +684,10 @@ export const useUIStore = create<UIStore>()(
setNotifyOnSubtasks: (value) => {
set({ notifyOnSubtasks: value });
},
setPersistChatDraft: (value) => {
set({ persistChatDraft: value });
},
}),
{
name: 'ui-store',
@@ -718,6 +725,7 @@ export const useUIStore = create<UIStore>()(
notificationMode: state.notificationMode,
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,
notifyOnSubtasks: state.notifyOnSubtasks,
persistChatDraft: state.persistChatDraft,
})
}
),