11import { Box , Flex , Textarea } from '@invoke-ai/ui-library' ;
22import { useStore } from '@nanostores/react' ;
3- import { useAppDispatch , useAppSelector } from 'app/store/storeHooks' ;
3+ import { useAppDispatch , useAppSelector , useAppStore } from 'app/store/storeHooks' ;
44import { usePersistedTextAreaSize } from 'common/hooks/usePersistedTextareaSize' ;
55import {
6- positivePromptAddedToHistory ,
76 positivePromptChanged ,
87 selectModelSupportsNegativePrompt ,
98 selectPositivePrompt ,
@@ -29,9 +28,10 @@ import {
2928import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData' ;
3029import { selectAllowPromptExpansion } from 'features/system/store/configSlice' ;
3130import { selectActiveTab } from 'features/ui/store/uiSelectors' ;
32- import { memo , useCallback , useEffect , useMemo , useRef } from 'react' ;
31+ import React , { memo , useCallback , useMemo , useRef } from 'react' ;
3332import type { HotkeyCallback } from 'react-hotkeys-hook' ;
3433import { useTranslation } from 'react-i18next' ;
34+ import { useClickAway } from 'react-use' ;
3535import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets' ;
3636
3737import { PositivePromptHistoryIconButton } from './PositivePromptHistory' ;
@@ -42,17 +42,90 @@ const persistOptions: Parameters<typeof usePersistedTextAreaSize>[2] = {
4242 initialHeight : 120 ,
4343} ;
4444
45+ const usePromptHistory = ( ) => {
46+ const store = useAppStore ( ) ;
47+ const history = useAppSelector ( selectPositivePromptHistory ) ;
48+
49+ /**
50+ * This ref is populated only when the user navigates back in history. In other words, its presence is a proxy
51+ * for "are we currently browsing history?"
52+ *
53+ * When we are moving thru history, we will always have a stashedPrompt (the prompt before we started browsing)
54+ * and a historyIdx which is an index into the history array (0 = most recent, 1 = previous, etc).
55+ */
56+ const stateRef = useRef < { stashedPrompt : string ; historyIdx : number } | null > ( null ) ;
57+
58+ const prev = useCallback ( ( ) => {
59+ if ( history . length === 0 ) {
60+ // No history, nothing to do
61+ return ;
62+ }
63+ let state = stateRef . current ;
64+ if ( ! state ) {
65+ // First time going "back" in history, init state
66+ state = { stashedPrompt : selectPositivePrompt ( store . getState ( ) ) , historyIdx : 0 } ;
67+ stateRef . current = state ;
68+ } else {
69+ // Subsequent "back" in history, increment index
70+ if ( state . historyIdx === history . length - 1 ) {
71+ // Already at the end of history, nothing to do
72+ return ;
73+ }
74+ state . historyIdx = state . historyIdx + 1 ;
75+ }
76+ // We should go "back" in history
77+ const newPrompt = history [ state . historyIdx ] ;
78+ if ( newPrompt === undefined ) {
79+ // Shouldn't happen
80+ return ;
81+ }
82+ store . dispatch ( positivePromptChanged ( newPrompt ) ) ;
83+ } , [ history , store ] ) ;
84+ const next = useCallback ( ( ) => {
85+ if ( history . length === 0 ) {
86+ // No history, nothing to do
87+ return ;
88+ }
89+ let state = stateRef . current ;
90+ if ( ! state ) {
91+ // If the user hasn't gone "back" in history, "forward" does nothing
92+ return ;
93+ }
94+ state . historyIdx = state . historyIdx - 1 ;
95+ if ( state . historyIdx < 0 ) {
96+ // Overshot to the "current" stashed prompt
97+ store . dispatch ( positivePromptChanged ( state . stashedPrompt ) ) ;
98+ // Clear state bc we're back to current prompt
99+ stateRef . current = null ;
100+ return ;
101+ }
102+ // We should go "forward" in history
103+ const newPrompt = history [ state . historyIdx ] ;
104+ if ( newPrompt === undefined ) {
105+ // Shouldn't happen
106+ return ;
107+ }
108+ store . dispatch ( positivePromptChanged ( newPrompt ) ) ;
109+ } , [ history , store ] ) ;
110+ const reset = useCallback ( ( ) => {
111+ // Clear stashed state - used when user clicks away or types in the prompt box
112+ stateRef . current = null ;
113+ } , [ ] ) ;
114+ return { prev, next, reset } ;
115+ } ;
116+
45117export const ParamPositivePrompt = memo ( ( ) => {
46118 const dispatch = useAppDispatch ( ) ;
47119 const prompt = useAppSelector ( selectPositivePrompt ) ;
48- const history = useAppSelector ( selectPositivePromptHistory ) ;
49120 const viewMode = useAppSelector ( selectStylePresetViewMode ) ;
50121 const activeStylePresetId = useAppSelector ( selectStylePresetActivePresetId ) ;
51122 const modelSupportsNegativePrompt = useAppSelector ( selectModelSupportsNegativePrompt ) ;
52123 const { isPending : isPromptExpansionPending } = useStore ( promptExpansionApi . $state ) ;
53124 const isPromptExpansionEnabled = useAppSelector ( selectAllowPromptExpansion ) ;
54125 const activeTab = useAppSelector ( selectActiveTab ) ;
55126
127+ const promptHistoryApi = usePromptHistory ( ) ;
128+
56129 const textareaRef = useRef < HTMLTextAreaElement > ( null ) ;
57130 usePersistedTextAreaSize ( 'positive_prompt' , textareaRef , persistOptions ) ;
58131
@@ -70,8 +143,11 @@ export const ParamPositivePrompt = memo(() => {
70143 const handleChange = useCallback (
71144 ( v : string ) => {
72145 dispatch ( positivePromptChanged ( v ) ) ;
146+ // When the user changes the prompt, reset the prompt history state. This event is not fired when the prompt is
147+ // changed via the prompt history navigation.
148+ promptHistoryApi . reset ( ) ;
73149 } ,
74- [ dispatch ]
150+ [ dispatch , promptHistoryApi ]
75151 ) ;
76152 const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus } = usePrompt ( {
77153 prompt,
@@ -80,21 +156,8 @@ export const ParamPositivePrompt = memo(() => {
80156 isDisabled : isPromptExpansionPending ,
81157 } ) ;
82158
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.
159+ // When the user clicks away from the textarea, reset the prompt history state.
160+ useClickAway ( textareaRef , promptHistoryApi . reset ) ;
98161
99162 const focus : HotkeyCallback = useCallback (
100163 ( e ) => {
@@ -115,124 +178,30 @@ export const ParamPositivePrompt = memo(() => {
115178 // Helper: check if prompt textarea is focused
116179 const isPromptFocused = useCallback ( ( ) => document . activeElement === textareaRef . current , [ ] ) ;
117180
118- // Compute a starting working history and ensure current prompt is bumped into history
119- const startBrowsing = useCallback ( ( ) => {
120- if ( browsingIndexRef . current !== null ) {
121- return ;
122- }
123- preBrowsePromptRef . current = prompt ?? '' ;
124- const trimmedCurrent = ( prompt ?? '' ) . trim ( ) ;
125- if ( trimmedCurrent ) {
126- dispatch ( positivePromptAddedToHistory ( trimmedCurrent ) ) ;
127- }
128- browsingIndexRef . current = 0 ;
129- } , [ dispatch , prompt ] ) ;
130-
131- const applyHistoryAtIndex = useCallback (
132- ( idx : number , placeCaretAt : 'start' | 'end' ) => {
133- const list = history ;
134- if ( list . length === 0 ) {
135- return ;
136- }
137- const clamped = Math . max ( 0 , Math . min ( idx , list . length - 1 ) ) ;
138- browsingIndexRef . current = clamped ;
139- const historyItem = list [ clamped ] ;
140- if ( historyItem !== undefined ) {
141- dispatch ( positivePromptChanged ( historyItem ) ) ;
142- }
143- requestAnimationFrame ( ( ) => {
144- const el = textareaRef . current ;
145- if ( ! el ) {
146- return ;
147- }
148- if ( placeCaretAt === 'start' ) {
149- el . selectionStart = 0 ;
150- el . selectionEnd = 0 ;
151- } else {
152- const end = el . value . length ;
153- el . selectionStart = end ;
154- el . selectionEnd = end ;
155- }
156- } ) ;
157- } ,
158- [ dispatch , history ]
159- ) ;
160-
161- const browsePrev = useCallback ( ( ) => {
162- if ( ! isPromptFocused ( ) ) {
163- return ;
164- }
165- if ( history . length === 0 ) {
166- return ;
167- }
168- if ( browsingIndexRef . current === null ) {
169- startBrowsing ( ) ;
170- // Move to older entry on first activation
171- if ( history . length > 1 ) {
172- applyHistoryAtIndex ( 1 , 'start' ) ;
173- } else {
174- applyHistoryAtIndex ( 0 , 'start' ) ;
175- }
176- return ;
177- }
178- // Already browsing, go older if possible
179- const next = Math . min ( ( browsingIndexRef . current ?? 0 ) + 1 , history . length - 1 ) ;
180- applyHistoryAtIndex ( next , 'start' ) ;
181- } , [ applyHistoryAtIndex , history . length , isPromptFocused , startBrowsing ] ) ;
182-
183- const browseNext = useCallback ( ( ) => {
184- if ( ! isPromptFocused ( ) ) {
185- return ;
186- }
187- if ( history . length === 0 ) {
188- return ;
189- }
190- if ( browsingIndexRef . current === null ) {
191- // Not browsing; Down does nothing (matches shell semantics)
192- return ;
193- }
194- if ( ( browsingIndexRef . current ?? 0 ) > 0 ) {
195- const next = ( browsingIndexRef . current ?? 0 ) - 1 ;
196- applyHistoryAtIndex ( next , 'end' ) ;
197- } else {
198- // Exit browsing and restore pre-browse prompt
199- browsingIndexRef . current = null ;
200- dispatch ( positivePromptChanged ( preBrowsePromptRef . current ) ) ;
201- requestAnimationFrame ( ( ) => {
202- const el = textareaRef . current ;
203- if ( el ) {
204- const end = el . value . length ;
205- el . selectionStart = end ;
206- el . selectionEnd = end ;
207- }
208- } ) ;
209- }
210- } , [ applyHistoryAtIndex , dispatch , history . length , isPromptFocused ] ) ;
211-
212181 // Register hotkeys for browsing
213182 useRegisteredHotkeys ( {
214183 id : 'promptHistoryPrev' ,
215184 category : 'app' ,
216185 callback : ( e ) => {
217186 if ( isPromptFocused ( ) ) {
218187 e . preventDefault ( ) ;
219- browsePrev ( ) ;
188+ promptHistoryApi . prev ( ) ;
220189 }
221190 } ,
222191 options : { preventDefault : true , enableOnFormTags : [ 'INPUT' , 'SELECT' , 'TEXTAREA' ] } ,
223- dependencies : [ browsePrev , isPromptFocused ] ,
192+ dependencies : [ promptHistoryApi . prev , isPromptFocused ] ,
224193 } ) ;
225194 useRegisteredHotkeys ( {
226195 id : 'promptHistoryNext' ,
227196 category : 'app' ,
228197 callback : ( e ) => {
229198 if ( isPromptFocused ( ) ) {
230199 e . preventDefault ( ) ;
231- browseNext ( ) ;
200+ promptHistoryApi . next ( ) ;
232201 }
233202 } ,
234203 options : { preventDefault : true , enableOnFormTags : [ 'INPUT' , 'SELECT' , 'TEXTAREA' ] } ,
235- dependencies : [ browseNext , isPromptFocused ] ,
204+ dependencies : [ promptHistoryApi . next , isPromptFocused ] ,
236205 } ) ;
237206
238207 const dndTargetData = useMemo ( ( ) => promptGenerationFromImageDndTarget . getData ( ) , [ ] ) ;
0 commit comments