@@ -106,12 +106,14 @@ vi.mock("lucide-react", () => ({
106106 StopCircle : vi . fn ( ( ) => < span data-testid = "stop-icon" /> ) ,
107107 ChevronDown : vi . fn ( ( ) => < span data-testid = "chevron" /> ) ,
108108 Send : vi . fn ( ( ) => < span data-testid = "send-icon" /> ) ,
109+ Reply : vi . fn ( ( ) => < span data-testid = "reply-icon" /> ) ,
109110 FileCodeIcon : vi . fn ( ( ) => < span data-testid = "file-icon" /> ) ,
110111 GraduationCap : vi . fn ( ( ) => < span data-testid = "graduation-icon" /> ) ,
111112 X : vi . fn ( ( ) => < span data-testid = "x-icon" /> ) ,
112113 AlertCircle : vi . fn ( ( ) => < span data-testid = "alert-icon" /> ) ,
113114 CheckCircle : vi . fn ( ( ) => < span data-testid = "check-icon" /> ) ,
114115 Loader2 : vi . fn ( ( ) => < span data-testid = "loader-icon" /> ) ,
116+ Loader2Icon : vi . fn ( ( ) => < span data-testid = "loader-icon" /> ) ,
115117 Settings : vi . fn ( ( ) => < span data-testid = "settings-icon" /> ) ,
116118 Info : vi . fn ( ( ) => < span data-testid = "info-icon" /> ) ,
117119} ) ) ;
@@ -146,6 +148,7 @@ const defaultProps: InputPromptProps = {
146148 } ,
147149 activeEditor : null ,
148150 editingMessage : null ,
151+ messages : [ ] ,
149152 onSendPrompt : mockOnSendPrompt ,
150153 onInterrupt : mockOnInterrupt ,
151154 onSelectSkills : mockOnSelectSkills ,
@@ -208,6 +211,274 @@ describe("InputPrompt", () => {
208211 } ) ;
209212 } ) ;
210213
214+ describe ( "History navigation" , ( ) => {
215+ it ( "adds sent messages to history" , async ( ) => {
216+ render ( < InputPrompt { ...defaultProps } /> ) ;
217+ const textarea = screen . getByRole ( "textbox" ) ;
218+
219+ fireEvent . change ( textarea , { target : { value : "First message" } } ) ;
220+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
221+ expect ( mockOnSendPrompt ) . toHaveBeenCalledWith ( "First message" , [ ] , [ ] , undefined ) ;
222+
223+ fireEvent . change ( textarea , { target : { value : "Second message" } } ) ;
224+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
225+ expect ( mockOnSendPrompt ) . toHaveBeenCalledWith ( "Second message" , [ ] , [ ] , undefined ) ;
226+ } ) ;
227+
228+ it ( "navigates to previous history with ArrowUp when at start" , async ( ) => {
229+ render ( < InputPrompt { ...defaultProps } /> ) ;
230+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
231+
232+ // Add two messages to history
233+ fireEvent . change ( textarea , { target : { value : "First message" } } ) ;
234+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
235+ fireEvent . change ( textarea , { target : { value : "Second message" } } ) ;
236+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
237+
238+ // Clear and navigate with ArrowUp
239+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
240+ // Simulate cursor at start (selectionStart === 0)
241+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
242+
243+ // Should show "Second message" (most recent)
244+ await waitFor ( ( ) => {
245+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "Second message" ) ;
246+ } ) ;
247+ } ) ;
248+
249+ it ( "navigates through history with multiple ArrowUp presses" , async ( ) => {
250+ render ( < InputPrompt { ...defaultProps } /> ) ;
251+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
252+
253+ // Add three messages to history
254+ fireEvent . change ( textarea , { target : { value : "First" } } ) ;
255+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
256+ fireEvent . change ( textarea , { target : { value : "Second" } } ) ;
257+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
258+ fireEvent . change ( textarea , { target : { value : "Third" } } ) ;
259+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
260+
261+ // Navigate with ArrowUp twice
262+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
263+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
264+ await waitFor ( ( ) => {
265+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "Third" ) ;
266+ } ) ;
267+
268+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
269+ await waitFor ( ( ) => {
270+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "Second" ) ;
271+ } ) ;
272+ } ) ;
273+
274+ it ( "navigates to next history with ArrowDown" , async ( ) => {
275+ render ( < InputPrompt { ...defaultProps } /> ) ;
276+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
277+
278+ // Add messages to history
279+ fireEvent . change ( textarea , { target : { value : "First" } } ) ;
280+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
281+ fireEvent . change ( textarea , { target : { value : "Second" } } ) ;
282+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
283+ fireEvent . change ( textarea , { target : { value : "Third" } } ) ;
284+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
285+
286+ // Navigate to history - shows most recent
287+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
288+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
289+ await waitFor ( ( ) => {
290+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "Third" ) ;
291+ } ) ;
292+
293+ // ArrowUp again - goes to older
294+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
295+ await waitFor ( ( ) => {
296+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "Second" ) ;
297+ } ) ;
298+
299+ // ArrowDown - goes back to newer
300+ fireEvent . keyDown ( textarea , { key : "ArrowDown" } ) ;
301+ await waitFor ( ( ) => {
302+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "Third" ) ;
303+ } ) ;
304+
305+ // ArrowDown again - exits and restores draft (empty)
306+ fireEvent . keyDown ( textarea , { key : "ArrowDown" } ) ;
307+ await waitFor ( ( ) => {
308+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "" ) ;
309+ } ) ;
310+ } ) ;
311+
312+ it ( "restores draft when navigating past the end of history" , async ( ) => {
313+ render ( < InputPrompt { ...defaultProps } /> ) ;
314+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
315+
316+ // Add messages to history
317+ fireEvent . change ( textarea , { target : { value : "First" } } ) ;
318+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
319+ fireEvent . change ( textarea , { target : { value : "Second" } } ) ;
320+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
321+
322+ // Navigate to history (entering with empty value preserves no draft)
323+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
324+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
325+ await waitFor ( ( ) => {
326+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "Second" ) ;
327+ } ) ;
328+
329+ // Navigate back with ArrowDown (goes forward to end of history)
330+ fireEvent . keyDown ( textarea , { key : "ArrowDown" } ) ;
331+ await waitFor ( ( ) => {
332+ // Reached end of history, restores empty draft
333+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "" ) ;
334+ } ) ;
335+ } ) ;
336+
337+ it ( "exits history browsing when typing" , async ( ) => {
338+ render ( < InputPrompt { ...defaultProps } /> ) ;
339+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
340+
341+ // Add message to history
342+ fireEvent . change ( textarea , { target : { value : "History message" } } ) ;
343+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
344+
345+ // Navigate to history
346+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
347+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
348+ await waitFor ( ( ) => {
349+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "History message" ) ;
350+ } ) ;
351+
352+ // Type something - should exit history browsing
353+ fireEvent . change ( textarea , { target : { value : "New input" } } ) ;
354+
355+ // Next ArrowUp should start from beginning, not from history position
356+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
357+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
358+ await waitFor ( ( ) => {
359+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "History message" ) ;
360+ } ) ;
361+ } ) ;
362+
363+ it ( "exits history browsing when pressing non-arrow keys" , async ( ) => {
364+ render ( < InputPrompt { ...defaultProps } /> ) ;
365+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
366+
367+ // Add message to history
368+ fireEvent . change ( textarea , { target : { value : "History message" } } ) ;
369+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
370+
371+ // Navigate to history
372+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
373+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
374+ await waitFor ( ( ) => {
375+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "History message" ) ;
376+ } ) ;
377+
378+ // Press Escape - should exit history browsing but not change value
379+ fireEvent . keyDown ( textarea , { key : "Escape" } ) ;
380+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "History message" ) ;
381+ } ) ;
382+
383+ it ( "does not navigate history when no history exists" , ( ) => {
384+ render ( < InputPrompt { ...defaultProps } /> ) ;
385+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
386+
387+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
388+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
389+
390+ // Value should remain empty
391+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "" ) ;
392+ } ) ;
393+
394+ it ( "does not navigate history when caret is not at start/end" , async ( ) => {
395+ render ( < InputPrompt { ...defaultProps } /> ) ;
396+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
397+
398+ // Add message to history
399+ fireEvent . change ( textarea , { target : { value : "History message" } } ) ;
400+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
401+
402+ // Type content with caret in the middle
403+ fireEvent . change ( textarea , { target : { value : "Hello World" } } ) ;
404+ // Set cursor to middle (after "Hello")
405+ textarea . setSelectionRange ( 5 , 5 ) ;
406+
407+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
408+
409+ // Value should remain unchanged since caret is not at start
410+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "Hello World" ) ;
411+ } ) ;
412+ } ) ;
413+
414+ describe ( "Loading history from messages" , ( ) => {
415+ it ( "loads user messages from messages prop into history" , async ( ) => {
416+ const messages = [
417+ { role : "user" , content : "First message from history" } ,
418+ { role : "assistant" , content : "Assistant response" } ,
419+ { role : "user" , content : "Second message from history" } ,
420+ ] ;
421+ render ( < InputPrompt { ...defaultProps } messages = { messages } /> ) ;
422+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
423+
424+ // Navigate to history
425+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
426+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
427+
428+ await waitFor ( ( ) => {
429+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "Second message from history" ) ;
430+ } ) ;
431+
432+ // Navigate to older message
433+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
434+ await waitFor ( ( ) => {
435+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "First message from history" ) ;
436+ } ) ;
437+ } ) ;
438+
439+ it ( "clears history when messages is empty" , async ( ) => {
440+ const { rerender } = render ( < InputPrompt { ...defaultProps } messages = { [ ] } /> ) ;
441+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
442+
443+ // Add a message first
444+ fireEvent . change ( textarea , { target : { value : "New message" } } ) ;
445+ fireEvent . keyDown ( textarea , { key : "Enter" } ) ;
446+
447+ // Rerender with empty messages
448+ rerender ( < InputPrompt { ...defaultProps } messages = { [ ] } /> ) ;
449+
450+ // Try to navigate history - should not work since history is empty
451+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
452+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
453+
454+ // Value should remain empty
455+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "" ) ;
456+ } ) ;
457+
458+ it ( "ignores non-user messages when building history" , async ( ) => {
459+ const messages = [
460+ { role : "user" , content : "User message" } ,
461+ { role : "assistant" , content : "Should be ignored" } ,
462+ { role : "tool" , content : "Should also be ignored" } ,
463+ { role : "system" , content : "Ignored" } ,
464+ ] ;
465+ render ( < InputPrompt { ...defaultProps } messages = { messages } /> ) ;
466+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
467+
468+ // Navigate to history
469+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
470+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
471+
472+ await waitFor ( ( ) => {
473+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "User message" ) ;
474+ } ) ;
475+
476+ // Second ArrowUp should stay at the same message (already at oldest)
477+ fireEvent . keyDown ( textarea , { key : "ArrowUp" } ) ;
478+ expect ( ( textarea as HTMLTextAreaElement ) . value ) . toBe ( "User message" ) ;
479+ } ) ;
480+ } ) ;
481+
211482 describe ( "Loading state" , ( ) => {
212483 it ( "renders interrupt button when loading" , ( ) => {
213484 render ( < InputPrompt { ...defaultProps } loading = { true } /> ) ;
0 commit comments