From 245d8db74bba4b2319bf6a86212ae3af3b5da38e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 04:44:58 +0000 Subject: [PATCH 1/2] feat(ui): shell-style message history recall in the chat composer ArrowUp on the composer's first line steps back through this session's previously sent messages (consecutive duplicates collapsed); ArrowDown steps forward and finally restores the unsent draft. Guards keep normal editing intact: recall only fires with a collapsed cursor on the first/last line, never during IME composition, and any manual edit or send exits history-browsing mode. The cursor lands at the end of each recalled message. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016d9SxeefoRSLZmQ1oKo17j Signed-off-by: Feelings0220 <124495386+Feelings0220@users.noreply.github.com> --- ui/src/components/chat/ChatInterface.tsx | 73 +++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/ui/src/components/chat/ChatInterface.tsx b/ui/src/components/chat/ChatInterface.tsx index b3c92b3af..1e475ea2e 100644 --- a/ui/src/components/chat/ChatInterface.tsx +++ b/ui/src/components/chat/ChatInterface.tsx @@ -171,6 +171,27 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se // Shared call_id -> is_error lookup so each group summary is O(group size). const toolResultsByCallId = useMemo(() => buildToolCallResultsIndex(allMessages), [allMessages]); + // Shell-style composer history: this session's user message texts, oldest + // first, consecutive duplicates collapsed. Recalled with ArrowUp/ArrowDown. + const userMessageHistory = useMemo(() => { + const texts: string[] = []; + for (const msg of allMessages) { + if (msg.role !== "user") continue; + const text = (msg.parts ?? []) + .filter(part => part.kind === "text") + .map(part => (part as { text?: string }).text ?? "") + .join("") + .trim(); + if (text && texts[texts.length - 1] !== text) texts.push(text); + } + return texts; + }, [allMessages]); + const composerRef = useRef(null); + // null = not browsing history; otherwise the index currently shown. + const historyIndexRef = useRef(null); + // The in-progress draft to restore when stepping past the newest entry. + const historyDraftRef = useRef(""); + const { handleMessageEvent } = useMemo(() => createMessageHandlers({ setMessages: setStreamingMessages, setIsStreaming, @@ -336,6 +357,7 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se if (guardResult === "blocked") return; } + historyIndexRef.current = null; setCurrentInputMessage(""); setChatStatus("thinking"); setStoredMessages(prev => [...prev, ...streamingMessages]); @@ -984,6 +1006,50 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se }; const handleKeyDown = (e: React.KeyboardEvent) => { + // Shell-style history recall: ArrowUp on the first line steps back through + // this session's sent messages, ArrowDown steps forward and finally + // restores the unsent draft. Never triggers mid-multiline navigation, and + // stays inert during IME composition. + if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !e.nativeEvent.isComposing) { + const textarea = composerRef.current; + if (textarea && userMessageHistory.length > 0) { + const { selectionStart, selectionEnd, value } = textarea; + const collapsed = selectionStart === selectionEnd; + const onFirstLine = !value.slice(0, selectionStart).includes("\n"); + const onLastLine = !value.slice(selectionEnd).includes("\n"); + const placeCursorAtEnd = (text: string) => + requestAnimationFrame(() => textarea.setSelectionRange(text.length, text.length)); + + if (e.key === "ArrowUp" && collapsed && onFirstLine) { + if (historyIndexRef.current === null) { + historyDraftRef.current = value; + historyIndexRef.current = userMessageHistory.length; + } + if (historyIndexRef.current > 0) { + e.preventDefault(); + historyIndexRef.current -= 1; + const recalled = userMessageHistory[historyIndexRef.current]; + setCurrentInputMessage(recalled); + placeCursorAtEnd(recalled); + } + return; + } + if (e.key === "ArrowDown" && collapsed && onLastLine && historyIndexRef.current !== null) { + e.preventDefault(); + historyIndexRef.current += 1; + if (historyIndexRef.current >= userMessageHistory.length) { + historyIndexRef.current = null; + setCurrentInputMessage(historyDraftRef.current); + placeCursorAtEnd(historyDraftRef.current); + } else { + const recalled = userMessageHistory[historyIndexRef.current]; + setCurrentInputMessage(recalled); + placeCursorAtEnd(recalled); + } + return; + } + } + } if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); if (currentInputMessage.trim() && selectedAgentName && selectedNamespace && chatStatus === "ready") { @@ -1102,9 +1168,14 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se