@@ -20,10 +20,20 @@ import {
2020 isUrlOnly ,
2121 shouldAutoConvertLongText ,
2222} from "@posthog/core/message-editor/paste" ;
23+ import {
24+ formatHotkey ,
25+ SHORTCUTS ,
26+ } from "@posthog/ui/features/command/keyboard-shortcuts" ;
27+ import {
28+ PROMPT_RECALL_HINT_KEY ,
29+ type PromptRecallHandler ,
30+ } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall" ;
2331import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore" ;
2432import { useSettingsStore as useFeatureSettingsStore } from "@posthog/ui/features/settings/settingsStore" ;
25- import { toast } from "@posthog/ui/primitives/toast" ;
33+ import { type ToastOptions , toast } from "@posthog/ui/primitives/toast" ;
2634import { isSendMessageSubmitKey } from "@posthog/ui/utils/sendMessageKey" ;
35+ import type { Node as ProseMirrorNode } from "@tiptap/pm/model" ;
36+ import { TextSelection } from "@tiptap/pm/state" ;
2737import type { EditorView } from "@tiptap/pm/view" ;
2838import { useEditor } from "@tiptap/react" ;
2939import type React from "react" ;
@@ -61,6 +71,7 @@ export interface UseTiptapEditorOptions {
6171 } ;
6272 clearOnSubmit ?: boolean ;
6373 getPromptHistory ?: ( ) => string [ ] ;
74+ onPromptRecall ?: PromptRecallHandler ;
6475 onBeforeSubmit ?: ( text : string , clearEditor : ( ) => void ) => boolean ;
6576 onSubmit ?: ( text : string ) => void ;
6677 onBashCommand ?: ( command : string ) => void ;
@@ -191,15 +202,49 @@ async function resolveGithubRefChip(
191202 ) ;
192203}
193204
194- function showPasteHint ( message : string , description : string ) : void {
205+ function replaceComposerText ( view : EditorView , text = "" ) {
206+ const tr = view . state . tr . delete ( 1 , view . state . doc . content . size - 1 ) ;
207+ return text ? tr . insertText ( text , 1 ) : tr ;
208+ }
209+
210+ function hasVisibleSuggestionPopup ( sessionId : string ) : boolean {
211+ // tippy.js sets data-state="hidden" when hiding via .hide(); the session
212+ // tag keeps another mounted composer's popup from matching.
213+ return (
214+ document . querySelector (
215+ `[data-tippy-root] .tippy-box:not([data-state='hidden']) [data-suggestion-session="${ CSS . escape ( sessionId ) } "]` ,
216+ ) !== null
217+ ) ;
218+ }
219+
220+ function showHintOnce (
221+ key : string ,
222+ title : string ,
223+ detail : string | ToastOptions ,
224+ ) : void {
195225 const store = useFeatureSettingsStore . getState ( ) ;
196- const key =
197- message === "Pasted as file attachment" ? "paste-as-file" : "paste-inline" ;
198226 if ( ! store . shouldShowHint ( key ) ) return ;
199227 store . recordHintShown ( key ) ;
200- toast . info ( message , {
228+ toast . info ( title , detail ) ;
229+ }
230+
231+ function showMessageNavHint ( ) : void {
232+ showHintOnce (
233+ PROMPT_RECALL_HINT_KEY ,
234+ "Recalled a sent prompt" ,
235+ `Use ${ formatHotkey ( SHORTCUTS . MESSAGE_PREV ) } and ${ formatHotkey ( SHORTCUTS . MESSAGE_NEXT ) } to jump between your messages in the conversation.` ,
236+ ) ;
237+ }
238+
239+ function showPasteHint ( message : string , description : string ) : void {
240+ const key =
241+ message === "Pasted as file attachment" ? "paste-as-file" : "paste-inline" ;
242+ showHintOnce ( key , message , {
201243 description,
202- action : { label : "Got it" , onClick : ( ) => store . markHintLearned ( key ) } ,
244+ action : {
245+ label : "Got it" ,
246+ onClick : ( ) => useFeatureSettingsStore . getState ( ) . markHintLearned ( key ) ,
247+ } ,
203248 } ) ;
204249}
205250
@@ -216,6 +261,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
216261 capabilities = { } ,
217262 clearOnSubmit = true ,
218263 getPromptHistory,
264+ onPromptRecall,
219265 onBeforeSubmit,
220266 onSubmit,
221267 onBashCommand,
@@ -256,6 +302,14 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
256302 const getPromptHistoryRef = useRef ( getPromptHistory ) ;
257303 getPromptHistoryRef . current = getPromptHistory ;
258304
305+ const onPromptRecallRef = useRef ( onPromptRecall ) ;
306+ onPromptRecallRef . current = onPromptRecall ;
307+
308+ // Doc snapshot taken when arrow-key recall first replaces the input, so
309+ // arrowing back down past the newest prompt restores what was being typed
310+ // (kept as a ProseMirror node to preserve mention chips).
311+ const promptRecallDraftRef = useRef < ProseMirrorNode | null > ( null ) ;
312+
259313 const prevBashModeRef = useRef ( false ) ;
260314 const prevIsEmptyRef = useRef ( true ) ;
261315 const submitRef = useRef < ( ) => void > ( ( ) => { } ) ;
@@ -324,11 +378,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
324378
325379 if ( isSendMessageSubmitKey ( event ) ) {
326380 if ( ! view . editable || submitDisabledRef . current ) return false ;
327- // tippy.js sets data-state="hidden" when hiding via .hide()
328- const visibleSuggestion = document . querySelector (
329- "[data-tippy-root] .tippy-box:not([data-state='hidden'])" ,
330- ) ;
331- if ( visibleSuggestion ) return false ;
381+ if ( hasVisibleSuggestionPopup ( sessionId ) ) return false ;
332382 event . preventDefault ( ) ;
333383 historyActions . reset ( ) ;
334384 submitRef . current ( ) ;
@@ -337,12 +387,17 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
337387
338388 if (
339389 ( event . key === "ArrowUp" || event . key === "ArrowDown" ) &&
340- // Only navigate prompt history when the input is empty, so arrow
341- // keys (and Shift+Arrow selection) behave normally while editing.
342- ! event . shiftKey
390+ // Plain arrows only: Shift+Arrow selects, and Alt/Cmd/Ctrl arrow
391+ // chords are global shortcuts handled elsewhere.
392+ ! event . shiftKey &&
393+ ! event . altKey &&
394+ ! event . metaKey &&
395+ ! event . ctrlKey
343396 ) {
344397 const historyGetter = getPromptHistoryRef . current ;
345- if ( ! taskId && ! historyGetter ) return false ;
398+ if ( ! taskId && ! historyGetter && ! onPromptRecallRef . current ) {
399+ return false ;
400+ }
346401
347402 const currentText = view . state . doc . textContent ;
348403 const isEmpty = ! currentText . trim ( ) ;
@@ -355,23 +410,15 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
355410 sessionStoreSetters . dequeueMessagesAsText ( taskId ) ;
356411 if ( queuedContent !== null && queuedContent !== undefined ) {
357412 event . preventDefault ( ) ;
358- view . dispatch (
359- view . state . tr
360- . delete ( 1 , view . state . doc . content . size - 1 )
361- . insertText ( queuedContent , 1 ) ,
362- ) ;
413+ view . dispatch ( replaceComposerText ( view , queuedContent ) ) ;
363414 return true ;
364415 }
365416 }
366417
367418 const newText = historyActions . navigateUp ( history , currentText ) ;
368419 if ( newText !== null ) {
369420 event . preventDefault ( ) ;
370- view . dispatch (
371- view . state . tr
372- . delete ( 1 , view . state . doc . content . size - 1 )
373- . insertText ( newText , 1 ) ,
374- ) ;
421+ view . dispatch ( replaceComposerText ( view , newText ) ) ;
375422 return true ;
376423 }
377424 }
@@ -380,14 +427,60 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
380427 const newText = historyActions . navigateDown ( history ) ;
381428 if ( newText !== null ) {
382429 event . preventDefault ( ) ;
383- view . dispatch (
384- view . state . tr
385- . delete ( 1 , view . state . doc . content . size - 1 )
386- . insertText ( newText , 1 ) ,
387- ) ;
430+ view . dispatch ( replaceComposerText ( view , newText ) ) ;
388431 return true ;
389432 }
390433 }
434+
435+ const recallPrompt = onPromptRecallRef . current ;
436+ if ( recallPrompt ) {
437+ if ( hasVisibleSuggestionPopup ( sessionId ) ) return false ;
438+
439+ const { selection, doc } = view . state ;
440+ // Arrows move the caret as usual; only a press that can't
441+ // travel further (caret already at the first or last position)
442+ // hands off to sent-prompt recall.
443+ const atBoundary =
444+ selection . empty &&
445+ ( event . key === "ArrowUp"
446+ ? selection . from <= 1
447+ : selection . to >= doc . content . size - 1 ) ;
448+ if ( ! atBoundary ) return false ;
449+
450+ const result = recallPrompt ( event . key === "ArrowUp" ? - 1 : 1 ) ;
451+ if ( ! result ) return false ;
452+ event . preventDefault ( ) ;
453+
454+ if ( result . kind === "recall" ) {
455+ if ( result . fresh ) {
456+ promptRecallDraftRef . current = view . state . doc ;
457+ showMessageNavHint ( ) ;
458+ }
459+ const tr = replaceComposerText ( view , result . text ) ;
460+ // Recalling up parks the caret at the start so the next Up
461+ // press keeps cycling; recalling down parks it at the end.
462+ if ( event . key === "ArrowUp" ) {
463+ tr . setSelection ( TextSelection . create ( tr . doc , 1 ) ) ;
464+ }
465+ view . dispatch ( tr ) ;
466+ return true ;
467+ }
468+
469+ const draft = promptRecallDraftRef . current ;
470+ promptRecallDraftRef . current = null ;
471+ if ( draft ) {
472+ const tr = view . state . tr . replaceWith (
473+ 0 ,
474+ view . state . doc . content . size ,
475+ draft . content ,
476+ ) ;
477+ tr . setSelection ( TextSelection . atEnd ( tr . doc ) ) ;
478+ view . dispatch ( tr ) ;
479+ } else {
480+ view . dispatch ( replaceComposerText ( view ) ) ;
481+ }
482+ return true ;
483+ }
391484 }
392485
393486 return false ;
@@ -635,6 +728,11 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
635728 const draft = useDraftSync ( editor , sessionId , context ) ;
636729 draftRef . current = draft ;
637730
731+ // biome-ignore lint/correctness/useExhaustiveDependencies: `editor` is the trigger: a recreated editor brings a new schema, and restoring a snapshot taken against the old one would throw on replaceWith.
732+ useEffect ( ( ) => {
733+ promptRecallDraftRef . current = null ;
734+ } , [ editor ] ) ;
735+
638736 // Keep attachmentsRef in sync with state (synchronous, no effect needed)
639737 attachmentsRef . current = attachments ;
640738
@@ -675,6 +773,8 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
675773
676774 const text = editor . getText ( ) . trim ( ) ;
677775
776+ promptRecallDraftRef . current = null ;
777+
678778 const doClear = ( ) => {
679779 if ( ! clearOnSubmit ) return ;
680780 editor . commands . clearContent ( ) ;
0 commit comments