@@ -3,9 +3,11 @@ import { useStore } from '@nanostores/react';
33import { useAppDispatch , useAppSelector } from 'app/store/storeHooks' ;
44import { usePersistedTextAreaSize } from 'common/hooks/usePersistedTextareaSize' ;
55import {
6+ positivePromptAddedToHistory ,
67 positivePromptChanged ,
78 selectModelSupportsNegativePrompt ,
89 selectPositivePrompt ,
10+ selectPositivePromptHistory ,
911} from 'features/controlLayers/store/paramsSlice' ;
1012import { promptGenerationFromImageDndTarget } from 'features/dnd/dnd' ;
1113import { DndDropTarget } from 'features/dnd/DndDropTarget' ;
@@ -27,7 +29,7 @@ import {
2729import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData' ;
2830import { selectAllowPromptExpansion } from 'features/system/store/configSlice' ;
2931import { selectActiveTab } from 'features/ui/store/uiSelectors' ;
30- import { memo , useCallback , useMemo , useRef } from 'react' ;
32+ import { memo , useCallback , useEffect , useMemo , useRef } from 'react' ;
3133import type { HotkeyCallback } from 'react-hotkeys-hook' ;
3234import { useTranslation } from 'react-i18next' ;
3335import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets' ;
@@ -43,6 +45,7 @@ const persistOptions: Parameters<typeof usePersistedTextAreaSize>[2] = {
4345export const ParamPositivePrompt = memo ( ( ) => {
4446 const dispatch = useAppDispatch ( ) ;
4547 const prompt = useAppSelector ( selectPositivePrompt ) ;
48+ const history = useAppSelector ( selectPositivePromptHistory ) ;
4649 const viewMode = useAppSelector ( selectStylePresetViewMode ) ;
4750 const activeStylePresetId = useAppSelector ( selectStylePresetActivePresetId ) ;
4851 const modelSupportsNegativePrompt = useAppSelector ( selectModelSupportsNegativePrompt ) ;
@@ -77,6 +80,22 @@ export const ParamPositivePrompt = memo(() => {
7780 isDisabled : isPromptExpansionPending ,
7881 } ) ;
7982
83+ // Browsing state for boundary Up/Down traversal
84+ const browsingIndexRef = useRef < number | null > ( null ) ; // null => not browsing; 0..n => index in history
85+ const preBrowsePromptRef = useRef < string > ( '' ) ; // original prompt when browsing started
86+ const lastHistoryFirstRef = useRef < string | undefined > ( undefined ) ;
87+
88+ // Reset browsing when history updates due to a new generation (first item changes or history mutates)
89+ useEffect ( ( ) => {
90+ if ( lastHistoryFirstRef . current !== history [ 0 ] ) {
91+ browsingIndexRef . current = null ;
92+ preBrowsePromptRef . current = '' ;
93+ lastHistoryFirstRef . current = history [ 0 ] ;
94+ }
95+ } , [ history ] ) ;
96+
97+ // Boundary navigation via Up/Down keys was replaced by explicit hotkeys below.
98+
8099 const focus : HotkeyCallback = useCallback (
81100 ( e ) => {
82101 onFocus ( ) ;
@@ -93,6 +112,112 @@ export const ParamPositivePrompt = memo(() => {
93112 dependencies : [ focus ] ,
94113 } ) ;
95114
115+ // Helper: check if prompt textarea is focused
116+ const isPromptFocused = useCallback ( ( ) => document . activeElement === textareaRef . current , [ ] ) ;
117+
118+ // Compute a starting working history and ensure current prompt is bumped into history
119+ const startBrowsing = useCallback ( ( ) => {
120+ if ( browsingIndexRef . current !== null ) return ;
121+ preBrowsePromptRef . current = prompt ?? '' ;
122+ const trimmedCurrent = ( prompt ?? '' ) . trim ( ) ;
123+ if ( trimmedCurrent ) {
124+ dispatch ( positivePromptAddedToHistory ( trimmedCurrent ) ) ;
125+ }
126+ browsingIndexRef . current = 0 ;
127+ } , [ dispatch , prompt ] ) ;
128+
129+ const applyHistoryAtIndex = useCallback (
130+ ( idx : number , placeCaretAt : 'start' | 'end' ) => {
131+ const list = history ;
132+ if ( list . length === 0 ) return ;
133+ const clamped = Math . max ( 0 , Math . min ( idx , list . length - 1 ) ) ;
134+ browsingIndexRef . current = clamped ;
135+ dispatch ( positivePromptChanged ( list [ clamped ] ) ) ;
136+ requestAnimationFrame ( ( ) => {
137+ const el = textareaRef . current ;
138+ if ( ! el ) return ;
139+ if ( placeCaretAt === 'start' ) {
140+ el . selectionStart = 0 ;
141+ el . selectionEnd = 0 ;
142+ } else {
143+ const end = el . value . length ;
144+ el . selectionStart = end ;
145+ el . selectionEnd = end ;
146+ }
147+ } ) ;
148+ } ,
149+ [ dispatch , history ]
150+ ) ;
151+
152+ const browsePrev = useCallback ( ( ) => {
153+ if ( ! isPromptFocused ( ) ) return ;
154+ if ( history . length === 0 ) return ;
155+ if ( browsingIndexRef . current === null ) {
156+ startBrowsing ( ) ;
157+ // Move to older entry on first activation
158+ if ( history . length > 1 ) {
159+ applyHistoryAtIndex ( 1 , 'start' ) ;
160+ } else {
161+ applyHistoryAtIndex ( 0 , 'start' ) ;
162+ }
163+ return ;
164+ }
165+ // Already browsing, go older if possible
166+ const next = Math . min ( ( browsingIndexRef . current ?? 0 ) + 1 , history . length - 1 ) ;
167+ applyHistoryAtIndex ( next , 'start' ) ;
168+ } , [ applyHistoryAtIndex , history . length , isPromptFocused , startBrowsing ] ) ;
169+
170+ const browseNext = useCallback ( ( ) => {
171+ if ( ! isPromptFocused ( ) ) return ;
172+ if ( history . length === 0 ) return ;
173+ if ( browsingIndexRef . current === null ) {
174+ // Not browsing; Down does nothing (matches shell semantics)
175+ return ;
176+ }
177+ if ( ( browsingIndexRef . current ?? 0 ) > 0 ) {
178+ const next = ( browsingIndexRef . current ?? 0 ) - 1 ;
179+ applyHistoryAtIndex ( next , 'end' ) ;
180+ } else {
181+ // Exit browsing and restore pre-browse prompt
182+ browsingIndexRef . current = null ;
183+ dispatch ( positivePromptChanged ( preBrowsePromptRef . current ) ) ;
184+ requestAnimationFrame ( ( ) => {
185+ const el = textareaRef . current ;
186+ if ( el ) {
187+ const end = el . value . length ;
188+ el . selectionStart = end ;
189+ el . selectionEnd = end ;
190+ }
191+ } ) ;
192+ }
193+ } , [ applyHistoryAtIndex , dispatch , history . length , isPromptFocused ] ) ;
194+
195+ // Register hotkeys for browsing
196+ useRegisteredHotkeys ( {
197+ id : 'promptHistoryPrev' ,
198+ category : 'app' ,
199+ callback : ( e ) => {
200+ if ( isPromptFocused ( ) ) {
201+ e . preventDefault ( ) ;
202+ browsePrev ( ) ;
203+ }
204+ } ,
205+ options : { preventDefault : true , enableOnFormTags : [ 'INPUT' , 'SELECT' , 'TEXTAREA' ] } ,
206+ dependencies : [ browsePrev , isPromptFocused ] ,
207+ } ) ;
208+ useRegisteredHotkeys ( {
209+ id : 'promptHistoryNext' ,
210+ category : 'app' ,
211+ callback : ( e ) => {
212+ if ( isPromptFocused ( ) ) {
213+ e . preventDefault ( ) ;
214+ browseNext ( ) ;
215+ }
216+ } ,
217+ options : { preventDefault : true , enableOnFormTags : [ 'INPUT' , 'SELECT' , 'TEXTAREA' ] } ,
218+ dependencies : [ browseNext , isPromptFocused ] ,
219+ } ) ;
220+
96221 const dndTargetData = useMemo ( ( ) => promptGenerationFromImageDndTarget . getData ( ) , [ ] ) ;
97222
98223 return (
0 commit comments