Skip to content

Commit 55e2362

Browse files
authored
feat(sessions): Navigate previous messages with plain arrow keys (#3443)
1 parent 01b8a59 commit 55e2362

13 files changed

Lines changed: 537 additions & 40 deletions

File tree

packages/ui/src/features/command/keyboard-shortcuts.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,16 +235,16 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
235235
context: "Message editor",
236236
},
237237
{
238-
id: "prompt-history-prev",
238+
id: "prompt-recall-prev",
239239
keys: "up",
240-
description: "Previous prompt (when input is empty)",
240+
description: "Recall previous prompt",
241241
category: "editor",
242242
context: "Message editor",
243243
},
244244
{
245-
id: "prompt-history-next",
245+
id: "prompt-recall-next",
246246
keys: "down",
247-
description: "Next prompt (when input is empty)",
247+
description: "Recall next prompt",
248248
category: "editor",
249249
context: "Message editor",
250250
},

packages/ui/src/features/message-editor/components/PromptInput.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk";
33
import { ArrowUp, Stop } from "@phosphor-icons/react";
44
import { InputGroup, InputGroupAddon, InputGroupButton } from "@posthog/quill";
55
import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts";
6+
import type { PromptRecallHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall";
67
import { cycleModeOption } from "@posthog/ui/features/sessions/sessionStore";
78
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
89
import { hasOpenOverlay } from "@posthog/ui/utils/overlay";
@@ -76,6 +77,8 @@ export interface PromptInputProps {
7677
hideDefaultToolbar?: boolean;
7778
// prompt history provider
7879
getPromptHistory?: () => string[];
80+
// plain Up/Down at the caret boundary recalls sent prompts into the input
81+
onPromptRecall?: PromptRecallHandler;
7982
// callbacks
8083
onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean;
8184
onSubmit?: (text: string) => void;
@@ -121,6 +124,7 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
121124
headerAddon,
122125
hideDefaultToolbar = false,
123126
getPromptHistory,
127+
onPromptRecall,
124128
onBeforeSubmit,
125129
onSubmit,
126130
onBashCommand,
@@ -177,6 +181,7 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
177181
commands: enableCommands,
178182
},
179183
getPromptHistory,
184+
onPromptRecall,
180185
onBeforeSubmit,
181186
onSubmit,
182187
onBashCommand,

packages/ui/src/features/message-editor/tiptap/CommandMention.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export function createCommandMention(options: CommandMentionOptions) {
1111

1212
return createSuggestionMention({
1313
name: "commandMention",
14+
sessionId,
1415
char: "/",
1516
chipType: "command",
1617
items: (query) =>

packages/ui/src/features/message-editor/tiptap/FileMention.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createSuggestionMention } from "./createSuggestionMention";
44
export function createFileMention(sessionId: string) {
55
return createSuggestionMention({
66
name: "fileMention",
7+
sessionId,
78
char: "@",
89
chipType: "file",
910
items: (query) => (sessionId ? getFileSuggestions(sessionId, query) : []),

packages/ui/src/features/message-editor/tiptap/IssueMention.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createSuggestionMention } from "./createSuggestionMention";
55
export function createIssueMention(sessionId: string) {
66
return createSuggestionMention({
77
name: "issueMention",
8+
sessionId,
89
char: "#",
910
chipType: "github_issue",
1011
debounceMs: 250,

packages/ui/src/features/message-editor/tiptap/createSuggestionMention.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { SuggestionList, type SuggestionListRef } from "./SuggestionList";
1212

1313
export interface SuggestionMentionConfig<T extends SuggestionItem> {
1414
name: string;
15+
/** Tags the popup so keydown checks can tell this editor's popup apart. */
16+
sessionId: string;
1517
char: string;
1618
chipType: ChipType;
1719
startOfLine?: boolean;
@@ -35,6 +37,7 @@ export function createSuggestionMention<T extends SuggestionItem>(
3537
) {
3638
const {
3739
name,
40+
sessionId,
3841
char,
3942
chipType,
4043
startOfLine = false,
@@ -97,6 +100,7 @@ export function createSuggestionMention<T extends SuggestionItem>(
97100
},
98101
editor: props.editor,
99102
});
103+
renderer.element.setAttribute("data-suggestion-session", sessionId);
100104

101105
if (!props.clientRect) return;
102106

packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts

Lines changed: 130 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
2331
import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore";
2432
import { 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";
2634
import { isSendMessageSubmitKey } from "@posthog/ui/utils/sendMessageKey";
35+
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
36+
import { TextSelection } from "@tiptap/pm/state";
2737
import type { EditorView } from "@tiptap/pm/view";
2838
import { useEditor } from "@tiptap/react";
2939
import 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

Comments
 (0)