From ef451cf20f47d232b618073e2573293f87518aec Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 6 Mar 2026 14:31:27 -0500 Subject: [PATCH] feat(dashboard): add input history navigation with up/down arrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add shell-style up/down arrow navigation through past messages in the SimpleInput component. History is derived from the conversation data already parsed from session logs — no new state management needed. dashboard/components/SessionCard.js: - Pass `conversation` prop through to SimpleInput (line 170) - Prop chain verified: App -> SessionCard -> SimpleInput, including the Modal/enlarged path (Modal.js:69 already passes conversation) dashboard/components/SimpleInput.js: - Accept `conversation` prop and derive `userHistory` via useMemo, filtering for role === 'user' messages and mapping to content - Add historyIndexRef (-1 = not browsing) and draftRef (preserves in-progress text when entering history mode) - ArrowUp: intercepts only when cursor at position 0 and autocomplete closed, walks backward through history (newest to oldest) - ArrowDown: only when already browsing history, walks forward; past newest entry restores saved draft and exits history mode - Bounds clamp on ArrowUp prevents undefined array access if userHistory shrinks between navigations (SSE update edge case) - Reset historyIndexRef on submit (line 110) and manual input (line 141) - Textarea height recalculated after setting history text via setTimeout to run after Preact commits the state update Co-Authored-By: Claude Opus 4.6 --- dashboard/components/SessionCard.js | 1 + dashboard/components/SimpleInput.js | 62 ++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/dashboard/components/SessionCard.js b/dashboard/components/SessionCard.js index a6ea69d..cd7551d 100644 --- a/dashboard/components/SessionCard.js +++ b/dashboard/components/SessionCard.js @@ -167,6 +167,7 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio status=${session.status} onRespond=${onRespond} autocompleteConfig=${autocompleteConfig} + conversation=${conversation} /> `} diff --git a/dashboard/components/SimpleInput.js b/dashboard/components/SimpleInput.js index f833d04..079d552 100644 --- a/dashboard/components/SimpleInput.js +++ b/dashboard/components/SimpleInput.js @@ -2,7 +2,7 @@ import { html, useState, useRef, useCallback, useMemo, useEffect } from '../lib/ import { getStatusMeta } from '../utils/status.js'; import { getTriggerInfo as _getTriggerInfo, filteredSkills as _filteredSkills } from '../utils/autocomplete.js'; -export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig = null }) { +export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig = null, conversation }) { const [text, setText] = useState(''); const [focused, setFocused] = useState(false); const [sending, setSending] = useState(false); @@ -12,8 +12,15 @@ export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig = const [selectedIndex, setSelectedIndex] = useState(0); const textareaRef = useRef(null); const autocompleteRef = useRef(null); + const historyIndexRef = useRef(-1); + const draftRef = useRef(''); const meta = getStatusMeta(status); + const userHistory = useMemo( + () => (conversation || []).filter(m => m.role === 'user').map(m => m.content), + [conversation] + ); + const getTriggerInfo = useCallback((value, cursorPos) => { return _getTriggerInfo(value, cursorPos, autocompleteConfig); }, [autocompleteConfig]); @@ -100,6 +107,7 @@ export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig = try { await onRespond(sessionId, text.trim(), true, 0); setText(''); + historyIndexRef.current = -1; } catch (err) { setError('Failed to send message'); console.error('SimpleInput send error:', err); @@ -130,6 +138,7 @@ export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig = const value = e.target.value; const cursorPos = e.target.selectionStart; setText(value); + historyIndexRef.current = -1; setTriggerInfo(getTriggerInfo(value, cursorPos)); e.target.style.height = 'auto'; e.target.style.height = e.target.scrollHeight + 'px'; @@ -169,6 +178,57 @@ export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig = } } + // History navigation (only when autocomplete is closed) + if (e.key === 'ArrowUp' && !showAutocomplete && + e.target.selectionStart === 0 && e.target.selectionEnd === 0 && + userHistory.length > 0) { + e.preventDefault(); + if (historyIndexRef.current === -1) { + draftRef.current = text; + historyIndexRef.current = userHistory.length - 1; + } else if (historyIndexRef.current > 0) { + historyIndexRef.current -= 1; + } + // Clamp if history shrank since last navigation + if (historyIndexRef.current >= userHistory.length) { + historyIndexRef.current = userHistory.length - 1; + } + const historyText = userHistory[historyIndexRef.current]; + setText(historyText); + setTimeout(() => { + if (textareaRef.current) { + textareaRef.current.selectionStart = historyText.length; + textareaRef.current.selectionEnd = historyText.length; + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'; + } + }, 0); + return; + } + + if (e.key === 'ArrowDown' && !showAutocomplete && + historyIndexRef.current !== -1) { + e.preventDefault(); + historyIndexRef.current += 1; + let newText; + if (historyIndexRef.current >= userHistory.length) { + historyIndexRef.current = -1; + newText = draftRef.current; + } else { + newText = userHistory[historyIndexRef.current]; + } + setText(newText); + setTimeout(() => { + if (textareaRef.current) { + textareaRef.current.selectionStart = newText.length; + textareaRef.current.selectionEnd = newText.length; + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'; + } + }, 0); + return; + } + // Normal Enter-to-submit (only when dropdown is closed) if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault();