diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 69d0ae2ae..57133ad16 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -66,6 +66,7 @@ import { useSkills } from "@/core/skills/hooks"; import { useSuggestionsConfig } from "@/core/suggestions/hooks"; import type { AgentThreadContext } from "@/core/threads"; import { textOfMessage } from "@/core/threads/utils"; +import { isIMEComposing } from "@/lib/ime"; import { cn } from "@/lib/utils"; import { @@ -203,6 +204,8 @@ export function InputBox({ const { skills } = useSkills(); const promptRootRef = useRef(null); const textareaRef = useRef(null); + const promptHistoryIndexRef = useRef(null); + const promptHistoryDraftRef = useRef(""); const [followups, setFollowups] = useState([]); const { data: suggestionsConfig } = useSuggestionsConfig(); @@ -261,6 +264,44 @@ export function InputBox({ [selectedModel], ); + const promptHistory = useMemo(() => { + const history: string[] = []; + for (const message of thread.messages) { + if (message.type !== "human") { + continue; + } + const additionalKwargs = message.additional_kwargs; + if ( + additionalKwargs && + typeof additionalKwargs === "object" && + Reflect.get(additionalKwargs, "hide_from_ui") === true + ) { + continue; + } + const text = textOfMessage(message)?.trim(); + if (!text) { + continue; + } + if (history.at(-1) !== text) { + history.push(text); + } + } + return history; + }, [thread.messages]); + + useEffect(() => { + promptHistoryIndexRef.current = null; + promptHistoryDraftRef.current = ""; + }, [threadId]); + + useEffect(() => { + const currentIndex = promptHistoryIndexRef.current; + if (currentIndex !== null && currentIndex >= promptHistory.length) { + promptHistoryIndexRef.current = null; + promptHistoryDraftRef.current = ""; + } + }, [promptHistory.length]); + const handleModelSelect = useCallback( (model_name: string) => { const model = models.find((m) => m.name === model_name); @@ -315,6 +356,8 @@ export function InputBox({ if (!message.text.trim() && message.files.length === 0) { return; } + promptHistoryIndexRef.current = null; + promptHistoryDraftRef.current = ""; setFollowups([]); setFollowupsHidden(false); setFollowupsLoading(false); @@ -489,6 +532,91 @@ export function InputBox({ ], ); + const setPromptHistoryValue = useCallback( + (value: string) => { + textInput.setInput(value); + requestAnimationFrame(() => { + const textarea = textareaRef.current; + if (!textarea) { + return; + } + textarea.focus(); + textarea.setSelectionRange(value.length, value.length); + }); + }, + [textInput], + ); + + const handlePromptHistoryKeyDown = useCallback( + (event: KeyboardEvent) => { + if ( + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + isIMEComposing(event) || + promptHistory.length === 0 || + (event.key !== "ArrowUp" && event.key !== "ArrowDown") + ) { + return; + } + + const currentValue = textInput.value ?? ""; + const currentHistoryIndex = promptHistoryIndexRef.current; + const isBrowsingHistory = currentHistoryIndex !== null; + + if (!isBrowsingHistory && currentValue.length > 0) { + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + const nextIndex = isBrowsingHistory + ? Math.max(currentHistoryIndex - 1, 0) + : promptHistory.length - 1; + if (!isBrowsingHistory) { + promptHistoryDraftRef.current = currentValue; + } + promptHistoryIndexRef.current = nextIndex; + setPromptHistoryValue(promptHistory[nextIndex] ?? ""); + return; + } + + if (!isBrowsingHistory) { + return; + } + + event.preventDefault(); + if (currentHistoryIndex >= promptHistory.length - 1) { + promptHistoryIndexRef.current = null; + setPromptHistoryValue(promptHistoryDraftRef.current); + promptHistoryDraftRef.current = ""; + return; + } + + const nextIndex = currentHistoryIndex + 1; + promptHistoryIndexRef.current = nextIndex; + setPromptHistoryValue(promptHistory[nextIndex] ?? ""); + }, + [promptHistory, setPromptHistoryValue, textInput.value], + ); + + const handlePromptTextareaKeyDown = useCallback( + (event: KeyboardEvent) => { + handleSkillSuggestionKeyDown(event); + if (event.defaultPrevented) { + return; + } + handlePromptHistoryKeyDown(event); + }, + [handlePromptHistoryKeyDown, handleSkillSuggestionKeyDown], + ); + + const handlePromptTextareaChange = useCallback(() => { + promptHistoryIndexRef.current = null; + promptHistoryDraftRef.current = ""; + }, []); + const showFollowups = !disabled && !isWelcomeMode && @@ -708,8 +836,9 @@ export function InputBox({ autoFocus={autoFocus} defaultValue={initialValue} onBlur={() => setTextareaFocused(false)} + onChange={handlePromptTextareaChange} onFocus={() => setTextareaFocused(true)} - onKeyDown={handleSkillSuggestionKeyDown} + onKeyDown={handlePromptTextareaKeyDown} ref={textareaRef} /> diff --git a/frontend/tests/e2e/chat.spec.ts b/frontend/tests/e2e/chat.spec.ts index 237a80844..5b8533b84 100644 --- a/frontend/tests/e2e/chat.spec.ts +++ b/frontend/tests/e2e/chat.spec.ts @@ -44,6 +44,44 @@ test.describe("Chat workspace", () => { await expect(textarea).toHaveValue("/data-analysis "); }); + test("uses arrow keys to navigate skill suggestions before prompt history", async ({ + page, + }) => { + await page.goto("/workspace/chats/new"); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + + await textarea.fill("/"); + + const dataAnalysis = page.getByRole("option", { + name: /data-analysis/i, + }); + const frontendDesign = page.getByRole("option", { + name: /frontend-design/i, + }); + await expect(dataAnalysis).toBeVisible(); + await expect(frontendDesign).toBeVisible(); + await expect(dataAnalysis).toHaveAttribute("aria-selected", "true"); + + await textarea.press("ArrowDown"); + + await expect(textarea).toHaveValue("/"); + await expect(dataAnalysis).toHaveAttribute("aria-selected", "false"); + await expect(frontendDesign).toHaveAttribute("aria-selected", "true"); + + await textarea.press("ArrowUp"); + + await expect(textarea).toHaveValue("/"); + await expect(dataAnalysis).toHaveAttribute("aria-selected", "true"); + await expect(frontendDesign).toHaveAttribute("aria-selected", "false"); + + await textarea.press("ArrowDown"); + await textarea.press("Enter"); + + await expect(textarea).toHaveValue("/frontend-design "); + }); + test("keeps Shift+Enter as newline while skill suggestions are visible", async ({ page, }) => { diff --git a/frontend/tests/e2e/thread-history.spec.ts b/frontend/tests/e2e/thread-history.spec.ts index 61ee29805..444ea3e18 100644 --- a/frontend/tests/e2e/thread-history.spec.ts +++ b/frontend/tests/e2e/thread-history.spec.ts @@ -65,6 +65,70 @@ test.describe("Thread history", () => { ).toBeVisible({ timeout: 15_000 }); }); + test("input box recalls previous prompts with arrow keys", async ({ + page, + }) => { + const firstPrompt = "Summarize the latest quarterly report"; + const secondPrompt = "Turn the summary into an action plan"; + + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MOCK_THREAD_ID, + title: "Prompt history conversation", + updated_at: "2025-06-03T12:00:00Z", + messages: [ + { + type: "human", + id: "msg-human-prompt-history-1", + content: [{ type: "text", text: firstPrompt }], + }, + { + type: "ai", + id: "msg-ai-prompt-history-1", + content: "First answer", + }, + { + type: "human", + id: "msg-human-prompt-history-2", + content: [{ type: "text", text: secondPrompt }], + }, + { + type: "ai", + id: "msg-ai-prompt-history-2", + content: "Second answer", + }, + ], + }, + ], + }); + + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + await expect(page.getByText("Second answer")).toBeVisible({ + timeout: 15_000, + }); + + const textarea = page.locator("textarea[name='message']"); + await expect(textarea).toBeVisible(); + + await textarea.focus(); + await textarea.press("ArrowUp"); + await expect(textarea).toHaveValue(secondPrompt); + + await textarea.press("ArrowUp"); + await expect(textarea).toHaveValue(firstPrompt); + + await textarea.press("ArrowDown"); + await expect(textarea).toHaveValue(secondPrompt); + + await textarea.press("ArrowDown"); + await expect(textarea).toHaveValue(""); + + await textarea.fill("draft should not be overwritten"); + await textarea.press("ArrowUp"); + await expect(textarea).toHaveValue("draft should not be overwritten"); + }); + test("deleting an inactive chat keeps the current chat open", async ({ page, }) => {