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 { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { useFileStore } from '@/stores/fileStore';
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
import { useMessageStore } from '@/stores/messageStore';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { isIMECompositionEvent } from '@/lib/ime';
|
import { isIMECompositionEvent } from '@/lib/ime';
|
||||||
import { StopIcon } from '@/components/icons/StopIcon';
|
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 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 }) => {
|
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 [isDragging, setIsDragging] = React.useState(false);
|
||||||
const [showFileMention, setShowFileMention] = React.useState(false);
|
const [showFileMention, setShowFileMention] = React.useState(false);
|
||||||
const [mentionQuery, setMentionQuery] = React.useState('');
|
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 [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null);
|
||||||
const [mobileControlsOpen, setMobileControlsOpen] = React.useState(false);
|
const [mobileControlsOpen, setMobileControlsOpen] = React.useState(false);
|
||||||
const [mobileControlsPanel, setMobileControlsPanel] = React.useState<MobileControlsPanel>(null);
|
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 textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||||
const dropZoneRef = React.useRef<HTMLDivElement>(null);
|
const dropZoneRef = React.useRef<HTMLDivElement>(null);
|
||||||
const mentionRef = React.useRef<FileMentionHandle>(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 { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
|
||||||
const agents = getVisibleAgents();
|
const agents = getVisibleAgents();
|
||||||
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius } = useUIStore();
|
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft } = useUIStore();
|
||||||
const { working } = useAssistantStatus();
|
const { working } = useAssistantStatus();
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
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 consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
||||||
const hasDrafts = draftCount > 0;
|
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
|
// Session activity for auto-send on idle
|
||||||
const { phase: sessionPhase } = useCurrentSessionActivity();
|
const { phase: sessionPhase } = useCurrentSessionActivity();
|
||||||
const prevSessionPhaseRef = React.useRef(sessionPhase);
|
const prevSessionPhaseRef = React.useRef(sessionPhase);
|
||||||
@@ -378,6 +490,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
clearQueue(currentSessionId);
|
clearQueue(currentSessionId);
|
||||||
}
|
}
|
||||||
setMessage('');
|
setMessage('');
|
||||||
|
// Reset message history navigation state
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
setDraftMessage('');
|
||||||
if (attachedFiles.length > 0) {
|
if (attachedFiles.length > 0) {
|
||||||
clearAttachedFiles();
|
clearAttachedFiles();
|
||||||
}
|
}
|
||||||
@@ -584,6 +699,52 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
return;
|
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
|
// Handle Enter/Ctrl+Enter based on queue mode
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
|
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -93,9 +93,9 @@ const VisualSectionContent: React.FC = () => {
|
|||||||
return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'terminalFontSize', 'spacing', 'cornerRadius', 'inputBarOffset', 'terminalQuickKeys']} />;
|
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 = () => {
|
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
|
// 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 {
|
interface OpenChamberVisualSettingsProps {
|
||||||
/** Which settings to show. If undefined, shows all. */
|
/** 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 setShowTerminalQuickKeysOnDesktop = useUIStore(state => state.setShowTerminalQuickKeysOnDesktop);
|
||||||
const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled);
|
const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled);
|
||||||
const setQueueMode = useMessageQueueStore(state => state.setQueueMode);
|
const setQueueMode = useMessageQueueStore(state => state.setQueueMode);
|
||||||
|
const persistChatDraft = useUIStore(state => state.persistChatDraft);
|
||||||
|
const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft);
|
||||||
const {
|
const {
|
||||||
themeMode,
|
themeMode,
|
||||||
setThemeMode,
|
setThemeMode,
|
||||||
@@ -756,6 +758,23 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ interface UIStore {
|
|||||||
notifyOnSubtasks: boolean;
|
notifyOnSubtasks: boolean;
|
||||||
|
|
||||||
showTerminalQuickKeysOnDesktop: boolean;
|
showTerminalQuickKeysOnDesktop: boolean;
|
||||||
|
persistChatDraft: boolean;
|
||||||
|
|
||||||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
@@ -133,6 +134,7 @@ interface UIStore {
|
|||||||
setNotificationMode: (mode: 'always' | 'hidden-only') => void;
|
setNotificationMode: (mode: 'always' | 'hidden-only') => void;
|
||||||
setShowTerminalQuickKeysOnDesktop: (value: boolean) => void;
|
setShowTerminalQuickKeysOnDesktop: (value: boolean) => void;
|
||||||
setNotifyOnSubtasks: (value: boolean) => void;
|
setNotifyOnSubtasks: (value: boolean) => void;
|
||||||
|
setPersistChatDraft: (value: boolean) => void;
|
||||||
openMultiRunLauncher: () => void;
|
openMultiRunLauncher: () => void;
|
||||||
openMultiRunLauncherWithPrompt: (prompt: string) => void;
|
openMultiRunLauncherWithPrompt: (prompt: string) => void;
|
||||||
}
|
}
|
||||||
@@ -196,6 +198,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
notifyOnSubtasks: true,
|
notifyOnSubtasks: true,
|
||||||
|
|
||||||
showTerminalQuickKeysOnDesktop: false,
|
showTerminalQuickKeysOnDesktop: false,
|
||||||
|
persistChatDraft: true,
|
||||||
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
set({ theme });
|
set({ theme });
|
||||||
@@ -681,6 +684,10 @@ export const useUIStore = create<UIStore>()(
|
|||||||
setNotifyOnSubtasks: (value) => {
|
setNotifyOnSubtasks: (value) => {
|
||||||
set({ notifyOnSubtasks: value });
|
set({ notifyOnSubtasks: value });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setPersistChatDraft: (value) => {
|
||||||
|
set({ persistChatDraft: value });
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ui-store',
|
name: 'ui-store',
|
||||||
@@ -718,6 +725,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
notificationMode: state.notificationMode,
|
notificationMode: state.notificationMode,
|
||||||
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,
|
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,
|
||||||
notifyOnSubtasks: state.notifyOnSubtasks,
|
notifyOnSubtasks: state.notifyOnSubtasks,
|
||||||
|
persistChatDraft: state.persistChatDraft,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user