feat(dashboard): add input history navigation with up/down arrows

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 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-03-06 14:31:27 -05:00
parent fb9d4e5b9f
commit ef451cf20f
2 changed files with 62 additions and 1 deletions

View File

@@ -167,6 +167,7 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
status=${session.status} status=${session.status}
onRespond=${onRespond} onRespond=${onRespond}
autocompleteConfig=${autocompleteConfig} autocompleteConfig=${autocompleteConfig}
conversation=${conversation}
/> />
`} `}
</div> </div>

View File

@@ -2,7 +2,7 @@ import { html, useState, useRef, useCallback, useMemo, useEffect } from '../lib/
import { getStatusMeta } from '../utils/status.js'; import { getStatusMeta } from '../utils/status.js';
import { getTriggerInfo as _getTriggerInfo, filteredSkills as _filteredSkills } from '../utils/autocomplete.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 [text, setText] = useState('');
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
@@ -12,8 +12,15 @@ export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig =
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const textareaRef = useRef(null); const textareaRef = useRef(null);
const autocompleteRef = useRef(null); const autocompleteRef = useRef(null);
const historyIndexRef = useRef(-1);
const draftRef = useRef('');
const meta = getStatusMeta(status); const meta = getStatusMeta(status);
const userHistory = useMemo(
() => (conversation || []).filter(m => m.role === 'user').map(m => m.content),
[conversation]
);
const getTriggerInfo = useCallback((value, cursorPos) => { const getTriggerInfo = useCallback((value, cursorPos) => {
return _getTriggerInfo(value, cursorPos, autocompleteConfig); return _getTriggerInfo(value, cursorPos, autocompleteConfig);
}, [autocompleteConfig]); }, [autocompleteConfig]);
@@ -100,6 +107,7 @@ export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig =
try { try {
await onRespond(sessionId, text.trim(), true, 0); await onRespond(sessionId, text.trim(), true, 0);
setText(''); setText('');
historyIndexRef.current = -1;
} catch (err) { } catch (err) {
setError('Failed to send message'); setError('Failed to send message');
console.error('SimpleInput send error:', err); console.error('SimpleInput send error:', err);
@@ -130,6 +138,7 @@ export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig =
const value = e.target.value; const value = e.target.value;
const cursorPos = e.target.selectionStart; const cursorPos = e.target.selectionStart;
setText(value); setText(value);
historyIndexRef.current = -1;
setTriggerInfo(getTriggerInfo(value, cursorPos)); setTriggerInfo(getTriggerInfo(value, cursorPos));
e.target.style.height = 'auto'; e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px'; 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) // Normal Enter-to-submit (only when dropdown is closed)
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();