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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user