From 7e6265b92ddf4b7699c21ab3f09eb2f8ca443cf0 Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 10 Apr 2026 14:23:03 -0400 Subject: [PATCH] Preserve draft text when navigating command history with arrow keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When pressing Up arrow in the chat input, the current draft text was permanently lost — pressing Down arrow back to the live position returned an empty string instead of the original draft. Added a draft slot to CommandHistory that stashes the current input text on the first Up navigation and restores it when the user navigates back down past the newest history entry. The draft is cleared when a message is sent (Add). Changes: - CommandHistory: added _draft field, Navigate accepts optional currentText - Dashboard.razor JS: passes ta.value to JsNavigateHistory - Dashboard.razor C#: forwards currentText to hist.Navigate - CommandHistoryTests: 4 new tests (14 total, all passing) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/CommandHistoryTests.cs | 58 +++++++++++++++++++--- PolyPilot/Components/Pages/Dashboard.razor | 6 +-- PolyPilot/Models/CommandHistory.cs | 14 +++++- 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/PolyPilot.Tests/CommandHistoryTests.cs b/PolyPilot.Tests/CommandHistoryTests.cs index 03a7712cbc..bf96b47451 100644 --- a/PolyPilot.Tests/CommandHistoryTests.cs +++ b/PolyPilot.Tests/CommandHistoryTests.cs @@ -9,7 +9,7 @@ public class CommandHistoryTests public void Navigate_EmptyHistory_ReturnsNull() { var history = new CommandHistory(); - Assert.Null(history.Navigate(up: true)); + Assert.Null(history.Navigate(up: true, currentText: "draft")); Assert.Null(history.Navigate(up: false)); } @@ -58,19 +58,19 @@ public void Navigate_Up_CyclesThroughAllEntries() } [Fact] - public void Navigate_UpThenDown_NavigatesCorrectly() + public void Navigate_UpThenDown_RestoresDraft() { var history = new CommandHistory(); history.Add("first"); history.Add("second"); history.Add("third"); - Assert.Equal("third", history.Navigate(up: true)!.Value.Text); + Assert.Equal("third", history.Navigate(up: true, currentText: "my draft")!.Value.Text); Assert.Equal("second", history.Navigate(up: true)!.Value.Text); // Down goes back toward newest Assert.Equal("third", history.Navigate(up: false)!.Value.Text); - // Past end clears to empty - Assert.Equal("", history.Navigate(up: false)!.Value.Text); + // Past end restores the original draft + Assert.Equal("my draft", history.Navigate(up: false)!.Value.Text); } [Fact] @@ -148,14 +148,58 @@ public void IsNavigating_TrueAfterUpFalseAfterReturningToEnd() } [Fact] - public void Navigate_Down_PastEnd_ReturnsEmpty() + public void Navigate_Down_PastEnd_RestoresDraftOrEmpty() { var history = new CommandHistory(); history.Add("cmd"); - // Already past end, navigate down + // Already past end, navigate down — no draft saved, returns empty var result = history.Navigate(up: false); Assert.NotNull(result); Assert.Equal("", result!.Value.Text); } + + [Fact] + public void Navigate_DraftPreservedThroughFullCycle() + { + var history = new CommandHistory(); + history.Add("old1"); + history.Add("old2"); + + // User is typing "work in progress", presses up + Assert.Equal("old2", history.Navigate(up: true, currentText: "work in progress")!.Value.Text); + Assert.Equal("old1", history.Navigate(up: true)!.Value.Text); + // All the way back down + Assert.Equal("old2", history.Navigate(up: false)!.Value.Text); + Assert.Equal("work in progress", history.Navigate(up: false)!.Value.Text); + Assert.False(history.IsNavigating); + } + + [Fact] + public void Navigate_EmptyDraftPreserved() + { + var history = new CommandHistory(); + history.Add("cmd"); + + // User presses up with empty input + Assert.Equal("cmd", history.Navigate(up: true, currentText: "")!.Value.Text); + // Down restores empty draft + Assert.Equal("", history.Navigate(up: false)!.Value.Text); + } + + [Fact] + public void Add_ClearsDraft() + { + var history = new CommandHistory(); + history.Add("old"); + + // Navigate up with a draft + history.Navigate(up: true, currentText: "my draft"); + // Send a message — this should clear the draft + history.Add("new message"); + + // Navigate up then down — draft should be gone (empty, not "my draft") + Assert.Equal("new message", history.Navigate(up: true, currentText: "")!.Value.Text); + Assert.Equal("", history.Navigate(up: false)!.Value.Text); + } } diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index c55414fc32..5373f2cc56 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -1000,7 +1000,7 @@ if ((e.key === 'ArrowUp' && atStart) || (e.key === 'ArrowDown' && (atEnd || histNavActive))) { if (sessionName && window.__dashRef) { e.preventDefault(); - window.__dashRef.invokeMethodAsync('JsNavigateHistory', sessionName, e.key === 'ArrowUp').then(function(isNav) { + window.__dashRef.invokeMethodAsync('JsNavigateHistory', sessionName, e.key === 'ArrowUp', ta.value || '').then(function(isNav) { if (!window.__histNavActive) window.__histNavActive = {}; window.__histNavActive[sessionName] = isNav; }).catch(function() { @@ -3640,12 +3640,12 @@ } [JSInvokable] - public async Task JsNavigateHistory(string sessionName, bool up) + public async Task JsNavigateHistory(string sessionName, bool up, string? currentText = null) { if (!commandHistoryBySession.TryGetValue(sessionName, out var hist)) return false; - var result = hist.Navigate(up); + var result = hist.Navigate(up, currentText); if (result == null) return false; var (text, cursorAtStart) = result.Value; diff --git a/PolyPilot/Models/CommandHistory.cs b/PolyPilot/Models/CommandHistory.cs index 654cbe238a..1a993decdf 100644 --- a/PolyPilot/Models/CommandHistory.cs +++ b/PolyPilot/Models/CommandHistory.cs @@ -2,11 +2,14 @@ namespace PolyPilot.Models; /// /// Manages per-session command history with up/down navigation. +/// Preserves the user's in-progress draft when entering history mode +/// and restores it when navigating back down past the newest entry. /// public class CommandHistory { private readonly List _entries = new(); private int _index; + private string? _draft; private const int MaxEntries = 50; public int Count => _entries.Count; @@ -23,6 +26,7 @@ public void Add(string command) if (_entries.Count > MaxEntries) _entries.RemoveAt(0); } _index = _entries.Count; // past the end = "no selection" + _draft = null; // draft consumed — message was sent } /// @@ -31,16 +35,22 @@ public void Add(string command) /// false when navigating down (so next ArrowDown fires immediately). /// Returns null if history is empty. /// - public (string Text, bool CursorAtStart)? Navigate(bool up) + /// True for ArrowUp (older), false for ArrowDown (newer). + /// The current input text — saved as draft on first up-navigation. + public (string Text, bool CursorAtStart)? Navigate(bool up, string? currentText = null) { if (_entries.Count == 0) return null; + // Stash the draft when first entering history mode + if (up && !IsNavigating) + _draft = currentText ?? ""; + if (up) _index = Math.Max(0, _index - 1); else _index = Math.Min(_entries.Count, _index + 1); - var text = _index < _entries.Count ? _entries[_index] : ""; + var text = _index < _entries.Count ? _entries[_index] : (_draft ?? ""); return (text, up); } }