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,
})
}
),