feat: add prompt history recall (#3718)

This commit is contained in:
zgenu 2026-06-23 08:18:14 +08:00 committed by GitHub
parent b990da785f
commit 14d9bb87a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 232 additions and 1 deletions

View file

@ -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<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const promptHistoryIndexRef = useRef<number | null>(null);
const promptHistoryDraftRef = useRef("");
const [followups, setFollowups] = useState<string[]>([]);
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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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}
/>
</PromptInputBody>

View file

@ -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,
}) => {

View file

@ -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,
}) => {