diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index 967c454e2d..177de7666a 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -235,16 +235,16 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ context: "Message editor", }, { - id: "prompt-history-prev", + id: "prompt-recall-prev", keys: "up", - description: "Previous prompt (when input is empty)", + description: "Recall previous prompt", category: "editor", context: "Message editor", }, { - id: "prompt-history-next", + id: "prompt-recall-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 57044cf66c..c720c58df9 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 { 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"; @@ -76,6 +77,8 @@ export interface PromptInputProps { hideDefaultToolbar?: boolean; // prompt history provider getPromptHistory?: () => string[]; + // 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; @@ -121,6 +124,7 @@ export const PromptInput = forwardRef( headerAddon, hideDefaultToolbar = false, getPromptHistory, + onPromptRecall, onBeforeSubmit, onSubmit, onBashCommand, @@ -177,6 +181,7 @@ export const PromptInput = forwardRef( commands: enableCommands, }, getPromptHistory, + onPromptRecall, onBeforeSubmit, onSubmit, onBashCommand, 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 b1a0c179b8..1b11f8bbb8 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -20,10 +20,20 @@ import { isUrlOnly, shouldAutoConvertLongText, } from "@posthog/core/message-editor/paste"; +import { + formatHotkey, + SHORTCUTS, +} from "@posthog/ui/features/command/keyboard-shortcuts"; +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"; +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"; import type { EditorView } from "@tiptap/pm/view"; import { useEditor } from "@tiptap/react"; import type React from "react"; @@ -61,6 +71,7 @@ export interface UseTiptapEditorOptions { }; clearOnSubmit?: boolean; getPromptHistory?: () => string[]; + onPromptRecall?: PromptRecallHandler; onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean; onSubmit?: (text: string) => void; onBashCommand?: (command: string) => void; @@ -191,15 +202,49 @@ async function resolveGithubRefChip( ); } -function showPasteHint(message: string, description: string): void { +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. + return ( + document.querySelector( + `[data-tippy-root] .tippy-box:not([data-state='hidden']) [data-suggestion-session="${CSS.escape(sessionId)}"]`, + ) !== null + ); +} + +function showHintOnce( + key: string, + title: string, + detail: string | ToastOptions, +): 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, { + 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 key = + message === "Pasted as file attachment" ? "paste-as-file" : "paste-inline"; + showHintOnce(key, message, { description, - action: { label: "Got it", onClick: () => store.markHintLearned(key) }, + action: { + label: "Got it", + onClick: () => useFeatureSettingsStore.getState().markHintLearned(key), + }, }); } @@ -216,6 +261,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { capabilities = {}, clearOnSubmit = true, getPromptHistory, + onPromptRecall, onBeforeSubmit, onSubmit, onBashCommand, @@ -256,6 +302,14 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const getPromptHistoryRef = useRef(getPromptHistory); getPromptHistoryRef.current = getPromptHistory; + 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 promptRecallDraftRef = useRef(null); + const prevBashModeRef = useRef(false); const prevIsEmptyRef = useRef(true); const submitRef = useRef<() => void>(() => {}); @@ -324,11 +378,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(); @@ -337,12 +387,17 @@ 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; + if (!taskId && !historyGetter && !onPromptRecallRef.current) { + return false; + } const currentText = view.state.doc.textContent; const isEmpty = !currentText.trim(); @@ -355,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; } } @@ -367,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; } } @@ -380,14 +427,60 @@ 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; } } + + const recallPrompt = onPromptRecallRef.current; + if (recallPrompt) { + if (hasVisibleSuggestionPopup(sessionId)) 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 sent-prompt recall. + const atBoundary = + selection.empty && + (event.key === "ArrowUp" + ? selection.from <= 1 + : selection.to >= doc.content.size - 1); + if (!atBoundary) return false; + + const result = recallPrompt(event.key === "ArrowUp" ? -1 : 1); + if (!result) return false; + event.preventDefault(); + + if (result.kind === "recall") { + if (result.fresh) { + promptRecallDraftRef.current = view.state.doc; + showMessageNavHint(); + } + 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") { + tr.setSelection(TextSelection.create(tr.doc, 1)); + } + view.dispatch(tr); + return true; + } + + const draft = promptRecallDraftRef.current; + promptRecallDraftRef.current = null; + if (draft) { + const tr = view.state.tr.replaceWith( + 0, + view.state.doc.content.size, + draft.content, + ); + tr.setSelection(TextSelection.atEnd(tr.doc)); + view.dispatch(tr); + } else { + view.dispatch(replaceComposerText(view)); + } + return true; + } } return false; @@ -635,6 +728,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; @@ -675,6 +773,8 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const text = editor.getText().trim(); + promptRecallDraftRef.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 c3f39d0f9c..5a049e930a 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -16,8 +16,13 @@ import type { TurnContext, } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ConversationSearchBar"; +import { + PROMPT_RECALL_HINT_KEY, + type PromptRecallHandler, +} 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"; @@ -62,7 +67,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 +99,11 @@ export interface ConversationViewProps { * scrollbar from off-edge content; nested code blocks keep their own scroll. */ scrollX?: boolean; + /** + * Filled with the view's prompt recall handler so the composer can forward + * plain Up/Down presses (caret at the input boundary) to it. + */ + promptRecallRef?: RefObject; } export function ConversationView({ @@ -99,6 +117,7 @@ export function ConversationView({ compact = false, collapseMode: collapseModeProp, scrollX = true, + promptRecallRef, }: ConversationViewProps) { const diffWorkerFactory = useService(DIFF_WORKER_FACTORY); const diffsPoolOptions = useMemo( @@ -230,16 +249,18 @@ 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; }, [items]); + 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. const scrollToUserMessage = useCallback((id: string, itemIndex: number) => { @@ -270,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/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index 5c84aaefa6..7b68e4b148 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 { PromptRecallHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { copyFromContextMenu, getGithubRefUrlFromEventTarget, @@ -323,6 +324,11 @@ export function SessionView({ const [isDraggingFile, setIsDraggingFile] = useState(false); const editorRef = useRef(null); + const promptRecallRef = useRef(null); + const handlePromptRecall = useCallback( + (direction) => promptRecallRef.current?.(direction) ?? null, + [], + ); 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 +579,7 @@ export function SessionView({ slackThreadUrl={slackThreadUrl} compact={compact} scrollX={false} + promptRecallRef={promptRecallRef} /> {!useNewChatThread && } @@ -696,6 +703,7 @@ export function SessionView({ onToggleMessagingMode={ isCloudRun ? undefined : toggleMessagingMode } + 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 6e82d78d3d..0a18973537 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -41,12 +41,17 @@ 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 { + PROMPT_RECALL_HINT_KEY, + type PromptRecallHandler, +} from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker"; import { ToolGroup, 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"; @@ -84,6 +89,7 @@ import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { memo, type ReactNode, + type RefObject, useCallback, useEffect, useLayoutEffect, @@ -92,7 +98,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,25 +821,31 @@ function ThreadKeyboardNav({ setJumpPickerOpen, keyboardFocusedMessageId, setKeyboardFocusedMessageId, + promptRecallRef, }: { items: ConversationItem[]; jumpPickerOpen: boolean; setJumpPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; keyboardFocusedMessageId: string | null; setKeyboardFocusedMessageId: (id: string | null) => void; + promptRecallRef?: RefObject; }) { const { scrollToMessage } = 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, @@ -863,6 +874,7 @@ function ThreadKeyboardNav({ const nextId = userMessageIds[nextIndex]; if (!nextId) return; + useSettingsStore.getState().markHintLearned(PROMPT_RECALL_HINT_KEY); setKeyboardFocusedMessageId(nextId); scrollToMessage(nextId); }, @@ -886,6 +898,8 @@ function ThreadKeyboardNav({ THREAD_HOTKEY_OPTIONS, ); + usePromptRecallSource(userMessages, promptRecallRef); + const handleJumpToMessage = useCallback( (id: string) => { setKeyboardFocusedMessageId(id); @@ -987,6 +1001,7 @@ export function ChatThread({ repoPath, task, taskId, + promptRecallRef, }: ConversationViewProps) { const diffWorkerFactory = useService(DIFF_WORKER_FACTORY); const diffsPoolOptions = useMemo( @@ -1129,6 +1144,7 @@ export function ChatThread({ setJumpPickerOpen={setJumpPickerOpen} keyboardFocusedMessageId={keyboardFocusedMessageId} setKeyboardFocusedMessageId={setKeyboardFocusedMessageId} + promptRecallRef={promptRecallRef} /> 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..2781320de4 --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; +import { + type PromptRecallAction, + type PromptRecallDirection, + type PromptRecallResult, + promptRecallStep, + resolvePromptRecall, +} 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<{ + 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 }, + ])( + "returns null when no prompts were sent (direction $direction)", + ({ direction }) => { + expect(promptRecallStep([], null, direction)).toBeNull(); + }, + ); +}); + +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, + }); + }); + + 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 new file mode 100644 index 0000000000..c29e6d4d68 --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/composerPromptRecall.ts @@ -0,0 +1,89 @@ +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 { + id: string; + content: string; +} + +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; +} + +// 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, + 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: stripInjectedPromptBlocks(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]); +}