Unverified 提交 ac309b61 authored 作者: Adekunle James Adeniji's avatar Adekunle James Adeniji 提交者: GitHub

feat(chat input): recall previous messages using Up Arrow (#2343)

Closes #1999 <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2343"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces terminal-style history recall in the chat input using a zero-width trigger and the mentions menu. > > - New `HistoryNavigation` plugin listens for `ArrowUp` on empty input to insert `HISTORY_TRIGGER` and open a history menu; `Escape` clears it > - Integrates `messageHistory` from `ChatInput` (per-chat user messages, newest first) into `LexicalChatInput`; home input passes an empty history > - Extends mentions to support history items under `HISTORY_TRIGGER`, customizes menu item rendering, and strips the trigger from the external value sync/onChange flow > - Adds comprehensive Playwright e2e tests for opening, navigating, selecting (Enter/mouse), closing (Escape), and guard cases (non-empty input, empty history), plus normal message sending after closing > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ea2a9298c6dc7e72a6cc1d3b044a4b6bcacf5bb4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Recall previous user messages in the chat input using the Up Arrow. This adds a simple history menu that lets you quickly reuse past prompts without leaving the input. - **New Features** - Press Up Arrow in an empty input to open a history menu (only if history exists). - Navigate with Arrow keys, select with Enter or mouse click, and close with Escape. Defaults to the most recent item. - Integrates with Lexical via an invisible trigger, and won’t open when the input has content. - Pulls user-only messages per chat for history; includes end-to-end tests covering open, navigation, selection, closing, and guard cases. <sup>Written for commit d87bc2f712a69f411c3046e9376099656326bca1. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 2be066af
import { test, Timeout } from "./helpers/test_helper";
import { expect } from "@playwright/test";
test("should open, navigate, and select from history menu", async ({ po }) => {
await po.setUp({ autoApprove: true });
// Send messages to populate history
await po.sendPrompt("First test message");
await po.waitForChatCompletion();
await po.sendPrompt("Second test message");
await po.waitForChatCompletion();
// Click on the chat input to focus it
const chatInput = po.getChatInput();
await chatInput.click();
await chatInput.fill("");
// Press up arrow with empty input to open history menu
await po.page.keyboard.press("ArrowUp");
// Wait for history menu to appear and contain items
const historyMenu = po.page.locator('[data-mentions-menu="true"]');
await expect(historyMenu).toBeVisible();
// Verify menu has items (oldest at top, newest at bottom)
const menuItems = po.page.locator('[data-mentions-menu="true"] li');
await expect(menuItems).toHaveCount(2);
await expect(menuItems.nth(0)).toContainText("First test message");
await expect(menuItems.nth(1)).toContainText("Second test message");
// Verify default selection is the last (most recent) item
const lastItem = menuItems.nth(1);
await expect(lastItem).toHaveClass(/bg-accent/, { timeout: 500 });
// Navigate up to first item
await po.page.keyboard.press("ArrowUp");
const firstItem = menuItems.nth(0);
await expect(firstItem).toHaveClass(/bg-accent/);
await expect(firstItem).toContainText("First test message");
// Navigate up again to wrap to last item
await po.page.keyboard.press("ArrowUp");
await expect(lastItem).toHaveClass(/bg-accent/);
// Select with Enter
await po.page.keyboard.press("Enter");
// Menu should close and text should be inserted
await expect(historyMenu).not.toBeVisible({ timeout: Timeout.MEDIUM });
await expect(chatInput).toContainText("Second test message", {
timeout: Timeout.MEDIUM,
});
// Clear input for mouse click test
await chatInput.fill("");
await po.page.keyboard.press("ArrowUp");
await expect(historyMenu).toBeVisible();
// Click the first item (oldest message)
await menuItems.nth(0).click();
// Verify menu closed and first message was inserted
await expect(historyMenu).not.toBeVisible({ timeout: Timeout.MEDIUM });
await expect(chatInput).toContainText("First test message", {
timeout: Timeout.MEDIUM,
});
});
test("should handle edge cases: guards, escape, and sending after cancel", async ({
po,
}) => {
await po.setUp({ autoApprove: true });
const chatInput = po.getChatInput();
const historyMenu = po.page.locator('[data-mentions-menu="true"]');
// Test 1: Empty history guard - menu should not open with no history
await chatInput.click();
await chatInput.fill("");
await po.page.keyboard.press("ArrowUp");
const inputValue = await chatInput.textContent({ timeout: Timeout.MEDIUM });
expect(inputValue?.trim()).toBe("");
await expect(historyMenu).not.toBeVisible();
// Create some history
await po.sendPrompt("History entry for testing");
await po.waitForChatCompletion();
// Test 2: Non-empty input guard - menu should not open when input has content
await chatInput.click();
await chatInput.fill("typed content");
await po.page.keyboard.press("ArrowUp");
const inputValueWithContent = await chatInput.textContent({
timeout: Timeout.MEDIUM,
});
expect(inputValueWithContent?.trim()).toBe("typed content");
await expect(historyMenu).not.toBeVisible();
// Test 3: Escape closes menu and clears input
await chatInput.fill("");
await po.page.keyboard.press("ArrowUp");
await expect(historyMenu).toBeVisible();
await po.page.keyboard.press("Escape");
await expect(historyMenu).not.toBeVisible();
// Test 4: After closing menu, can send regular messages
await chatInput.click();
await chatInput.fill("New test message after escape");
await po.page.keyboard.press("Enter");
await po.waitForChatCompletion();
// Verify the message was sent
await expect(
po.page.getByText("New test message after escape", { exact: false }),
).toBeVisible();
});
...@@ -18,7 +18,7 @@ import { ...@@ -18,7 +18,7 @@ import {
Lock, Lock,
} from "lucide-react"; } from "lucide-react";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState, useMemo } from "react";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { ipc } from "@/ipc/types"; import { ipc } from "@/ipc/types";
...@@ -160,6 +160,16 @@ export function ChatInput({ chatId }: { chatId?: number }) { ...@@ -160,6 +160,16 @@ export function ChatInput({ chatId }: { chatId?: number }) {
proposal.type === "code-proposal" && proposal.type === "code-proposal" &&
messageId === lastMessage.id; messageId === lastMessage.id;
// Extract user message history for terminal-style navigation
const userMessageHistory = useMemo(() => {
if (!chatId) return [];
const messages = messagesById.get(chatId) ?? [];
return messages
.filter((msg) => msg.role === "user")
.map((msg) => msg.content)
.reverse(); // Most recent first
}, [chatId, messagesById]);
const { userBudget } = useUserBudgetInfo(); const { userBudget } = useUserBudgetInfo();
// Token counting for context limit banner // Token counting for context limit banner
...@@ -464,6 +474,7 @@ export function ChatInput({ chatId }: { chatId?: number }) { ...@@ -464,6 +474,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
placeholder="Ask Dyad to build..." placeholder="Ask Dyad to build..."
excludeCurrentApp={true} excludeCurrentApp={true}
disableSendButton={disableSendButton} disableSendButton={disableSendButton}
messageHistory={userMessageHistory}
/> />
{isStreaming ? ( {isStreaming ? (
......
import { useCallback, useEffect, useRef } from "react";
import { $getRoot, $createParagraphNode, $createTextNode } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
KEY_ARROW_UP_COMMAND,
KEY_ESCAPE_COMMAND,
COMMAND_PRIORITY_CRITICAL,
} from "lexical";
export const HISTORY_TRIGGER = "\u200B";
/** Delay (ms) before dispatching KEY_ARROW_UP so the typeahead menu has mounted. */
const SELECT_LAST_DISPATCH_DELAY_MS = 60;
interface HistoryNavigationProps {
messageHistory: string[];
onTriggerInserted: () => void;
onTriggerCleared: () => void;
}
function clearSelectLastTimeout(
timeoutRef: { current: ReturnType<typeof setTimeout> | null },
scheduledRef: { current: boolean },
) {
if (timeoutRef.current != null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
scheduledRef.current = false;
}
export function HistoryNavigation({
messageHistory,
onTriggerInserted,
onTriggerCleared,
}: HistoryNavigationProps) {
const [editor] = useLexicalComposerContext();
const syntheticUpScheduledRef = useRef(false);
const selectLastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const handleArrowUp = useCallback(
(event: KeyboardEvent) => {
if (messageHistory.length === 0) {
return false;
}
// Ignore our synthetic KEY_ARROW_UP (we dispatch it to select last item).
// This also debounces rapid ArrowUp presses: if a press arrives while a
// synthetic dispatch is scheduled, it's ignored to prevent multiple menus.
if (syntheticUpScheduledRef.current) {
syntheticUpScheduledRef.current = false;
return false;
}
let isEmpty = false;
editor.getEditorState().read(() => {
const root = $getRoot();
isEmpty = root.getTextContent().trim().length === 0;
});
if (!isEmpty) {
return false;
}
event.preventDefault();
onTriggerInserted();
editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
paragraph.append($createTextNode(HISTORY_TRIGGER));
root.append(paragraph);
paragraph.selectEnd();
});
// Dispatch KEY_ARROW_UP after a short delay so the typeahead menu has
// mounted. Store timeout id and clear on unmount or ESC to avoid leaks.
syntheticUpScheduledRef.current = true;
selectLastTimeoutRef.current = setTimeout(() => {
selectLastTimeoutRef.current = null;
if (!syntheticUpScheduledRef.current) return;
editor.dispatchCommand(
KEY_ARROW_UP_COMMAND,
new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }),
);
}, SELECT_LAST_DISPATCH_DELAY_MS);
return true;
},
[editor, messageHistory, onTriggerInserted],
);
useEffect(() => {
const unregisterArrowUp = editor.registerCommand(
KEY_ARROW_UP_COMMAND,
handleArrowUp,
COMMAND_PRIORITY_CRITICAL,
);
const unregisterEscape = editor.registerCommand(
KEY_ESCAPE_COMMAND,
(event: KeyboardEvent) => {
let isTriggerOnly = false;
editor.getEditorState().read(() => {
const root = $getRoot();
const textContent = root.getTextContent();
isTriggerOnly = textContent === HISTORY_TRIGGER;
});
if (!isTriggerOnly) {
return false;
}
event.preventDefault();
clearSelectLastTimeout(selectLastTimeoutRef, syntheticUpScheduledRef);
editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
root.append(paragraph);
paragraph.select();
});
onTriggerCleared();
return true;
},
COMMAND_PRIORITY_CRITICAL,
);
return () => {
unregisterArrowUp();
unregisterEscape();
clearSelectLastTimeout(selectLastTimeoutRef, syntheticUpScheduledRef);
};
}, [editor, handleArrowUp, onTriggerCleared]);
return null;
}
...@@ -98,6 +98,7 @@ export function HomeChatInput({ ...@@ -98,6 +98,7 @@ export function HomeChatInput({
disabled={isStreaming} disabled={isStreaming}
excludeCurrentApp={false} excludeCurrentApp={false}
disableSendButton={false} disableSendButton={false}
messageHistory={[]}
/> />
{isStreaming ? ( {isStreaming ? (
......
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { import {
$getRoot, $getRoot,
$createParagraphNode, $createParagraphNode,
...@@ -27,6 +27,7 @@ import { useAtomValue } from "jotai"; ...@@ -27,6 +27,7 @@ import { useAtomValue } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { MENTION_REGEX, parseAppMentions } from "@/shared/parse_mention_apps"; import { MENTION_REGEX, parseAppMentions } from "@/shared/parse_mention_apps";
import { useLoadApp } from "@/hooks/useLoadApp"; import { useLoadApp } from "@/hooks/useLoadApp";
import { HistoryNavigation, HISTORY_TRIGGER } from "./HistoryNavigation";
// Define the theme for mentions // Define the theme for mentions
const beautifulMentionsTheme: BeautifulMentionsTheme = { const beautifulMentionsTheme: BeautifulMentionsTheme = {
...@@ -41,8 +42,27 @@ const CustomMenuItem = forwardRef< ...@@ -41,8 +42,27 @@ const CustomMenuItem = forwardRef<
>(({ selected, item, ...props }, ref) => { >(({ selected, item, ...props }, ref) => {
const isPrompt = item.data?.type === "prompt"; const isPrompt = item.data?.type === "prompt";
const isApp = item.data?.type === "app"; const isApp = item.data?.type === "app";
const label = isPrompt ? "Prompt" : isApp ? "App" : "File"; const isHistory = item.data?.type === "history";
const label = isPrompt ? "Prompt" : isApp ? "App" : isHistory ? "" : "File";
const value = (item as any)?.value; const value = (item as any)?.value;
// For history items, show full text without label
if (isHistory) {
return (
<li
className={`m-0 px-3 py-2 cursor-pointer whitespace-nowrap overflow-hidden text-ellipsis ${
selected
? "bg-accent text-accent-foreground"
: "bg-popover text-popover-foreground hover:bg-accent/50"
}`}
{...props}
ref={ref}
>
<span className="truncate text-sm">{value}</span>
</li>
);
}
return ( return (
<li <li
className={`m-0 flex items-center px-3 py-2 cursor-pointer whitespace-nowrap ${ className={`m-0 flex items-center px-3 py-2 cursor-pointer whitespace-nowrap ${
...@@ -236,6 +256,7 @@ interface LexicalChatInputProps { ...@@ -236,6 +256,7 @@ interface LexicalChatInputProps {
onPaste?: (e: React.ClipboardEvent) => void; onPaste?: (e: React.ClipboardEvent) => void;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
messageHistory: string[];
excludeCurrentApp: boolean; excludeCurrentApp: boolean;
disableSendButton: boolean; disableSendButton: boolean;
} }
...@@ -253,17 +274,32 @@ export function LexicalChatInput({ ...@@ -253,17 +274,32 @@ export function LexicalChatInput({
placeholder = "Ask Dyad to build...", placeholder = "Ask Dyad to build...",
disabled = false, disabled = false,
disableSendButton, disableSendButton,
messageHistory = [],
}: LexicalChatInputProps) { }: LexicalChatInputProps) {
const { apps } = useLoadApps(); const { apps } = useLoadApps();
const { prompts } = usePrompts(); const { prompts } = usePrompts();
const [shouldClear, setShouldClear] = useState(false); const [shouldClear, setShouldClear] = useState(false);
const historyTriggerActiveRef = useRef(false);
const selectedAppId = useAtomValue(selectedAppIdAtom); const selectedAppId = useAtomValue(selectedAppIdAtom);
const { app } = useLoadApp(selectedAppId); const { app } = useLoadApp(selectedAppId);
const appFiles = app?.files; const appFiles = app?.files;
// Prepare mention items - convert apps to mention format // Prepare mention items - convert apps to mention format
const mentionItems = React.useMemo(() => { const mentionItems = React.useMemo(() => {
if (!apps) return { "@": [] }; const result: Record<string, any[]> = { "@": [], [HISTORY_TRIGGER]: [] };
// Add history items under the history trigger - always available regardless of app loading
// Reverse so most recent appears at the bottom
const historyItems = (messageHistory || [])
.slice()
.reverse()
.map((item) => ({
value: item,
type: "history",
}));
result[HISTORY_TRIGGER] = historyItems;
if (!apps) return result;
// Get current app name // Get current app name
const currentApp = apps.find((app) => app.id === selectedAppId); const currentApp = apps.find((app) => app.id === selectedAppId);
...@@ -304,10 +340,18 @@ export function LexicalChatInput({ ...@@ -304,10 +340,18 @@ export function LexicalChatInput({
type: "file", type: "file",
})); }));
return { result["@"] = [...appMentions, ...promptItems, ...fileItems];
"@": [...appMentions, ...promptItems, ...fileItems],
}; return result;
}, [apps, selectedAppId, value, excludeCurrentApp, prompts, appFiles]); }, [
apps,
selectedAppId,
value,
excludeCurrentApp,
prompts,
appFiles,
messageHistory,
]);
const initialConfig = { const initialConfig = {
namespace: "ChatInput", namespace: "ChatInput",
...@@ -325,6 +369,24 @@ export function LexicalChatInput({ ...@@ -325,6 +369,24 @@ export function LexicalChatInput({
const root = $getRoot(); const root = $getRoot();
let textContent = root.getTextContent(); let textContent = root.getTextContent();
// If the history trigger is active, keep the input value empty while the
// menu is open, and always strip the invisible trigger from the value.
if (historyTriggerActiveRef.current) {
const hasTrigger = textContent.includes(HISTORY_TRIGGER);
const withoutTrigger = textContent.split(HISTORY_TRIGGER).join("");
// Clear the ref when trigger is gone OR when real content is inserted
// (e.g., when a menu item is selected). This ensures consistent state
// even if the selected text contains a zero-width space character.
if (!hasTrigger || withoutTrigger.trim() !== "") {
historyTriggerActiveRef.current = false;
}
if (withoutTrigger.trim() === "") {
textContent = "";
} else {
textContent = withoutTrigger;
}
}
// Transform @AppName mentions to @app:AppName format // Transform @AppName mentions to @app:AppName format
// This regex matches @AppName where AppName is one of our actual app names // This regex matches @AppName where AppName is one of our actual app names
...@@ -424,6 +486,15 @@ export function LexicalChatInput({ ...@@ -424,6 +486,15 @@ export function LexicalChatInput({
shouldClear={shouldClear} shouldClear={shouldClear}
onCleared={handleCleared} onCleared={handleCleared}
/> />
<HistoryNavigation
messageHistory={messageHistory}
onTriggerInserted={() => {
historyTriggerActiveRef.current = true;
}}
onTriggerCleared={() => {
historyTriggerActiveRef.current = false;
}}
/>
</div> </div>
</LexicalComposer> </LexicalComposer>
); );
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论