From b7b693b02fa95004786027801649f2dad8e9ac2f Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 12:01:29 -0700 Subject: [PATCH 01/13] navigate previous messages with arrow keys --- .../features/command/keyboard-shortcuts.ts | 2 + .../message-editor/components/PromptInput.tsx | 5 ++ .../message-editor/tiptap/useTiptapEditor.ts | 40 +++++++++- .../sessions/components/ConversationView.tsx | 55 ++++++++++++- .../sessions/components/SessionView.tsx | 10 +++ .../components/chat-thread/ChatThread.tsx | 46 ++++++++++- .../composerMessageNavigation.test.ts | 80 +++++++++++++++++++ .../chat-thread/composerMessageNavigation.ts | 37 +++++++++ 8 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.test.ts create mode 100644 packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.ts diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index 967c454e2d..243af0a1c0 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -212,6 +212,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Previous message", category: "panels", context: "Task detail", + alternateKeys: "up", }, { id: "message-next", @@ -219,6 +220,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Next message", category: "panels", context: "Task detail", + alternateKeys: "down", }, { id: "message-jump", diff --git a/packages/ui/src/features/message-editor/components/PromptInput.tsx b/packages/ui/src/features/message-editor/components/PromptInput.tsx index 57044cf66c..403160849c 100644 --- a/packages/ui/src/features/message-editor/components/PromptInput.tsx +++ b/packages/ui/src/features/message-editor/components/PromptInput.tsx @@ -76,6 +76,9 @@ export interface PromptInputProps { hideDefaultToolbar?: boolean; // prompt history provider getPromptHistory?: () => string[]; + // plain Up/Down at the caret boundary; return true when the conversation + // view consumed the keypress + onNavigateMessages?: (direction: -1 | 1) => boolean; // callbacks onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean; onSubmit?: (text: string) => void; @@ -121,6 +124,7 @@ export const PromptInput = forwardRef( headerAddon, hideDefaultToolbar = false, getPromptHistory, + onNavigateMessages, onBeforeSubmit, onSubmit, onBashCommand, @@ -177,6 +181,7 @@ export const PromptInput = forwardRef( commands: enableCommands, }, getPromptHistory, + onNavigateMessages, onBeforeSubmit, onSubmit, onBashCommand, diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index b1a0c179b8..96a9781dc4 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -61,6 +61,7 @@ export interface UseTiptapEditorOptions { }; clearOnSubmit?: boolean; getPromptHistory?: () => string[]; + onNavigateMessages?: (direction: -1 | 1) => boolean; onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean; onSubmit?: (text: string) => void; onBashCommand?: (command: string) => void; @@ -216,6 +217,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { capabilities = {}, clearOnSubmit = true, getPromptHistory, + onNavigateMessages, onBeforeSubmit, onSubmit, onBashCommand, @@ -256,6 +258,9 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const getPromptHistoryRef = useRef(getPromptHistory); getPromptHistoryRef.current = getPromptHistory; + const onNavigateMessagesRef = useRef(onNavigateMessages); + onNavigateMessagesRef.current = onNavigateMessages; + const prevBashModeRef = useRef(false); const prevIsEmptyRef = useRef(true); const submitRef = useRef<() => void>(() => {}); @@ -337,12 +342,14 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { if ( (event.key === "ArrowUp" || event.key === "ArrowDown") && - // Only navigate prompt history when the input is empty, so arrow - // keys (and Shift+Arrow selection) behave normally while editing. - !event.shiftKey + // Plain arrows only: Shift+Arrow selects, and Alt/Cmd/Ctrl arrow + // chords are global shortcuts handled elsewhere. + !event.shiftKey && + !event.altKey && + !event.metaKey && + !event.ctrlKey ) { const historyGetter = getPromptHistoryRef.current; - if (!taskId && !historyGetter) return false; const currentText = view.state.doc.textContent; const isEmpty = !currentText.trim(); @@ -388,6 +395,31 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { return true; } } + + const navigateMessages = onNavigateMessagesRef.current; + if (navigateMessages) { + const visibleSuggestion = document.querySelector( + "[data-tippy-root] .tippy-box:not([data-state='hidden'])", + ); + if (visibleSuggestion) return false; + + const { selection, doc } = view.state; + // Arrows move the caret as usual; only a press that can't + // travel further (caret already at the first or last position) + // hands off to conversation message navigation. + const atBoundary = + selection.empty && + (event.key === "ArrowUp" + ? selection.from <= 1 + : selection.to >= doc.content.size - 1); + if ( + atBoundary && + navigateMessages(event.key === "ArrowUp" ? -1 : 1) + ) { + event.preventDefault(); + return true; + } + } } return false; diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index c3f39d0f9c..6b3f82b173 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -16,6 +16,10 @@ import type { TurnContext, } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ConversationSearchBar"; +import { + type ComposerMessageNavigationHandler, + composerMessageNavigation, +} from "@posthog/ui/features/sessions/components/chat-thread/composerMessageNavigation"; import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker"; import { THREAD_HOTKEY_OPTIONS } from "@posthog/ui/features/sessions/components/chat-thread/threadHotkeys"; import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; @@ -62,7 +66,15 @@ import { type DiffWorkerFactory, } from "@posthog/ui/shell/diffWorkerHost"; import { Box, Flex, Text } from "@radix-ui/themes"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + memo, + type RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useHotkeys } from "react-hotkeys-hook"; export interface ConversationViewProps { @@ -86,6 +98,11 @@ export interface ConversationViewProps { * scrollbar from off-edge content; nested code blocks keep their own scroll. */ scrollX?: boolean; + /** + * Filled with the view's message navigation handler so the composer can + * forward plain Up/Down presses (caret at the input boundary) to it. + */ + composerNavigationRef?: RefObject; } export function ConversationView({ @@ -99,6 +116,7 @@ export function ConversationView({ compact = false, collapseMode: collapseModeProp, scrollX = true, + composerNavigationRef, }: ConversationViewProps) { const diffWorkerFactory = useService(DIFF_WORKER_FACTORY); const diffsPoolOptions = useMemo( @@ -318,6 +336,41 @@ export function ConversationView({ setShowScrollButton(false); }, []); + const navigateFromComposer = useCallback( + (direction) => { + const action = composerMessageNavigation( + userMessages.map((message) => message.id), + keyboardFocusedMessageId, + direction, + ); + if (!action) return false; + if (action.kind === "exitToBottom") { + setKeyboardFocusedMessageId(null); + scrollToBottom(); + return true; + } + const message = userMessages.find((entry) => entry.id === action.id); + if (!message) return false; + setKeyboardFocusedMessageId(action.id); + scrollToUserMessage(action.id, message.index); + return true; + }, + [ + userMessages, + keyboardFocusedMessageId, + scrollToUserMessage, + scrollToBottom, + ], + ); + + useEffect(() => { + if (!composerNavigationRef) return; + composerNavigationRef.current = navigateFromComposer; + return () => { + composerNavigationRef.current = null; + }; + }, [composerNavigationRef, navigateFromComposer]); + useEffect(() => { const handleVisibilityChange = () => { if (!document.hidden && isAtBottomRef.current) { diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index 5c84aaefa6..7cb5106d82 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -17,6 +17,7 @@ import { useAutoFocusOnTyping } from "@posthog/ui/features/message-editor/useAut import { resolveAndAttachDroppedFiles } from "@posthog/ui/features/message-editor/utils/persistFile"; import { PermissionSelector } from "@posthog/ui/features/permissions/PermissionSelector"; import { CloudInitializingView } from "@posthog/ui/features/sessions/components/CloudInitializingView"; +import type { ComposerMessageNavigationHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerMessageNavigation"; import { copyFromContextMenu, getGithubRefUrlFromEventTarget, @@ -323,6 +324,13 @@ export function SessionView({ const [isDraggingFile, setIsDraggingFile] = useState(false); const editorRef = useRef(null); + const composerNavigationRef = useRef( + null, + ); + const handleNavigateMessages = useCallback( + (direction: -1 | 1) => composerNavigationRef.current?.(direction) ?? false, + [], + ); const dragCounterRef = useRef(0); // URL of the GitHub chip the context menu was opened on, captured on // right-click so the "Copy" item can copy the link (selections can't reach it). @@ -573,6 +581,7 @@ export function SessionView({ slackThreadUrl={slackThreadUrl} compact={compact} scrollX={false} + composerNavigationRef={composerNavigationRef} /> {!useNewChatThread && } @@ -696,6 +705,7 @@ export function SessionView({ onToggleMessagingMode={ isCloudRun ? undefined : toggleMessagingMode } + onNavigateMessages={handleNavigateMessages} onBeforeSubmit={handleBeforeSubmit} onSubmit={handleSubmit} onBashCommand={onBashCommand} diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 6e82d78d3d..4d912ca662 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -41,6 +41,10 @@ import { } from "@posthog/ui/features/sessions/components/chat-thread/ChatMarkdown"; import { ChatThreadFooter } from "@posthog/ui/features/sessions/components/chat-thread/ChatThreadFooter"; import { ChatThreadChromeProvider } from "@posthog/ui/features/sessions/components/chat-thread/chatThreadChrome"; +import { + type ComposerMessageNavigationHandler, + composerMessageNavigation, +} from "@posthog/ui/features/sessions/components/chat-thread/composerMessageNavigation"; import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker"; import { ToolGroup, @@ -84,6 +88,7 @@ import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { memo, type ReactNode, + type RefObject, useCallback, useEffect, useLayoutEffect, @@ -92,7 +97,6 @@ import { useState, } from "react"; import { useHotkeys } from "react-hotkeys-hook"; - import type { ConversationViewProps } from "../ConversationView"; /** A row is either a parsed conversation item or a synthesized group of tool calls. */ @@ -816,14 +820,16 @@ function ThreadKeyboardNav({ setJumpPickerOpen, keyboardFocusedMessageId, setKeyboardFocusedMessageId, + composerNavigationRef, }: { items: ConversationItem[]; jumpPickerOpen: boolean; setJumpPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; keyboardFocusedMessageId: string | null; setKeyboardFocusedMessageId: (id: string | null) => void; + composerNavigationRef?: RefObject; }) { - const { scrollToMessage } = useChatMessageScroller(); + const { scrollToMessage, scrollToEnd } = useChatMessageScroller(); const userMessageIds = useMemo( () => @@ -886,6 +892,40 @@ function ThreadKeyboardNav({ THREAD_HOTKEY_OPTIONS, ); + const navigateFromComposer = useCallback( + (direction) => { + const action = composerMessageNavigation( + userMessageIds, + keyboardFocusedMessageId, + direction, + ); + if (!action) return false; + if (action.kind === "exitToBottom") { + setKeyboardFocusedMessageId(null); + scrollToEnd({ behavior: "smooth" }); + return true; + } + setKeyboardFocusedMessageId(action.id); + scrollToMessage(action.id); + return true; + }, + [ + userMessageIds, + keyboardFocusedMessageId, + setKeyboardFocusedMessageId, + scrollToMessage, + scrollToEnd, + ], + ); + + useEffect(() => { + if (!composerNavigationRef) return; + composerNavigationRef.current = navigateFromComposer; + return () => { + composerNavigationRef.current = null; + }; + }, [composerNavigationRef, navigateFromComposer]); + const handleJumpToMessage = useCallback( (id: string) => { setKeyboardFocusedMessageId(id); @@ -987,6 +1027,7 @@ export function ChatThread({ repoPath, task, taskId, + composerNavigationRef, }: ConversationViewProps) { const diffWorkerFactory = useService(DIFF_WORKER_FACTORY); const diffsPoolOptions = useMemo( @@ -1129,6 +1170,7 @@ export function ChatThread({ setJumpPickerOpen={setJumpPickerOpen} keyboardFocusedMessageId={keyboardFocusedMessageId} setKeyboardFocusedMessageId={setKeyboardFocusedMessageId} + composerNavigationRef={composerNavigationRef} /> diff --git a/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.test.ts b/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.test.ts new file mode 100644 index 0000000000..0f88553db4 --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + type ComposerNavigationAction, + type ComposerNavigationDirection, + composerMessageNavigation, +} from "./composerMessageNavigation"; + +const ids = ["m1", "m2", "m3"]; + +describe("composerMessageNavigation", () => { + it.each<{ + name: string; + focusedId: string | null; + direction: ComposerNavigationDirection; + expected: ComposerNavigationAction | null; + }>([ + { + name: "up with no focus jumps to the newest user message", + focusedId: null, + direction: -1, + expected: { kind: "focus", id: "m3" }, + }, + { + name: "up from the middle focuses the previous message", + focusedId: "m2", + direction: -1, + expected: { kind: "focus", id: "m1" }, + }, + { + name: "up at the oldest message stays on it", + focusedId: "m1", + direction: -1, + expected: { kind: "focus", id: "m1" }, + }, + { + name: "up with an unknown focus id falls back to the newest message", + focusedId: "gone", + direction: -1, + expected: { kind: "focus", id: "m3" }, + }, + { + name: "down with no focus does nothing", + focusedId: null, + direction: 1, + expected: null, + }, + { + name: "down from the middle focuses the next message", + focusedId: "m2", + direction: 1, + expected: { kind: "focus", id: "m3" }, + }, + { + name: "down at the newest message exits back to the bottom", + focusedId: "m3", + direction: 1, + expected: { kind: "exitToBottom" }, + }, + { + name: "down with an unknown focus id does nothing", + focusedId: "gone", + direction: 1, + expected: null, + }, + ])("$name", ({ focusedId, direction, expected }) => { + expect(composerMessageNavigation(ids, focusedId, direction)).toEqual( + expected, + ); + }); + + it.each<{ direction: ComposerNavigationDirection }>([ + { direction: -1 }, + { direction: 1 }, + ])( + "returns null when there are no user messages (direction $direction)", + ({ direction }) => { + expect(composerMessageNavigation([], null, direction)).toBeNull(); + }, + ); +}); diff --git a/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.ts b/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.ts new file mode 100644 index 0000000000..fe92e2f842 --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.ts @@ -0,0 +1,37 @@ +export type ComposerNavigationDirection = -1 | 1; + +export type ComposerNavigationAction = + | { kind: "focus"; id: string } + | { kind: "exitToBottom" }; + +export type ComposerMessageNavigationHandler = ( + direction: ComposerNavigationDirection, +) => boolean; + +export function composerMessageNavigation( + userMessageIds: string[], + focusedId: string | null, + direction: ComposerNavigationDirection, +): ComposerNavigationAction | null { + if (userMessageIds.length === 0) return null; + + const currentIndex = focusedId ? userMessageIds.indexOf(focusedId) : -1; + + if (direction === -1) { + const previousIndex = + currentIndex === -1 + ? userMessageIds.length - 1 + : Math.max(0, currentIndex - 1); + const id = userMessageIds[previousIndex]; + return id ? { kind: "focus", id } : null; + } + + // Down only means "toward newer" while already navigating; otherwise the + // caret is just resting at the end of the input and the key stays inert. + if (currentIndex === -1) return null; + if (currentIndex >= userMessageIds.length - 1) { + return { kind: "exitToBottom" }; + } + const id = userMessageIds[currentIndex + 1]; + return id ? { kind: "focus", id } : null; +} From e627dd2dcdbdd4722625637c1f477880e166af2c Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 12:21:29 -0700 Subject: [PATCH 02/13] scope suggestion popup check to its editor --- .../message-editor/tiptap/CommandMention.ts | 1 + .../message-editor/tiptap/FileMention.ts | 1 + .../message-editor/tiptap/IssueMention.tsx | 1 + .../tiptap/createSuggestionMention.ts | 4 ++++ .../message-editor/tiptap/useTiptapEditor.ts | 21 +++++++++++-------- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/features/message-editor/tiptap/CommandMention.ts b/packages/ui/src/features/message-editor/tiptap/CommandMention.ts index 9f6a67cc39..2054db79fc 100644 --- a/packages/ui/src/features/message-editor/tiptap/CommandMention.ts +++ b/packages/ui/src/features/message-editor/tiptap/CommandMention.ts @@ -11,6 +11,7 @@ export function createCommandMention(options: CommandMentionOptions) { return createSuggestionMention({ name: "commandMention", + sessionId, char: "/", chipType: "command", items: (query) => diff --git a/packages/ui/src/features/message-editor/tiptap/FileMention.ts b/packages/ui/src/features/message-editor/tiptap/FileMention.ts index a20306aac0..0423e60684 100644 --- a/packages/ui/src/features/message-editor/tiptap/FileMention.ts +++ b/packages/ui/src/features/message-editor/tiptap/FileMention.ts @@ -4,6 +4,7 @@ import { createSuggestionMention } from "./createSuggestionMention"; export function createFileMention(sessionId: string) { return createSuggestionMention({ name: "fileMention", + sessionId, char: "@", chipType: "file", items: (query) => (sessionId ? getFileSuggestions(sessionId, query) : []), diff --git a/packages/ui/src/features/message-editor/tiptap/IssueMention.tsx b/packages/ui/src/features/message-editor/tiptap/IssueMention.tsx index 51b3d04efe..2e57c0f6ed 100644 --- a/packages/ui/src/features/message-editor/tiptap/IssueMention.tsx +++ b/packages/ui/src/features/message-editor/tiptap/IssueMention.tsx @@ -5,6 +5,7 @@ import { createSuggestionMention } from "./createSuggestionMention"; export function createIssueMention(sessionId: string) { return createSuggestionMention({ name: "issueMention", + sessionId, char: "#", chipType: "github_issue", debounceMs: 250, diff --git a/packages/ui/src/features/message-editor/tiptap/createSuggestionMention.ts b/packages/ui/src/features/message-editor/tiptap/createSuggestionMention.ts index 430997832d..d1db4b7a5a 100644 --- a/packages/ui/src/features/message-editor/tiptap/createSuggestionMention.ts +++ b/packages/ui/src/features/message-editor/tiptap/createSuggestionMention.ts @@ -12,6 +12,8 @@ import { SuggestionList, type SuggestionListRef } from "./SuggestionList"; export interface SuggestionMentionConfig { name: string; + /** Tags the popup so keydown checks can tell this editor's popup apart. */ + sessionId: string; char: string; chipType: ChipType; startOfLine?: boolean; @@ -35,6 +37,7 @@ export function createSuggestionMention( ) { const { name, + sessionId, char, chipType, startOfLine = false, @@ -97,6 +100,7 @@ export function createSuggestionMention( }, editor: props.editor, }); + renderer.element.setAttribute("data-suggestion-session", sessionId); if (!props.clientRect) return; diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 96a9781dc4..56f08e3190 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -192,6 +192,16 @@ async function resolveGithubRefChip( ); } +function hasVisibleSuggestionPopup(sessionId: string): boolean { + // tippy.js sets data-state="hidden" when hiding via .hide(); the session + // tag keeps another mounted composer's popup from matching. + return ( + document.querySelector( + `[data-tippy-root] .tippy-box:not([data-state='hidden']) [data-suggestion-session="${CSS.escape(sessionId)}"]`, + ) !== null + ); +} + function showPasteHint(message: string, description: string): void { const store = useFeatureSettingsStore.getState(); const key = @@ -329,11 +339,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { if (isSendMessageSubmitKey(event)) { if (!view.editable || submitDisabledRef.current) return false; - // tippy.js sets data-state="hidden" when hiding via .hide() - const visibleSuggestion = document.querySelector( - "[data-tippy-root] .tippy-box:not([data-state='hidden'])", - ); - if (visibleSuggestion) return false; + if (hasVisibleSuggestionPopup(sessionId)) return false; event.preventDefault(); historyActions.reset(); submitRef.current(); @@ -398,10 +404,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const navigateMessages = onNavigateMessagesRef.current; if (navigateMessages) { - const visibleSuggestion = document.querySelector( - "[data-tippy-root] .tippy-box:not([data-state='hidden'])", - ); - if (visibleSuggestion) return false; + if (hasVisibleSuggestionPopup(sessionId)) return false; const { selection, doc } = view.state; // Arrows move the caret as usual; only a press that can't From 3ef58351b9e3c71ba42cb7f79323219838060bd9 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 12:21:52 -0700 Subject: [PATCH 03/13] read nav state through refs at keypress time --- .../sessions/components/ConversationView.tsx | 24 ++++++++++++------- .../components/chat-thread/ChatThread.tsx | 22 ++++++++++------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index 6b3f82b173..61aeeb79c4 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -258,6 +258,14 @@ export function ConversationView({ return result; }, [items]); + // Read by the composer navigation handler at keypress time, so it never + // acts on a stale snapshot (the ref registration effect runs after paint, + // and key repeats can outpace re-renders). + const userMessagesRef = useRef(userMessages); + userMessagesRef.current = userMessages; + const keyboardFocusedMessageIdRef = useRef(keyboardFocusedMessageId); + keyboardFocusedMessageIdRef.current = keyboardFocusedMessageId; + // Grouped rows != items, so scroll by the row the message landed in (same // mapping search uses), falling back to the raw item index. const scrollToUserMessage = useCallback((id: string, itemIndex: number) => { @@ -338,29 +346,27 @@ export function ConversationView({ const navigateFromComposer = useCallback( (direction) => { + const messages = userMessagesRef.current; const action = composerMessageNavigation( - userMessages.map((message) => message.id), - keyboardFocusedMessageId, + messages.map((message) => message.id), + keyboardFocusedMessageIdRef.current, direction, ); if (!action) return false; if (action.kind === "exitToBottom") { + keyboardFocusedMessageIdRef.current = null; setKeyboardFocusedMessageId(null); scrollToBottom(); return true; } - const message = userMessages.find((entry) => entry.id === action.id); + const message = messages.find((entry) => entry.id === action.id); if (!message) return false; + keyboardFocusedMessageIdRef.current = action.id; setKeyboardFocusedMessageId(action.id); scrollToUserMessage(action.id, message.index); return true; }, - [ - userMessages, - keyboardFocusedMessageId, - scrollToUserMessage, - scrollToBottom, - ], + [scrollToUserMessage, scrollToBottom], ); useEffect(() => { diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 4d912ca662..05f0819235 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -892,30 +892,34 @@ function ThreadKeyboardNav({ THREAD_HOTKEY_OPTIONS, ); + // Read at keypress time so the handler registered in the ref effect never + // acts on a stale snapshot (the effect runs after paint, and key repeats + // can outpace re-renders). + const userMessageIdsRef = useRef(userMessageIds); + userMessageIdsRef.current = userMessageIds; + const keyboardFocusedMessageIdRef = useRef(keyboardFocusedMessageId); + keyboardFocusedMessageIdRef.current = keyboardFocusedMessageId; + const navigateFromComposer = useCallback( (direction) => { const action = composerMessageNavigation( - userMessageIds, - keyboardFocusedMessageId, + userMessageIdsRef.current, + keyboardFocusedMessageIdRef.current, direction, ); if (!action) return false; if (action.kind === "exitToBottom") { + keyboardFocusedMessageIdRef.current = null; setKeyboardFocusedMessageId(null); scrollToEnd({ behavior: "smooth" }); return true; } + keyboardFocusedMessageIdRef.current = action.id; setKeyboardFocusedMessageId(action.id); scrollToMessage(action.id); return true; }, - [ - userMessageIds, - keyboardFocusedMessageId, - setKeyboardFocusedMessageId, - scrollToMessage, - scrollToEnd, - ], + [setKeyboardFocusedMessageId, scrollToMessage, scrollToEnd], ); useEffect(() => { From fb8f0d32aaee0c87e0fba503e800379aabffd337 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 12:31:06 -0700 Subject: [PATCH 04/13] recall sent prompts with arrow keys --- .../message-editor/components/PromptInput.tsx | 7 +-- .../message-editor/tiptap/useTiptapEditor.ts | 50 ++++++++++++++++--- .../sessions/components/ConversationView.tsx | 22 +++++--- .../sessions/components/SessionView.tsx | 4 +- .../components/chat-thread/ChatThread.tsx | 33 +++++++++--- .../composerMessageNavigation.test.ts | 14 +++--- .../chat-thread/composerMessageNavigation.ts | 12 +++-- 7 files changed, 105 insertions(+), 37 deletions(-) diff --git a/packages/ui/src/features/message-editor/components/PromptInput.tsx b/packages/ui/src/features/message-editor/components/PromptInput.tsx index 403160849c..74e2ae5f24 100644 --- a/packages/ui/src/features/message-editor/components/PromptInput.tsx +++ b/packages/ui/src/features/message-editor/components/PromptInput.tsx @@ -3,6 +3,7 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import { ArrowUp, Stop } from "@phosphor-icons/react"; import { InputGroup, InputGroupAddon, InputGroupButton } from "@posthog/quill"; import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import type { ComposerMessageNavigationHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerMessageNavigation"; import { cycleModeOption } from "@posthog/ui/features/sessions/sessionStore"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { hasOpenOverlay } from "@posthog/ui/utils/overlay"; @@ -76,9 +77,9 @@ export interface PromptInputProps { hideDefaultToolbar?: boolean; // prompt history provider getPromptHistory?: () => string[]; - // plain Up/Down at the caret boundary; return true when the conversation - // view consumed the keypress - onNavigateMessages?: (direction: -1 | 1) => boolean; + // plain Up/Down at the caret boundary; recalls sent prompts and keeps the + // conversation view in sync + onNavigateMessages?: ComposerMessageNavigationHandler; // callbacks onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean; onSubmit?: (text: string) => void; diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 56f08e3190..73fe85a211 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -20,10 +20,13 @@ import { isUrlOnly, shouldAutoConvertLongText, } from "@posthog/core/message-editor/paste"; +import type { ComposerMessageNavigationHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerMessageNavigation"; import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore"; import { useSettingsStore as useFeatureSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { toast } from "@posthog/ui/primitives/toast"; import { isSendMessageSubmitKey } from "@posthog/ui/utils/sendMessageKey"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { TextSelection } from "@tiptap/pm/state"; import type { EditorView } from "@tiptap/pm/view"; import { useEditor } from "@tiptap/react"; import type React from "react"; @@ -61,7 +64,7 @@ export interface UseTiptapEditorOptions { }; clearOnSubmit?: boolean; getPromptHistory?: () => string[]; - onNavigateMessages?: (direction: -1 | 1) => boolean; + onNavigateMessages?: ComposerMessageNavigationHandler; onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean; onSubmit?: (text: string) => void; onBashCommand?: (command: string) => void; @@ -271,6 +274,11 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const onNavigateMessagesRef = useRef(onNavigateMessages); onNavigateMessagesRef.current = onNavigateMessages; + // Doc snapshot taken when arrow-key recall first replaces the input, so + // arrowing back down past the newest prompt restores what was being typed + // (kept as a ProseMirror node to preserve mention chips). + const messageNavDraftRef = useRef(null); + const prevBashModeRef = useRef(false); const prevIsEmptyRef = useRef(true); const submitRef = useRef<() => void>(() => {}); @@ -409,19 +417,45 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const { selection, doc } = view.state; // Arrows move the caret as usual; only a press that can't // travel further (caret already at the first or last position) - // hands off to conversation message navigation. + // hands off to sent-prompt recall. const atBoundary = selection.empty && (event.key === "ArrowUp" ? selection.from <= 1 : selection.to >= doc.content.size - 1); - if ( - atBoundary && - navigateMessages(event.key === "ArrowUp" ? -1 : 1) - ) { - event.preventDefault(); + if (!atBoundary) return false; + + const result = navigateMessages(event.key === "ArrowUp" ? -1 : 1); + if (!result) return false; + event.preventDefault(); + + if (result.kind === "recall") { + if (result.fresh) { + messageNavDraftRef.current = view.state.doc; + } + const tr = view.state.tr + .delete(1, view.state.doc.content.size - 1) + .insertText(result.text, 1); + // Recalling up parks the caret at the start so the next Up + // press keeps cycling; recalling down parks it at the end. + if (event.key === "ArrowUp") { + tr.setSelection(TextSelection.create(tr.doc, 1)); + } + view.dispatch(tr); return true; } + + const draft = messageNavDraftRef.current; + messageNavDraftRef.current = null; + const tr = view.state.tr; + if (draft) { + tr.replaceWith(0, view.state.doc.content.size, draft.content); + tr.setSelection(TextSelection.atEnd(tr.doc)); + } else { + tr.delete(1, view.state.doc.content.size - 1); + } + view.dispatch(tr); + return true; } } @@ -710,6 +744,8 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const text = editor.getText().trim(); + messageNavDraftRef.current = null; + const doClear = () => { if (!clearOnSubmit) return; editor.commands.clearContent(); diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index 61aeeb79c4..b6f2aa6af4 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -248,11 +248,11 @@ export function ConversationView({ >(null); const userMessages = useMemo(() => { - const result: Array<{ id: string; index: number }> = []; + const result: Array<{ id: string; index: number; content: string }> = []; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type === "user_message") { - result.push({ id: item.id, index: i }); + result.push({ id: item.id, index: i, content: item.content }); } } return result; @@ -266,6 +266,16 @@ export function ConversationView({ const keyboardFocusedMessageIdRef = useRef(keyboardFocusedMessageId); keyboardFocusedMessageIdRef.current = keyboardFocusedMessageId; + // A newly sent prompt resets recall, so the next Up starts from it. + const userMessageCountRef = useRef(userMessages.length); + useEffect(() => { + if (userMessages.length > userMessageCountRef.current) { + keyboardFocusedMessageIdRef.current = null; + setKeyboardFocusedMessageId(null); + } + userMessageCountRef.current = userMessages.length; + }, [userMessages.length]); + // Grouped rows != items, so scroll by the row the message landed in (same // mapping search uses), falling back to the raw item index. const scrollToUserMessage = useCallback((id: string, itemIndex: number) => { @@ -352,19 +362,19 @@ export function ConversationView({ keyboardFocusedMessageIdRef.current, direction, ); - if (!action) return false; + if (!action) return null; if (action.kind === "exitToBottom") { keyboardFocusedMessageIdRef.current = null; setKeyboardFocusedMessageId(null); scrollToBottom(); - return true; + return { kind: "exitToBottom" }; } const message = messages.find((entry) => entry.id === action.id); - if (!message) return false; + if (!message) return null; keyboardFocusedMessageIdRef.current = action.id; setKeyboardFocusedMessageId(action.id); scrollToUserMessage(action.id, message.index); - return true; + return { kind: "recall", text: message.content, fresh: action.fresh }; }, [scrollToUserMessage, scrollToBottom], ); diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index 7cb5106d82..fa9c6d4266 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -327,8 +327,8 @@ export function SessionView({ const composerNavigationRef = useRef( null, ); - const handleNavigateMessages = useCallback( - (direction: -1 | 1) => composerNavigationRef.current?.(direction) ?? false, + const handleNavigateMessages = useCallback( + (direction) => composerNavigationRef.current?.(direction) ?? null, [], ); const dragCounterRef = useRef(0); diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 05f0819235..34a113f473 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -831,16 +831,20 @@ function ThreadKeyboardNav({ }) { const { scrollToMessage, scrollToEnd } = useChatMessageScroller(); - const userMessageIds = useMemo( + const userMessages = useMemo( () => items .filter( (item): item is Extract => item.type === "user_message", ) - .map((item) => item.id), + .map((item) => ({ id: item.id, content: item.content })), [items], ); + const userMessageIds = useMemo( + () => userMessages.map((message) => message.id), + [userMessages], + ); useHotkeys( SHORTCUTS.MESSAGE_JUMP, @@ -895,29 +899,42 @@ function ThreadKeyboardNav({ // Read at keypress time so the handler registered in the ref effect never // acts on a stale snapshot (the effect runs after paint, and key repeats // can outpace re-renders). - const userMessageIdsRef = useRef(userMessageIds); - userMessageIdsRef.current = userMessageIds; + const userMessagesRef = useRef(userMessages); + userMessagesRef.current = userMessages; const keyboardFocusedMessageIdRef = useRef(keyboardFocusedMessageId); keyboardFocusedMessageIdRef.current = keyboardFocusedMessageId; + // A newly sent prompt resets recall, so the next Up starts from it. + const userMessageCountRef = useRef(userMessages.length); + useEffect(() => { + if (userMessages.length > userMessageCountRef.current) { + keyboardFocusedMessageIdRef.current = null; + setKeyboardFocusedMessageId(null); + } + userMessageCountRef.current = userMessages.length; + }, [userMessages.length, setKeyboardFocusedMessageId]); + const navigateFromComposer = useCallback( (direction) => { + const messages = userMessagesRef.current; const action = composerMessageNavigation( - userMessageIdsRef.current, + messages.map((message) => message.id), keyboardFocusedMessageIdRef.current, direction, ); - if (!action) return false; + if (!action) return null; if (action.kind === "exitToBottom") { keyboardFocusedMessageIdRef.current = null; setKeyboardFocusedMessageId(null); scrollToEnd({ behavior: "smooth" }); - return true; + return { kind: "exitToBottom" }; } + const message = messages.find((entry) => entry.id === action.id); + if (!message) return null; keyboardFocusedMessageIdRef.current = action.id; setKeyboardFocusedMessageId(action.id); scrollToMessage(action.id); - return true; + return { kind: "recall", text: message.content, fresh: action.fresh }; }, [setKeyboardFocusedMessageId, scrollToMessage, scrollToEnd], ); diff --git a/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.test.ts b/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.test.ts index 0f88553db4..855e8b5dfc 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.test.ts +++ b/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.test.ts @@ -15,28 +15,28 @@ describe("composerMessageNavigation", () => { expected: ComposerNavigationAction | null; }>([ { - name: "up with no focus jumps to the newest user message", + name: "up with no focus starts fresh on the newest user message", focusedId: null, direction: -1, - expected: { kind: "focus", id: "m3" }, + expected: { kind: "focus", id: "m3", fresh: true }, }, { name: "up from the middle focuses the previous message", focusedId: "m2", direction: -1, - expected: { kind: "focus", id: "m1" }, + expected: { kind: "focus", id: "m1", fresh: false }, }, { name: "up at the oldest message stays on it", focusedId: "m1", direction: -1, - expected: { kind: "focus", id: "m1" }, + expected: { kind: "focus", id: "m1", fresh: false }, }, { - name: "up with an unknown focus id falls back to the newest message", + name: "up with an unknown focus id starts fresh on the newest message", focusedId: "gone", direction: -1, - expected: { kind: "focus", id: "m3" }, + expected: { kind: "focus", id: "m3", fresh: true }, }, { name: "down with no focus does nothing", @@ -48,7 +48,7 @@ describe("composerMessageNavigation", () => { name: "down from the middle focuses the next message", focusedId: "m2", direction: 1, - expected: { kind: "focus", id: "m3" }, + expected: { kind: "focus", id: "m3", fresh: false }, }, { name: "down at the newest message exits back to the bottom", diff --git a/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.ts b/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.ts index fe92e2f842..442912707c 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.ts +++ b/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.ts @@ -1,12 +1,16 @@ export type ComposerNavigationDirection = -1 | 1; export type ComposerNavigationAction = - | { kind: "focus"; id: string } + | { kind: "focus"; id: string; fresh: boolean } + | { kind: "exitToBottom" }; + +export type ComposerNavigationResult = + | { kind: "recall"; text: string; fresh: boolean } | { kind: "exitToBottom" }; export type ComposerMessageNavigationHandler = ( direction: ComposerNavigationDirection, -) => boolean; +) => ComposerNavigationResult | null; export function composerMessageNavigation( userMessageIds: string[], @@ -23,7 +27,7 @@ export function composerMessageNavigation( ? userMessageIds.length - 1 : Math.max(0, currentIndex - 1); const id = userMessageIds[previousIndex]; - return id ? { kind: "focus", id } : null; + return id ? { kind: "focus", id, fresh: currentIndex === -1 } : null; } // Down only means "toward newer" while already navigating; otherwise the @@ -33,5 +37,5 @@ export function composerMessageNavigation( return { kind: "exitToBottom" }; } const id = userMessageIds[currentIndex + 1]; - return id ? { kind: "focus", id } : null; + return id ? { kind: "focus", id, fresh: false } : null; } From db4141af6d53056b492a0bd5e1ee1806df7d01d5 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 15:49:46 -0700 Subject: [PATCH 05/13] reset recall focus during render, not effect --- .../sessions/components/ConversationView.tsx | 14 ++++++---- .../components/chat-thread/ChatThread.tsx | 27 ++++++++++++------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index b6f2aa6af4..0c8353e2d9 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -267,14 +267,18 @@ export function ConversationView({ keyboardFocusedMessageIdRef.current = keyboardFocusedMessageId; // A newly sent prompt resets recall, so the next Up starts from it. - const userMessageCountRef = useRef(userMessages.length); - useEffect(() => { - if (userMessages.length > userMessageCountRef.current) { + // Adjusted during render, not in an effect, so a keypress landing right + // after the commit can't see the stale focus. + const [prevUserMessageCount, setPrevUserMessageCount] = useState( + userMessages.length, + ); + if (userMessages.length !== prevUserMessageCount) { + setPrevUserMessageCount(userMessages.length); + if (userMessages.length > prevUserMessageCount) { keyboardFocusedMessageIdRef.current = null; setKeyboardFocusedMessageId(null); } - userMessageCountRef.current = userMessages.length; - }, [userMessages.length]); + } // Grouped rows != items, so scroll by the row the message landed in (same // mapping search uses), falling back to the raw item index. diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 34a113f473..941a856d31 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -904,16 +904,6 @@ function ThreadKeyboardNav({ const keyboardFocusedMessageIdRef = useRef(keyboardFocusedMessageId); keyboardFocusedMessageIdRef.current = keyboardFocusedMessageId; - // A newly sent prompt resets recall, so the next Up starts from it. - const userMessageCountRef = useRef(userMessages.length); - useEffect(() => { - if (userMessages.length > userMessageCountRef.current) { - keyboardFocusedMessageIdRef.current = null; - setKeyboardFocusedMessageId(null); - } - userMessageCountRef.current = userMessages.length; - }, [userMessages.length, setKeyboardFocusedMessageId]); - const navigateFromComposer = useCallback( (direction) => { const messages = userMessagesRef.current; @@ -1089,6 +1079,23 @@ export function ChatThread({ setKeyboardFocusedMessageId(null); }, []); + // A newly sent prompt resets recall, so the next Up starts from it. + // Adjusted during render, not in an effect, so a keypress landing right + // after the commit can't see the stale focus. + const userMessageCount = useMemo( + () => + items.reduce((n, item) => (item.type === "user_message" ? n + 1 : n), 0), + [items], + ); + const [prevUserMessageCount, setPrevUserMessageCount] = + useState(userMessageCount); + if (userMessageCount !== prevUserMessageCount) { + setPrevUserMessageCount(userMessageCount); + if (userMessageCount > prevUserMessageCount) { + setKeyboardFocusedMessageId(null); + } + } + const renderItem = useCallback( (item: ConversationItem) => { switch (item.type) { From 7f10b027434927442a9abe4940c713fbb6ebde5b Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 16:29:29 -0700 Subject: [PATCH 06/13] decouple prompt recall from view navigation --- .../features/command/keyboard-shortcuts.ts | 6 +- .../message-editor/components/PromptInput.tsx | 11 +-- .../message-editor/tiptap/useTiptapEditor.ts | 26 ++--- .../sessions/components/ConversationView.tsx | 92 ++++++++---------- .../sessions/components/SessionView.tsx | 14 ++- .../components/chat-thread/ChatThread.tsx | 95 ++++++++----------- .../composerMessageNavigation.test.ts | 80 ---------------- .../chat-thread/composerMessageNavigation.ts | 41 -------- .../chat-thread/composerPromptRecall.test.ts | 78 +++++++++++++++ .../chat-thread/composerPromptRecall.ts | 41 ++++++++ 10 files changed, 223 insertions(+), 261 deletions(-) delete mode 100644 packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.test.ts delete mode 100644 packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.ts create mode 100644 packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.test.ts create mode 100644 packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.ts diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index 243af0a1c0..069e4981f9 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -212,7 +212,6 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Previous message", category: "panels", context: "Task detail", - alternateKeys: "up", }, { id: "message-next", @@ -220,7 +219,6 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Next message", category: "panels", context: "Task detail", - alternateKeys: "down", }, { id: "message-jump", @@ -239,14 +237,14 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ { id: "prompt-history-prev", keys: "up", - description: "Previous prompt (when input is empty)", + description: "Recall previous prompt", category: "editor", context: "Message editor", }, { id: "prompt-history-next", keys: "down", - description: "Next prompt (when input is empty)", + description: "Recall next prompt", category: "editor", context: "Message editor", }, diff --git a/packages/ui/src/features/message-editor/components/PromptInput.tsx b/packages/ui/src/features/message-editor/components/PromptInput.tsx index 74e2ae5f24..c720c58df9 100644 --- a/packages/ui/src/features/message-editor/components/PromptInput.tsx +++ b/packages/ui/src/features/message-editor/components/PromptInput.tsx @@ -3,7 +3,7 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import { ArrowUp, Stop } from "@phosphor-icons/react"; import { InputGroup, InputGroupAddon, InputGroupButton } from "@posthog/quill"; import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; -import type { ComposerMessageNavigationHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerMessageNavigation"; +import type { PromptRecallHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { cycleModeOption } from "@posthog/ui/features/sessions/sessionStore"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { hasOpenOverlay } from "@posthog/ui/utils/overlay"; @@ -77,9 +77,8 @@ export interface PromptInputProps { hideDefaultToolbar?: boolean; // prompt history provider getPromptHistory?: () => string[]; - // plain Up/Down at the caret boundary; recalls sent prompts and keeps the - // conversation view in sync - onNavigateMessages?: ComposerMessageNavigationHandler; + // plain Up/Down at the caret boundary recalls sent prompts into the input + onPromptRecall?: PromptRecallHandler; // callbacks onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean; onSubmit?: (text: string) => void; @@ -125,7 +124,7 @@ export const PromptInput = forwardRef( headerAddon, hideDefaultToolbar = false, getPromptHistory, - onNavigateMessages, + onPromptRecall, onBeforeSubmit, onSubmit, onBashCommand, @@ -182,7 +181,7 @@ export const PromptInput = forwardRef( commands: enableCommands, }, getPromptHistory, - onNavigateMessages, + onPromptRecall, onBeforeSubmit, onSubmit, onBashCommand, diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 73fe85a211..a3e6bb3810 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -20,7 +20,7 @@ import { isUrlOnly, shouldAutoConvertLongText, } from "@posthog/core/message-editor/paste"; -import type { ComposerMessageNavigationHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerMessageNavigation"; +import type { PromptRecallHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore"; import { useSettingsStore as useFeatureSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { toast } from "@posthog/ui/primitives/toast"; @@ -64,7 +64,7 @@ export interface UseTiptapEditorOptions { }; clearOnSubmit?: boolean; getPromptHistory?: () => string[]; - onNavigateMessages?: ComposerMessageNavigationHandler; + onPromptRecall?: PromptRecallHandler; onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean; onSubmit?: (text: string) => void; onBashCommand?: (command: string) => void; @@ -230,7 +230,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { capabilities = {}, clearOnSubmit = true, getPromptHistory, - onNavigateMessages, + onPromptRecall, onBeforeSubmit, onSubmit, onBashCommand, @@ -271,13 +271,13 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const getPromptHistoryRef = useRef(getPromptHistory); getPromptHistoryRef.current = getPromptHistory; - const onNavigateMessagesRef = useRef(onNavigateMessages); - onNavigateMessagesRef.current = onNavigateMessages; + const onPromptRecallRef = useRef(onPromptRecall); + onPromptRecallRef.current = onPromptRecall; // Doc snapshot taken when arrow-key recall first replaces the input, so // arrowing back down past the newest prompt restores what was being typed // (kept as a ProseMirror node to preserve mention chips). - const messageNavDraftRef = useRef(null); + const promptRecallDraftRef = useRef(null); const prevBashModeRef = useRef(false); const prevIsEmptyRef = useRef(true); @@ -410,8 +410,8 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { } } - const navigateMessages = onNavigateMessagesRef.current; - if (navigateMessages) { + const recallPrompt = onPromptRecallRef.current; + if (recallPrompt) { if (hasVisibleSuggestionPopup(sessionId)) return false; const { selection, doc } = view.state; @@ -425,13 +425,13 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { : selection.to >= doc.content.size - 1); if (!atBoundary) return false; - const result = navigateMessages(event.key === "ArrowUp" ? -1 : 1); + const result = recallPrompt(event.key === "ArrowUp" ? -1 : 1); if (!result) return false; event.preventDefault(); if (result.kind === "recall") { if (result.fresh) { - messageNavDraftRef.current = view.state.doc; + promptRecallDraftRef.current = view.state.doc; } const tr = view.state.tr .delete(1, view.state.doc.content.size - 1) @@ -445,8 +445,8 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { return true; } - const draft = messageNavDraftRef.current; - messageNavDraftRef.current = null; + const draft = promptRecallDraftRef.current; + promptRecallDraftRef.current = null; const tr = view.state.tr; if (draft) { tr.replaceWith(0, view.state.doc.content.size, draft.content); @@ -744,7 +744,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const text = editor.getText().trim(); - messageNavDraftRef.current = null; + promptRecallDraftRef.current = null; const doClear = () => { if (!clearOnSubmit) return; diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index 0c8353e2d9..0e414bc49a 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -17,9 +17,9 @@ import type { } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ConversationSearchBar"; import { - type ComposerMessageNavigationHandler, - composerMessageNavigation, -} from "@posthog/ui/features/sessions/components/chat-thread/composerMessageNavigation"; + type PromptRecallHandler, + promptRecallStep, +} from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker"; import { THREAD_HOTKEY_OPTIONS } from "@posthog/ui/features/sessions/components/chat-thread/threadHotkeys"; import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; @@ -99,10 +99,10 @@ export interface ConversationViewProps { */ scrollX?: boolean; /** - * Filled with the view's message navigation handler so the composer can - * forward plain Up/Down presses (caret at the input boundary) to it. + * Filled with the view's prompt recall handler so the composer can forward + * plain Up/Down presses (caret at the input boundary) to it. */ - composerNavigationRef?: RefObject; + promptRecallRef?: RefObject; } export function ConversationView({ @@ -116,7 +116,7 @@ export function ConversationView({ compact = false, collapseMode: collapseModeProp, scrollX = true, - composerNavigationRef, + promptRecallRef, }: ConversationViewProps) { const diffWorkerFactory = useService(DIFF_WORKER_FACTORY); const diffsPoolOptions = useMemo( @@ -258,27 +258,20 @@ export function ConversationView({ return result; }, [items]); - // Read by the composer navigation handler at keypress time, so it never - // acts on a stale snapshot (the ref registration effect runs after paint, - // and key repeats can outpace re-renders). + // Read by the recall handler at keypress time, so it never acts on a stale + // snapshot (the ref registration effect runs after paint, and key repeats + // can outpace re-renders). const userMessagesRef = useRef(userMessages); userMessagesRef.current = userMessages; - const keyboardFocusedMessageIdRef = useRef(keyboardFocusedMessageId); - keyboardFocusedMessageIdRef.current = keyboardFocusedMessageId; - - // A newly sent prompt resets recall, so the next Up starts from it. - // Adjusted during render, not in an effect, so a keypress landing right - // after the commit can't see the stale focus. - const [prevUserMessageCount, setPrevUserMessageCount] = useState( - userMessages.length, - ); - if (userMessages.length !== prevUserMessageCount) { - setPrevUserMessageCount(userMessages.length); - if (userMessages.length > prevUserMessageCount) { - keyboardFocusedMessageIdRef.current = null; - setKeyboardFocusedMessageId(null); - } + + // Recall position is invisible (no highlight, no scrolling), so a plain ref + // is enough. A newly sent prompt resets it so the next Up starts from it. + const recallMessageIdRef = useRef(null); + const prevUserMessageCountRef = useRef(userMessages.length); + if (userMessages.length > prevUserMessageCountRef.current) { + recallMessageIdRef.current = null; } + prevUserMessageCountRef.current = userMessages.length; // Grouped rows != items, so scroll by the row the message landed in (same // mapping search uses), falling back to the raw item index. @@ -358,38 +351,31 @@ export function ConversationView({ setShowScrollButton(false); }, []); - const navigateFromComposer = useCallback( - (direction) => { - const messages = userMessagesRef.current; - const action = composerMessageNavigation( - messages.map((message) => message.id), - keyboardFocusedMessageIdRef.current, - direction, - ); - if (!action) return null; - if (action.kind === "exitToBottom") { - keyboardFocusedMessageIdRef.current = null; - setKeyboardFocusedMessageId(null); - scrollToBottom(); - return { kind: "exitToBottom" }; - } - const message = messages.find((entry) => entry.id === action.id); - if (!message) return null; - keyboardFocusedMessageIdRef.current = action.id; - setKeyboardFocusedMessageId(action.id); - scrollToUserMessage(action.id, message.index); - return { kind: "recall", text: message.content, fresh: action.fresh }; - }, - [scrollToUserMessage, scrollToBottom], - ); + const recallFromComposer = useCallback((direction) => { + const messages = userMessagesRef.current; + const action = promptRecallStep( + messages.map((message) => message.id), + recallMessageIdRef.current, + direction, + ); + if (!action) return null; + if (action.kind === "exit") { + recallMessageIdRef.current = null; + return { kind: "exit" }; + } + const message = messages.find((entry) => entry.id === action.id); + if (!message) return null; + recallMessageIdRef.current = action.id; + return { kind: "recall", text: message.content, fresh: action.fresh }; + }, []); useEffect(() => { - if (!composerNavigationRef) return; - composerNavigationRef.current = navigateFromComposer; + if (!promptRecallRef) return; + promptRecallRef.current = recallFromComposer; return () => { - composerNavigationRef.current = null; + promptRecallRef.current = null; }; - }, [composerNavigationRef, navigateFromComposer]); + }, [promptRecallRef, recallFromComposer]); useEffect(() => { const handleVisibilityChange = () => { diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index fa9c6d4266..7b68e4b148 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -17,7 +17,7 @@ import { useAutoFocusOnTyping } from "@posthog/ui/features/message-editor/useAut import { resolveAndAttachDroppedFiles } from "@posthog/ui/features/message-editor/utils/persistFile"; import { PermissionSelector } from "@posthog/ui/features/permissions/PermissionSelector"; import { CloudInitializingView } from "@posthog/ui/features/sessions/components/CloudInitializingView"; -import type { ComposerMessageNavigationHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerMessageNavigation"; +import type { PromptRecallHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { copyFromContextMenu, getGithubRefUrlFromEventTarget, @@ -324,11 +324,9 @@ export function SessionView({ const [isDraggingFile, setIsDraggingFile] = useState(false); const editorRef = useRef(null); - const composerNavigationRef = useRef( - null, - ); - const handleNavigateMessages = useCallback( - (direction) => composerNavigationRef.current?.(direction) ?? null, + const promptRecallRef = useRef(null); + const handlePromptRecall = useCallback( + (direction) => promptRecallRef.current?.(direction) ?? null, [], ); const dragCounterRef = useRef(0); @@ -581,7 +579,7 @@ export function SessionView({ slackThreadUrl={slackThreadUrl} compact={compact} scrollX={false} - composerNavigationRef={composerNavigationRef} + promptRecallRef={promptRecallRef} /> {!useNewChatThread && } @@ -705,7 +703,7 @@ export function SessionView({ onToggleMessagingMode={ isCloudRun ? undefined : toggleMessagingMode } - onNavigateMessages={handleNavigateMessages} + onPromptRecall={handlePromptRecall} onBeforeSubmit={handleBeforeSubmit} onSubmit={handleSubmit} onBashCommand={onBashCommand} diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 941a856d31..4b5c542d9e 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -42,9 +42,9 @@ import { import { ChatThreadFooter } from "@posthog/ui/features/sessions/components/chat-thread/ChatThreadFooter"; import { ChatThreadChromeProvider } from "@posthog/ui/features/sessions/components/chat-thread/chatThreadChrome"; import { - type ComposerMessageNavigationHandler, - composerMessageNavigation, -} from "@posthog/ui/features/sessions/components/chat-thread/composerMessageNavigation"; + type PromptRecallHandler, + promptRecallStep, +} from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker"; import { ToolGroup, @@ -820,16 +820,16 @@ function ThreadKeyboardNav({ setJumpPickerOpen, keyboardFocusedMessageId, setKeyboardFocusedMessageId, - composerNavigationRef, + promptRecallRef, }: { items: ConversationItem[]; jumpPickerOpen: boolean; setJumpPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; keyboardFocusedMessageId: string | null; setKeyboardFocusedMessageId: (id: string | null) => void; - composerNavigationRef?: RefObject; + promptRecallRef?: RefObject; }) { - const { scrollToMessage, scrollToEnd } = useChatMessageScroller(); + const { scrollToMessage } = useChatMessageScroller(); const userMessages = useMemo( () => @@ -901,41 +901,41 @@ function ThreadKeyboardNav({ // can outpace re-renders). const userMessagesRef = useRef(userMessages); userMessagesRef.current = userMessages; - const keyboardFocusedMessageIdRef = useRef(keyboardFocusedMessageId); - keyboardFocusedMessageIdRef.current = keyboardFocusedMessageId; - - const navigateFromComposer = useCallback( - (direction) => { - const messages = userMessagesRef.current; - const action = composerMessageNavigation( - messages.map((message) => message.id), - keyboardFocusedMessageIdRef.current, - direction, - ); - if (!action) return null; - if (action.kind === "exitToBottom") { - keyboardFocusedMessageIdRef.current = null; - setKeyboardFocusedMessageId(null); - scrollToEnd({ behavior: "smooth" }); - return { kind: "exitToBottom" }; - } - const message = messages.find((entry) => entry.id === action.id); - if (!message) return null; - keyboardFocusedMessageIdRef.current = action.id; - setKeyboardFocusedMessageId(action.id); - scrollToMessage(action.id); - return { kind: "recall", text: message.content, fresh: action.fresh }; - }, - [setKeyboardFocusedMessageId, scrollToMessage, scrollToEnd], - ); + + // Recall position is invisible (no highlight, no scrolling), so a plain ref + // is enough. A newly sent prompt resets it so the next Up starts from it. + const recallMessageIdRef = useRef(null); + const prevUserMessageCountRef = useRef(userMessages.length); + if (userMessages.length > prevUserMessageCountRef.current) { + recallMessageIdRef.current = null; + } + prevUserMessageCountRef.current = userMessages.length; + + const recallFromComposer = useCallback((direction) => { + const messages = userMessagesRef.current; + const action = promptRecallStep( + messages.map((message) => message.id), + recallMessageIdRef.current, + direction, + ); + if (!action) return null; + if (action.kind === "exit") { + recallMessageIdRef.current = null; + return { kind: "exit" }; + } + const message = messages.find((entry) => entry.id === action.id); + if (!message) return null; + recallMessageIdRef.current = action.id; + return { kind: "recall", text: message.content, fresh: action.fresh }; + }, []); useEffect(() => { - if (!composerNavigationRef) return; - composerNavigationRef.current = navigateFromComposer; + if (!promptRecallRef) return; + promptRecallRef.current = recallFromComposer; return () => { - composerNavigationRef.current = null; + promptRecallRef.current = null; }; - }, [composerNavigationRef, navigateFromComposer]); + }, [promptRecallRef, recallFromComposer]); const handleJumpToMessage = useCallback( (id: string) => { @@ -1038,7 +1038,7 @@ export function ChatThread({ repoPath, task, taskId, - composerNavigationRef, + promptRecallRef, }: ConversationViewProps) { const diffWorkerFactory = useService(DIFF_WORKER_FACTORY); const diffsPoolOptions = useMemo( @@ -1079,23 +1079,6 @@ export function ChatThread({ setKeyboardFocusedMessageId(null); }, []); - // A newly sent prompt resets recall, so the next Up starts from it. - // Adjusted during render, not in an effect, so a keypress landing right - // after the commit can't see the stale focus. - const userMessageCount = useMemo( - () => - items.reduce((n, item) => (item.type === "user_message" ? n + 1 : n), 0), - [items], - ); - const [prevUserMessageCount, setPrevUserMessageCount] = - useState(userMessageCount); - if (userMessageCount !== prevUserMessageCount) { - setPrevUserMessageCount(userMessageCount); - if (userMessageCount > prevUserMessageCount) { - setKeyboardFocusedMessageId(null); - } - } - const renderItem = useCallback( (item: ConversationItem) => { switch (item.type) { @@ -1198,7 +1181,7 @@ export function ChatThread({ setJumpPickerOpen={setJumpPickerOpen} keyboardFocusedMessageId={keyboardFocusedMessageId} setKeyboardFocusedMessageId={setKeyboardFocusedMessageId} - composerNavigationRef={composerNavigationRef} + promptRecallRef={promptRecallRef} /> diff --git a/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.test.ts b/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.test.ts deleted file mode 100644 index 855e8b5dfc..0000000000 --- a/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - type ComposerNavigationAction, - type ComposerNavigationDirection, - composerMessageNavigation, -} from "./composerMessageNavigation"; - -const ids = ["m1", "m2", "m3"]; - -describe("composerMessageNavigation", () => { - it.each<{ - name: string; - focusedId: string | null; - direction: ComposerNavigationDirection; - expected: ComposerNavigationAction | null; - }>([ - { - name: "up with no focus starts fresh on the newest user message", - focusedId: null, - direction: -1, - expected: { kind: "focus", id: "m3", fresh: true }, - }, - { - name: "up from the middle focuses the previous message", - focusedId: "m2", - direction: -1, - expected: { kind: "focus", id: "m1", fresh: false }, - }, - { - name: "up at the oldest message stays on it", - focusedId: "m1", - direction: -1, - expected: { kind: "focus", id: "m1", fresh: false }, - }, - { - name: "up with an unknown focus id starts fresh on the newest message", - focusedId: "gone", - direction: -1, - expected: { kind: "focus", id: "m3", fresh: true }, - }, - { - name: "down with no focus does nothing", - focusedId: null, - direction: 1, - expected: null, - }, - { - name: "down from the middle focuses the next message", - focusedId: "m2", - direction: 1, - expected: { kind: "focus", id: "m3", fresh: false }, - }, - { - name: "down at the newest message exits back to the bottom", - focusedId: "m3", - direction: 1, - expected: { kind: "exitToBottom" }, - }, - { - name: "down with an unknown focus id does nothing", - focusedId: "gone", - direction: 1, - expected: null, - }, - ])("$name", ({ focusedId, direction, expected }) => { - expect(composerMessageNavigation(ids, focusedId, direction)).toEqual( - expected, - ); - }); - - it.each<{ direction: ComposerNavigationDirection }>([ - { direction: -1 }, - { direction: 1 }, - ])( - "returns null when there are no user messages (direction $direction)", - ({ direction }) => { - expect(composerMessageNavigation([], null, direction)).toBeNull(); - }, - ); -}); diff --git a/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.ts b/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.ts deleted file mode 100644 index 442912707c..0000000000 --- a/packages/ui/src/features/sessions/components/chat-thread/composerMessageNavigation.ts +++ /dev/null @@ -1,41 +0,0 @@ -export type ComposerNavigationDirection = -1 | 1; - -export type ComposerNavigationAction = - | { kind: "focus"; id: string; fresh: boolean } - | { kind: "exitToBottom" }; - -export type ComposerNavigationResult = - | { kind: "recall"; text: string; fresh: boolean } - | { kind: "exitToBottom" }; - -export type ComposerMessageNavigationHandler = ( - direction: ComposerNavigationDirection, -) => ComposerNavigationResult | null; - -export function composerMessageNavigation( - userMessageIds: string[], - focusedId: string | null, - direction: ComposerNavigationDirection, -): ComposerNavigationAction | null { - if (userMessageIds.length === 0) return null; - - const currentIndex = focusedId ? userMessageIds.indexOf(focusedId) : -1; - - if (direction === -1) { - const previousIndex = - currentIndex === -1 - ? userMessageIds.length - 1 - : Math.max(0, currentIndex - 1); - const id = userMessageIds[previousIndex]; - return id ? { kind: "focus", id, fresh: currentIndex === -1 } : null; - } - - // Down only means "toward newer" while already navigating; otherwise the - // caret is just resting at the end of the input and the key stays inert. - if (currentIndex === -1) return null; - if (currentIndex >= userMessageIds.length - 1) { - return { kind: "exitToBottom" }; - } - const id = userMessageIds[currentIndex + 1]; - return id ? { kind: "focus", id, fresh: false } : null; -} diff --git a/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.test.ts b/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.test.ts new file mode 100644 index 0000000000..8987e19bf3 --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + type PromptRecallAction, + type PromptRecallDirection, + promptRecallStep, +} from "./composerPromptRecall"; + +const ids = ["m1", "m2", "m3"]; + +describe("promptRecallStep", () => { + it.each<{ + name: string; + currentId: string | null; + direction: PromptRecallDirection; + expected: PromptRecallAction | null; + }>([ + { + name: "up with no recall in progress starts fresh on the newest prompt", + currentId: null, + direction: -1, + expected: { kind: "recall", id: "m3", fresh: true }, + }, + { + name: "up from the middle recalls the previous prompt", + currentId: "m2", + direction: -1, + expected: { kind: "recall", id: "m1", fresh: false }, + }, + { + name: "up at the oldest prompt stays on it", + currentId: "m1", + direction: -1, + expected: { kind: "recall", id: "m1", fresh: false }, + }, + { + name: "up with an unknown current id starts fresh on the newest prompt", + currentId: "gone", + direction: -1, + expected: { kind: "recall", id: "m3", fresh: true }, + }, + { + name: "down with no recall in progress does nothing", + currentId: null, + direction: 1, + expected: null, + }, + { + name: "down from the middle recalls the next prompt", + currentId: "m2", + direction: 1, + expected: { kind: "recall", id: "m3", fresh: false }, + }, + { + name: "down at the newest prompt exits recall", + currentId: "m3", + direction: 1, + expected: { kind: "exit" }, + }, + { + name: "down with an unknown current id does nothing", + currentId: "gone", + direction: 1, + expected: null, + }, + ])("$name", ({ currentId, direction, expected }) => { + expect(promptRecallStep(ids, currentId, direction)).toEqual(expected); + }); + + it.each<{ direction: PromptRecallDirection }>([ + { direction: -1 }, + { direction: 1 }, + ])( + "returns null when no prompts were sent (direction $direction)", + ({ direction }) => { + expect(promptRecallStep([], null, direction)).toBeNull(); + }, + ); +}); diff --git a/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.ts b/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.ts new file mode 100644 index 0000000000..0ef2ef4a62 --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.ts @@ -0,0 +1,41 @@ +export type PromptRecallDirection = -1 | 1; + +export type PromptRecallAction = + | { kind: "recall"; id: string; fresh: boolean } + | { kind: "exit" }; + +export type PromptRecallResult = + | { kind: "recall"; text: string; fresh: boolean } + | { kind: "exit" }; + +export type PromptRecallHandler = ( + direction: PromptRecallDirection, +) => PromptRecallResult | null; + +export function promptRecallStep( + sentPromptIds: string[], + currentId: string | null, + direction: PromptRecallDirection, +): PromptRecallAction | null { + if (sentPromptIds.length === 0) return null; + + const currentIndex = currentId ? sentPromptIds.indexOf(currentId) : -1; + + if (direction === -1) { + const previousIndex = + currentIndex === -1 + ? sentPromptIds.length - 1 + : Math.max(0, currentIndex - 1); + const id = sentPromptIds[previousIndex]; + return id ? { kind: "recall", id, fresh: currentIndex === -1 } : null; + } + + // Down only cycles toward newer prompts while already recalling; otherwise + // the caret is just resting at the end of the input and the key stays inert. + if (currentIndex === -1) return null; + if (currentIndex >= sentPromptIds.length - 1) { + return { kind: "exit" }; + } + const id = sentPromptIds[currentIndex + 1]; + return id ? { kind: "recall", id, fresh: false } : null; +} From 1201009bb565ee878567a5bf5d4b7823077def0d Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 16:39:12 -0700 Subject: [PATCH 07/13] hint option arrows on first prompt recall --- .../message-editor/tiptap/useTiptapEditor.ts | 15 +++++++++++++++ .../sessions/components/ConversationView.tsx | 1 + .../components/chat-thread/ChatThread.tsx | 1 + 3 files changed, 17 insertions(+) diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index a3e6bb3810..fc2c10fc8e 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -20,6 +20,10 @@ import { isUrlOnly, shouldAutoConvertLongText, } from "@posthog/core/message-editor/paste"; +import { + formatHotkey, + SHORTCUTS, +} from "@posthog/ui/features/command/keyboard-shortcuts"; import type { PromptRecallHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore"; import { useSettingsStore as useFeatureSettingsStore } from "@posthog/ui/features/settings/settingsStore"; @@ -205,6 +209,16 @@ function hasVisibleSuggestionPopup(sessionId: string): boolean { ); } +function showMessageNavHint(): void { + const store = useFeatureSettingsStore.getState(); + if (!store.shouldShowHint("recall-message-nav")) return; + store.recordHintShown("recall-message-nav"); + toast.info( + "Recalled a sent prompt", + `Use ${formatHotkey(SHORTCUTS.MESSAGE_PREV)} and ${formatHotkey(SHORTCUTS.MESSAGE_NEXT)} to jump between your messages in the conversation.`, + ); +} + function showPasteHint(message: string, description: string): void { const store = useFeatureSettingsStore.getState(); const key = @@ -432,6 +446,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { if (result.kind === "recall") { if (result.fresh) { promptRecallDraftRef.current = view.state.doc; + showMessageNavHint(); } const tr = view.state.tr .delete(1, view.state.doc.content.size - 1) diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index 0e414bc49a..1458d744c6 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -282,6 +282,7 @@ export function ConversationView({ const handleNavigateMessage = useCallback( (direction: -1 | 1) => { + useSettingsStore.getState().markHintLearned("recall-message-nav"); if (userMessages.length === 0) return; const currentIndex = keyboardFocusedMessageId diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 4b5c542d9e..d80a5f1461 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -854,6 +854,7 @@ function ThreadKeyboardNav({ const handleNavigateMessage = useCallback( (direction: -1 | 1) => { + useSettingsStore.getState().markHintLearned("recall-message-nav"); if (userMessageIds.length === 0) return; const currentIndex = keyboardFocusedMessageId From 44e6febbe70747b6ed607f90f8afea9f94ccba13 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 17:15:05 -0700 Subject: [PATCH 08/13] extract shared composer prompt recall hook --- .../message-editor/tiptap/useTiptapEditor.ts | 9 +- .../sessions/components/ConversationView.tsx | 46 +-------- .../components/chat-thread/ChatThread.tsx | 46 +-------- .../chat-thread/composerPromptRecall.test.ts | 96 +++++++++++++++++++ .../chat-thread/composerPromptRecall.ts | 27 ++++++ .../chat-thread/usePromptRecallSource.ts | 44 +++++++++ 6 files changed, 181 insertions(+), 87 deletions(-) create mode 100644 packages/ui/src/features/sessions/components/chat-thread/usePromptRecallSource.ts diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index fc2c10fc8e..082628c7a4 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -24,7 +24,10 @@ import { formatHotkey, SHORTCUTS, } from "@posthog/ui/features/command/keyboard-shortcuts"; -import type { PromptRecallHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; +import { + PROMPT_RECALL_HINT_KEY, + type PromptRecallHandler, +} from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore"; import { useSettingsStore as useFeatureSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { toast } from "@posthog/ui/primitives/toast"; @@ -211,8 +214,8 @@ function hasVisibleSuggestionPopup(sessionId: string): boolean { function showMessageNavHint(): void { const store = useFeatureSettingsStore.getState(); - if (!store.shouldShowHint("recall-message-nav")) return; - store.recordHintShown("recall-message-nav"); + if (!store.shouldShowHint(PROMPT_RECALL_HINT_KEY)) return; + store.recordHintShown(PROMPT_RECALL_HINT_KEY); toast.info( "Recalled a sent prompt", `Use ${formatHotkey(SHORTCUTS.MESSAGE_PREV)} and ${formatHotkey(SHORTCUTS.MESSAGE_NEXT)} to jump between your messages in the conversation.`, diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index 1458d744c6..b4dd5aac26 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -17,11 +17,12 @@ import type { } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ConversationSearchBar"; import { + PROMPT_RECALL_HINT_KEY, type PromptRecallHandler, - promptRecallStep, } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker"; import { THREAD_HOTKEY_OPTIONS } from "@posthog/ui/features/sessions/components/chat-thread/threadHotkeys"; +import { usePromptRecallSource } from "@posthog/ui/features/sessions/components/chat-thread/usePromptRecallSource"; import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult"; import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems"; @@ -258,20 +259,7 @@ export function ConversationView({ return result; }, [items]); - // Read by the recall handler at keypress time, so it never acts on a stale - // snapshot (the ref registration effect runs after paint, and key repeats - // can outpace re-renders). - const userMessagesRef = useRef(userMessages); - userMessagesRef.current = userMessages; - - // Recall position is invisible (no highlight, no scrolling), so a plain ref - // is enough. A newly sent prompt resets it so the next Up starts from it. - const recallMessageIdRef = useRef(null); - const prevUserMessageCountRef = useRef(userMessages.length); - if (userMessages.length > prevUserMessageCountRef.current) { - recallMessageIdRef.current = null; - } - prevUserMessageCountRef.current = userMessages.length; + usePromptRecallSource(userMessages, promptRecallRef); // Grouped rows != items, so scroll by the row the message landed in (same // mapping search uses), falling back to the raw item index. @@ -282,7 +270,7 @@ export function ConversationView({ const handleNavigateMessage = useCallback( (direction: -1 | 1) => { - useSettingsStore.getState().markHintLearned("recall-message-nav"); + useSettingsStore.getState().markHintLearned(PROMPT_RECALL_HINT_KEY); if (userMessages.length === 0) return; const currentIndex = keyboardFocusedMessageId @@ -352,32 +340,6 @@ export function ConversationView({ setShowScrollButton(false); }, []); - const recallFromComposer = useCallback((direction) => { - const messages = userMessagesRef.current; - const action = promptRecallStep( - messages.map((message) => message.id), - recallMessageIdRef.current, - direction, - ); - if (!action) return null; - if (action.kind === "exit") { - recallMessageIdRef.current = null; - return { kind: "exit" }; - } - const message = messages.find((entry) => entry.id === action.id); - if (!message) return null; - recallMessageIdRef.current = action.id; - return { kind: "recall", text: message.content, fresh: action.fresh }; - }, []); - - useEffect(() => { - if (!promptRecallRef) return; - promptRecallRef.current = recallFromComposer; - return () => { - promptRecallRef.current = null; - }; - }, [promptRecallRef, recallFromComposer]); - useEffect(() => { const handleVisibilityChange = () => { if (!document.hidden && isAtBottomRef.current) { diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index d80a5f1461..bd568fa4aa 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -42,8 +42,8 @@ import { import { ChatThreadFooter } from "@posthog/ui/features/sessions/components/chat-thread/ChatThreadFooter"; import { ChatThreadChromeProvider } from "@posthog/ui/features/sessions/components/chat-thread/chatThreadChrome"; import { + PROMPT_RECALL_HINT_KEY, type PromptRecallHandler, - promptRecallStep, } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker"; import { @@ -51,6 +51,7 @@ import { type ToolGroupItem, } from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup"; import { THREAD_HOTKEY_OPTIONS } from "@posthog/ui/features/sessions/components/chat-thread/threadHotkeys"; +import { usePromptRecallSource } from "@posthog/ui/features/sessions/components/chat-thread/usePromptRecallSource"; import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult"; import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems"; @@ -854,7 +855,7 @@ function ThreadKeyboardNav({ const handleNavigateMessage = useCallback( (direction: -1 | 1) => { - useSettingsStore.getState().markHintLearned("recall-message-nav"); + useSettingsStore.getState().markHintLearned(PROMPT_RECALL_HINT_KEY); if (userMessageIds.length === 0) return; const currentIndex = keyboardFocusedMessageId @@ -897,46 +898,7 @@ function ThreadKeyboardNav({ THREAD_HOTKEY_OPTIONS, ); - // Read at keypress time so the handler registered in the ref effect never - // acts on a stale snapshot (the effect runs after paint, and key repeats - // can outpace re-renders). - const userMessagesRef = useRef(userMessages); - userMessagesRef.current = userMessages; - - // Recall position is invisible (no highlight, no scrolling), so a plain ref - // is enough. A newly sent prompt resets it so the next Up starts from it. - const recallMessageIdRef = useRef(null); - const prevUserMessageCountRef = useRef(userMessages.length); - if (userMessages.length > prevUserMessageCountRef.current) { - recallMessageIdRef.current = null; - } - prevUserMessageCountRef.current = userMessages.length; - - const recallFromComposer = useCallback((direction) => { - const messages = userMessagesRef.current; - const action = promptRecallStep( - messages.map((message) => message.id), - recallMessageIdRef.current, - direction, - ); - if (!action) return null; - if (action.kind === "exit") { - recallMessageIdRef.current = null; - return { kind: "exit" }; - } - const message = messages.find((entry) => entry.id === action.id); - if (!message) return null; - recallMessageIdRef.current = action.id; - return { kind: "recall", text: message.content, fresh: action.fresh }; - }, []); - - useEffect(() => { - if (!promptRecallRef) return; - promptRecallRef.current = recallFromComposer; - return () => { - promptRecallRef.current = null; - }; - }, [promptRecallRef, recallFromComposer]); + usePromptRecallSource(userMessages, promptRecallRef); const handleJumpToMessage = useCallback( (id: string) => { diff --git a/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.test.ts b/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.test.ts index 8987e19bf3..8277385fa7 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.test.ts +++ b/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import { type PromptRecallAction, type PromptRecallDirection, + type PromptRecallResult, promptRecallStep, + resolvePromptRecall, } from "./composerPromptRecall"; const ids = ["m1", "m2", "m3"]; @@ -66,6 +68,37 @@ describe("promptRecallStep", () => { expect(promptRecallStep(ids, currentId, direction)).toEqual(expected); }); + it.each<{ + name: string; + currentId: string | null; + direction: PromptRecallDirection; + expected: PromptRecallAction | null; + }>([ + { + name: "up with no recall in progress recalls it fresh", + currentId: null, + direction: -1, + expected: { kind: "recall", id: "m1", fresh: true }, + }, + { + name: "up while on it stays on it", + currentId: "m1", + direction: -1, + expected: { kind: "recall", id: "m1", fresh: false }, + }, + { + name: "down while on it exits recall", + currentId: "m1", + direction: 1, + expected: { kind: "exit" }, + }, + ])( + "with a single sent prompt, $name", + ({ currentId, direction, expected }) => { + expect(promptRecallStep(["m1"], currentId, direction)).toEqual(expected); + }, + ); + it.each<{ direction: PromptRecallDirection }>([ { direction: -1 }, { direction: 1 }, @@ -76,3 +109,66 @@ describe("promptRecallStep", () => { }, ); }); + +describe("resolvePromptRecall", () => { + const messages = [ + { id: "m1", content: "first prompt" }, + { id: "m2", content: "second prompt" }, + ]; + + it.each<{ + name: string; + currentId: string | null; + direction: PromptRecallDirection; + expectedResult: PromptRecallResult | null; + expectedNextId: string | null; + }>([ + { + name: "recalls the newest prompt fresh and tracks its id", + currentId: null, + direction: -1, + expectedResult: { kind: "recall", text: "second prompt", fresh: true }, + expectedNextId: "m2", + }, + { + name: "steps to an older prompt and tracks its id", + currentId: "m2", + direction: -1, + expectedResult: { kind: "recall", text: "first prompt", fresh: false }, + expectedNextId: "m1", + }, + { + name: "exits at the newest prompt and clears the tracked id", + currentId: "m2", + direction: 1, + expectedResult: { kind: "exit" }, + expectedNextId: null, + }, + { + name: "stays inert on down outside recall and keeps the id", + currentId: null, + direction: 1, + expectedResult: null, + expectedNextId: null, + }, + { + name: "stays inert on a stale id and keeps it", + currentId: "gone", + direction: 1, + expectedResult: null, + expectedNextId: "gone", + }, + ])("$name", ({ currentId, direction, expectedResult, expectedNextId }) => { + expect(resolvePromptRecall(messages, currentId, direction)).toEqual({ + result: expectedResult, + nextId: expectedNextId, + }); + }); + + it("returns null and keeps the id when no prompts were sent", () => { + expect(resolvePromptRecall([], null, -1)).toEqual({ + result: null, + nextId: null, + }); + }); +}); diff --git a/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.ts b/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.ts index 0ef2ef4a62..4946f131a3 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.ts +++ b/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.ts @@ -1,3 +1,10 @@ +export const PROMPT_RECALL_HINT_KEY = "recall-message-nav"; + +export interface RecallableMessage { + id: string; + content: string; +} + export type PromptRecallDirection = -1 | 1; export type PromptRecallAction = @@ -39,3 +46,23 @@ export function promptRecallStep( const id = sentPromptIds[currentIndex + 1]; return id ? { kind: "recall", id, fresh: false } : null; } + +export function resolvePromptRecall( + messages: RecallableMessage[], + currentId: string | null, + direction: PromptRecallDirection, +): { result: PromptRecallResult | null; nextId: string | null } { + const action = promptRecallStep( + messages.map((message) => message.id), + currentId, + direction, + ); + if (!action) return { result: null, nextId: currentId }; + if (action.kind === "exit") return { result: { kind: "exit" }, nextId: null }; + const message = messages.find((entry) => entry.id === action.id); + if (!message) return { result: null, nextId: currentId }; + return { + result: { kind: "recall", text: message.content, fresh: action.fresh }, + nextId: action.id, + }; +} diff --git a/packages/ui/src/features/sessions/components/chat-thread/usePromptRecallSource.ts b/packages/ui/src/features/sessions/components/chat-thread/usePromptRecallSource.ts new file mode 100644 index 0000000000..262da8804c --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/usePromptRecallSource.ts @@ -0,0 +1,44 @@ +import { + type PromptRecallHandler, + type RecallableMessage, + resolvePromptRecall, +} from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; +import { type RefObject, useCallback, useEffect, useRef } from "react"; + +export function usePromptRecallSource( + messages: RecallableMessage[], + promptRecallRef: RefObject | undefined, +): void { + // Read at keypress time so the handler registered in the ref effect never + // acts on a stale snapshot (the effect runs after paint, and key repeats + // can outpace re-renders). + const messagesRef = useRef(messages); + messagesRef.current = messages; + + // Recall position is invisible (no highlight, no scrolling), so a plain ref + // is enough. A newly sent prompt resets it so the next Up starts from it. + const recallMessageIdRef = useRef(null); + const prevMessageCountRef = useRef(messages.length); + if (messages.length > prevMessageCountRef.current) { + recallMessageIdRef.current = null; + } + prevMessageCountRef.current = messages.length; + + const recallFromComposer = useCallback((direction) => { + const { result, nextId } = resolvePromptRecall( + messagesRef.current, + recallMessageIdRef.current, + direction, + ); + recallMessageIdRef.current = nextId; + return result; + }, []); + + useEffect(() => { + if (!promptRecallRef) return; + promptRecallRef.current = recallFromComposer; + return () => { + promptRecallRef.current = null; + }; + }, [promptRecallRef, recallFromComposer]); +} From 242d3864b414a11175d42dfdcb00c24391b9b3d2 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 17:16:39 -0700 Subject: [PATCH 09/13] strip injected blocks from recalled prompts --- .../chat-thread/composerPromptRecall.test.ts | 32 +++++++++++++++++++ .../chat-thread/composerPromptRecall.ts | 23 ++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.test.ts b/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.test.ts index 8277385fa7..2781320de4 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.test.ts +++ b/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.test.ts @@ -171,4 +171,36 @@ describe("resolvePromptRecall", () => { nextId: null, }); }); + + it.each<{ name: string; content: string }>([ + { + name: "a channel context block", + content: + 'CONTEXT.md body\n\nfix the bug', + }, + { + name: "a canvas instructions block", + content: + "authoring rules\n\nfix the bug", + }, + { + name: "a custom instructions block", + content: + "fix the bug\n\nbe terse", + }, + { + name: "a trailing attachment summary", + content: "fix the bug\n\nAttached files: screenshot.png", + }, + { + name: "several injected blocks at once", + content: + 'CONTEXT.md body\n\nfix the bug\n\nbe terse', + }, + ])("strips $name from the recalled text", ({ content }) => { + expect(resolvePromptRecall([{ id: "m1", content }], null, -1)).toEqual({ + result: { kind: "recall", text: "fix the bug", fresh: true }, + nextId: "m1", + }); + }); }); diff --git a/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.ts b/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.ts index 4946f131a3..c29e6d4d68 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.ts +++ b/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.ts @@ -1,3 +1,8 @@ +import { stripTrailingAttachmentSummary } from "@posthog/core/editor/cloud-prompt"; +import { extractCanvasInstructions } from "@posthog/ui/features/sessions/components/session-update/canvasInstructions"; +import { extractChannelContext } from "@posthog/ui/features/sessions/components/session-update/channelContext"; +import { extractCustomInstructions } from "@posthog/ui/features/sessions/components/session-update/customInstructions"; + export const PROMPT_RECALL_HINT_KEY = "recall-message-nav"; export interface RecallableMessage { @@ -47,6 +52,18 @@ export function promptRecallStep( return id ? { kind: "recall", id, fresh: false } : null; } +// A stored prompt can carry blocks folded in at send time that the user never +// typed (channel CONTEXT.md, canvas instructions, personalization, a trailing +// attachment summary); recall returns only what the user wrote. +function stripInjectedPromptBlocks(content: string): string { + const withoutChannel = extractChannelContext(content)?.stripped ?? content; + const withoutCanvas = + extractCanvasInstructions(withoutChannel)?.stripped ?? withoutChannel; + const withoutInstructions = + extractCustomInstructions(withoutCanvas)?.stripped ?? withoutCanvas; + return stripTrailingAttachmentSummary(withoutInstructions); +} + export function resolvePromptRecall( messages: RecallableMessage[], currentId: string | null, @@ -62,7 +79,11 @@ export function resolvePromptRecall( const message = messages.find((entry) => entry.id === action.id); if (!message) return { result: null, nextId: currentId }; return { - result: { kind: "recall", text: message.content, fresh: action.fresh }, + result: { + kind: "recall", + text: stripInjectedPromptBlocks(message.content), + fresh: action.fresh, + }, nextId: action.id, }; } From 29ed203ba014aeb412397e074c1bd17464023331 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 17:18:04 -0700 Subject: [PATCH 10/13] clear recall draft when editor is recreated --- .../ui/src/features/message-editor/tiptap/useTiptapEditor.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 082628c7a4..6fb06fec92 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -722,6 +722,11 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const draft = useDraftSync(editor, sessionId, context); draftRef.current = draft; + // 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. + useEffect(() => { + promptRecallDraftRef.current = null; + }, [editor]); + // Keep attachmentsRef in sync with state (synchronous, no effect needed) attachmentsRef.current = attachments; From ab46ceb27fd679fd11700a8abf232b73d3f0fea2 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 17:19:26 -0700 Subject: [PATCH 11/13] mark recall hint learned only after navigating --- .../ui/src/features/sessions/components/ConversationView.tsx | 2 +- .../src/features/sessions/components/chat-thread/ChatThread.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index b4dd5aac26..5a049e930a 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -270,7 +270,6 @@ export function ConversationView({ const handleNavigateMessage = useCallback( (direction: -1 | 1) => { - useSettingsStore.getState().markHintLearned(PROMPT_RECALL_HINT_KEY); if (userMessages.length === 0) return; const currentIndex = keyboardFocusedMessageId @@ -292,6 +291,7 @@ export function ConversationView({ const nextMessage = userMessages[nextIndex]; if (!nextMessage) return; + useSettingsStore.getState().markHintLearned(PROMPT_RECALL_HINT_KEY); setKeyboardFocusedMessageId(nextMessage.id); scrollToUserMessage(nextMessage.id, nextMessage.index); }, diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index bd568fa4aa..0a18973537 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -855,7 +855,6 @@ function ThreadKeyboardNav({ const handleNavigateMessage = useCallback( (direction: -1 | 1) => { - useSettingsStore.getState().markHintLearned(PROMPT_RECALL_HINT_KEY); if (userMessageIds.length === 0) return; const currentIndex = keyboardFocusedMessageId @@ -875,6 +874,7 @@ function ThreadKeyboardNav({ const nextId = userMessageIds[nextIndex]; if (!nextId) return; + useSettingsStore.getState().markHintLearned(PROMPT_RECALL_HINT_KEY); setKeyboardFocusedMessageId(nextId); scrollToMessage(nextId); }, From 03d50eaa67b693b212b721bca5961a0a295dc0e4 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 17:21:42 -0700 Subject: [PATCH 12/13] dedupe composer hint and text replace helpers --- .../features/command/keyboard-shortcuts.ts | 4 +- .../message-editor/tiptap/useTiptapEditor.ts | 70 ++++++++++--------- 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index 069e4981f9..177de7666a 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -235,14 +235,14 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ context: "Message editor", }, { - id: "prompt-history-prev", + id: "prompt-recall-prev", keys: "up", description: "Recall previous prompt", category: "editor", context: "Message editor", }, { - id: "prompt-history-next", + id: "prompt-recall-next", keys: "down", description: "Recall next prompt", category: "editor", diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 6fb06fec92..4c6d97edcc 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -30,7 +30,7 @@ import { } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore"; import { useSettingsStore as useFeatureSettingsStore } from "@posthog/ui/features/settings/settingsStore"; -import { toast } from "@posthog/ui/primitives/toast"; +import { toast, type ToastOptions } from "@posthog/ui/primitives/toast"; import { isSendMessageSubmitKey } from "@posthog/ui/utils/sendMessageKey"; import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { TextSelection } from "@tiptap/pm/state"; @@ -202,6 +202,11 @@ async function resolveGithubRefChip( ); } +function replaceComposerText(view: EditorView, text = "") { + const tr = view.state.tr.delete(1, view.state.doc.content.size - 1); + return text ? tr.insertText(text, 1) : tr; +} + function hasVisibleSuggestionPopup(sessionId: string): boolean { // tippy.js sets data-state="hidden" when hiding via .hide(); the session // tag keeps another mounted composer's popup from matching. @@ -212,25 +217,34 @@ function hasVisibleSuggestionPopup(sessionId: string): boolean { ); } -function showMessageNavHint(): void { +function showHintOnce( + key: string, + title: string, + detail: string | ToastOptions, +): void { const store = useFeatureSettingsStore.getState(); - if (!store.shouldShowHint(PROMPT_RECALL_HINT_KEY)) return; - store.recordHintShown(PROMPT_RECALL_HINT_KEY); - toast.info( + if (!store.shouldShowHint(key)) return; + store.recordHintShown(key); + toast.info(title, detail); +} + +function showMessageNavHint(): void { + showHintOnce( + PROMPT_RECALL_HINT_KEY, "Recalled a sent prompt", `Use ${formatHotkey(SHORTCUTS.MESSAGE_PREV)} and ${formatHotkey(SHORTCUTS.MESSAGE_NEXT)} to jump between your messages in the conversation.`, ); } function showPasteHint(message: string, description: string): void { - const store = useFeatureSettingsStore.getState(); const key = message === "Pasted as file attachment" ? "paste-as-file" : "paste-inline"; - if (!store.shouldShowHint(key)) return; - store.recordHintShown(key); - toast.info(message, { + showHintOnce(key, message, { description, - action: { label: "Got it", onClick: () => store.markHintLearned(key) }, + action: { + label: "Got it", + onClick: () => useFeatureSettingsStore.getState().markHintLearned(key), + }, }); } @@ -381,6 +395,9 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { !event.ctrlKey ) { const historyGetter = getPromptHistoryRef.current; + if (!taskId && !historyGetter && !onPromptRecallRef.current) { + return false; + } const currentText = view.state.doc.textContent; const isEmpty = !currentText.trim(); @@ -393,11 +410,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { sessionStoreSetters.dequeueMessagesAsText(taskId); if (queuedContent !== null && queuedContent !== undefined) { event.preventDefault(); - view.dispatch( - view.state.tr - .delete(1, view.state.doc.content.size - 1) - .insertText(queuedContent, 1), - ); + view.dispatch(replaceComposerText(view, queuedContent)); return true; } } @@ -405,11 +418,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const newText = historyActions.navigateUp(history, currentText); if (newText !== null) { event.preventDefault(); - view.dispatch( - view.state.tr - .delete(1, view.state.doc.content.size - 1) - .insertText(newText, 1), - ); + view.dispatch(replaceComposerText(view, newText)); return true; } } @@ -418,11 +427,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const newText = historyActions.navigateDown(history); if (newText !== null) { event.preventDefault(); - view.dispatch( - view.state.tr - .delete(1, view.state.doc.content.size - 1) - .insertText(newText, 1), - ); + view.dispatch(replaceComposerText(view, newText)); return true; } } @@ -451,9 +456,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { promptRecallDraftRef.current = view.state.doc; showMessageNavHint(); } - const tr = view.state.tr - .delete(1, view.state.doc.content.size - 1) - .insertText(result.text, 1); + const tr = replaceComposerText(view, result.text); // Recalling up parks the caret at the start so the next Up // press keeps cycling; recalling down parks it at the end. if (event.key === "ArrowUp") { @@ -465,14 +468,17 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const draft = promptRecallDraftRef.current; promptRecallDraftRef.current = null; - const tr = view.state.tr; if (draft) { - tr.replaceWith(0, view.state.doc.content.size, draft.content); + const tr = view.state.tr.replaceWith( + 0, + view.state.doc.content.size, + draft.content, + ); tr.setSelection(TextSelection.atEnd(tr.doc)); + view.dispatch(tr); } else { - tr.delete(1, view.state.doc.content.size - 1); + view.dispatch(replaceComposerText(view)); } - view.dispatch(tr); return true; } } From 80330d208b945266e4728a0c3e298a4afd9c6802 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 17:55:12 -0700 Subject: [PATCH 13/13] sort toast import in useTiptapEditor --- .../ui/src/features/message-editor/tiptap/useTiptapEditor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 4c6d97edcc..1b11f8bbb8 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -30,7 +30,7 @@ import { } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore"; import { useSettingsStore as useFeatureSettingsStore } from "@posthog/ui/features/settings/settingsStore"; -import { toast, type ToastOptions } from "@posthog/ui/primitives/toast"; +import { type ToastOptions, toast } from "@posthog/ui/primitives/toast"; import { isSendMessageSubmitKey } from "@posthog/ui/utils/sendMessageKey"; import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { TextSelection } from "@tiptap/pm/state";