From af0dc1177366942861c5cb8762258a8c8b93c92e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 6 Feb 2026 01:14:09 -0800 Subject: [PATCH] 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 --- packages/ui/src/components/chat/ChatInput.tsx | 165 +++++++++++++++++- .../sections/openchamber/OpenChamberPage.tsx | 4 +- .../openchamber/OpenChamberVisualSettings.tsx | 21 ++- packages/ui/src/stores/useUIStore.ts | 8 + 4 files changed, 193 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 709c444f..6ec9d045 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ onOpenSettings, scrollToBottom }) => { - const [message, setMessage] = React.useState(''); + // Track if we restored a draft on mount (for text selection) + const initialDraftRef = React.useRef(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 = ({ onOpenSettings, scrollToBo const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null); const [mobileControlsOpen, setMobileControlsOpen] = React.useState(false); const [mobileControlsPanel, setMobileControlsPanel] = React.useState(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(null); const dropZoneRef = React.useRef(null); const mentionRef = React.useRef(null); @@ -95,7 +118,7 @@ export const ChatInput: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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(); diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 0bb22743..c5587671 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -93,9 +93,9 @@ const VisualSectionContent: React.FC = () => { return ; }; -// 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 ; + 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 37a8c465..eeec857f 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -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 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

)} + + {shouldShow('persistDraft') && ( +
+ +

+ Save your typed message across page reloads and session switches. +

+
+ )} ); }; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 08f76b95..398a8b91 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -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()( notifyOnSubtasks: true, showTerminalQuickKeysOnDesktop: false, + persistChatDraft: true, setTheme: (theme) => { set({ theme }); @@ -681,6 +684,10 @@ export const useUIStore = create()( setNotifyOnSubtasks: (value) => { set({ notifyOnSubtasks: value }); }, + + setPersistChatDraft: (value) => { + set({ persistChatDraft: value }); + }, }), { name: 'ui-store', @@ -718,6 +725,7 @@ export const useUIStore = create()( notificationMode: state.notificationMode, showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop, notifyOnSubtasks: state.notifyOnSubtasks, + persistChatDraft: state.persistChatDraft, }) } ),