@@ -3,9 +3,11 @@ import { useStore } from '@nanostores/react';
3
3
import { useAppDispatch , useAppSelector } from 'app/store/storeHooks' ;
4
4
import { usePersistedTextAreaSize } from 'common/hooks/usePersistedTextareaSize' ;
5
5
import {
6
+ positivePromptAddedToHistory ,
6
7
positivePromptChanged ,
7
8
selectModelSupportsNegativePrompt ,
8
9
selectPositivePrompt ,
10
+ selectPositivePromptHistory ,
9
11
} from 'features/controlLayers/store/paramsSlice' ;
10
12
import { promptGenerationFromImageDndTarget } from 'features/dnd/dnd' ;
11
13
import { DndDropTarget } from 'features/dnd/DndDropTarget' ;
@@ -27,7 +29,7 @@ import {
27
29
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData' ;
28
30
import { selectAllowPromptExpansion } from 'features/system/store/configSlice' ;
29
31
import { selectActiveTab } from 'features/ui/store/uiSelectors' ;
30
- import { memo , useCallback , useMemo , useRef } from 'react' ;
32
+ import { memo , useCallback , useEffect , useMemo , useRef } from 'react' ;
31
33
import type { HotkeyCallback } from 'react-hotkeys-hook' ;
32
34
import { useTranslation } from 'react-i18next' ;
33
35
import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets' ;
@@ -43,6 +45,7 @@ const persistOptions: Parameters<typeof usePersistedTextAreaSize>[2] = {
43
45
export const ParamPositivePrompt = memo ( ( ) => {
44
46
const dispatch = useAppDispatch ( ) ;
45
47
const prompt = useAppSelector ( selectPositivePrompt ) ;
48
+ const history = useAppSelector ( selectPositivePromptHistory ) ;
46
49
const viewMode = useAppSelector ( selectStylePresetViewMode ) ;
47
50
const activeStylePresetId = useAppSelector ( selectStylePresetActivePresetId ) ;
48
51
const modelSupportsNegativePrompt = useAppSelector ( selectModelSupportsNegativePrompt ) ;
@@ -77,6 +80,22 @@ export const ParamPositivePrompt = memo(() => {
77
80
isDisabled : isPromptExpansionPending ,
78
81
} ) ;
79
82
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
+
80
99
const focus : HotkeyCallback = useCallback (
81
100
( e ) => {
82
101
onFocus ( ) ;
@@ -93,6 +112,112 @@ export const ParamPositivePrompt = memo(() => {
93
112
dependencies : [ focus ] ,
94
113
} ) ;
95
114
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
+
96
221
const dndTargetData = useMemo ( ( ) => promptGenerationFromImageDndTarget . getData ( ) , [ ] ) ;
97
222
98
223
return (
0 commit comments