f3aeab020a
* feat(web): chat history nav with ArrowUp/Down and Ctrl+R fuzzy search ArrowUp/ArrowDown cycle the user's previous messages in the chat composer (bash-style), Ctrl+R opens a fuzzy reverse-search overlay anchored to the input. Opening the overlay drains the paginated message history in the background so search covers the whole session, not only the pages already loaded in the chat list. * fix(web): address PR review on chat history nav (#1246) - Always clear isDraining on cancel, error, and normal completion (cleanup setter + try/catch/finally in the drain loop). - Ctrl+R no longer dead-keys when the loaded history is empty: the overlay opens unconditionally and renders "No matches" until drained pages arrive. - Dedupe adjacent user prompts in the original message stream, not in the extracted output, so repeated prompts separated by agent/tool turns stay reachable from ArrowUp and Ctrl+R. - Stabilize handleReverseSearchSelect by depending on the destructured closeReverseSearch instead of the spread overlay object. - HitRow key={hit.index} so React reuses rows across re-rankings. - Reorder SSR/isOpen guards before computeStyle so window.* isn't touched during a server render. - Add use-drain-older-messages.test.ts covering normal drain, batch cap, cancellation on active->false, and rejection clearing isDraining. * fix(web): address PR feedback on chat history nav (#1246) - writeHistoryContent wraps editor mutations in try/finally so state.isApplying clears even if a chain or onChange handler throws. - Dismiss the Ctrl+R overlay on window resize and capture-phase scroll so the fixed-position portal can't drift away from the editor anchor. - Drop the unused historyRef from useChatHistory's return value. - Extract HitRow into message-history-search-row.tsx so the search file stays under the 200-line cap. * fix(web): keep Ctrl+R overlay open during keyboard navigation (#1246) - Drop capture:true on the window scroll listener so scrollIntoView inside the overlay's own result list (triggered by keyboard navigation past the visible window) no longer dismisses the overlay. - Remove the always-true isOpen prop and the unreachable guard from MessageHistorySearch; visibility is controlled solely by the parent's conditional render. --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
188 lines
6.3 KiB
TypeScript
188 lines
6.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
extractUserHistory,
|
|
navigateHistory,
|
|
fuzzyScore,
|
|
searchHistory,
|
|
type HistoryState,
|
|
} from "./message-history";
|
|
import type { Message } from "@/lib/types/http";
|
|
|
|
function msg(partial: Partial<Message>): Message {
|
|
return {
|
|
id: partial.id ?? "m",
|
|
session_id: "s1" as Message["session_id"],
|
|
task_id: "t1" as Message["task_id"],
|
|
author_type: partial.author_type ?? "user",
|
|
type: partial.type ?? "message",
|
|
content: partial.content ?? "",
|
|
created_at: partial.created_at ?? "2026-01-01T00:00:00Z",
|
|
...partial,
|
|
};
|
|
}
|
|
|
|
describe("extractUserHistory", () => {
|
|
it("returns user message contents newest-first", () => {
|
|
const messages: Message[] = [
|
|
msg({ id: "1", content: "first" }),
|
|
msg({ id: "2", content: "second" }),
|
|
msg({ id: "3", content: "third" }),
|
|
];
|
|
expect(extractUserHistory(messages)).toEqual(["third", "second", "first"]);
|
|
});
|
|
|
|
it("skips agent messages", () => {
|
|
const messages: Message[] = [
|
|
msg({ id: "1", content: "user-a" }),
|
|
msg({ id: "2", content: "agent-reply", author_type: "agent" }),
|
|
msg({ id: "3", content: "user-b" }),
|
|
];
|
|
expect(extractUserHistory(messages)).toEqual(["user-b", "user-a"]);
|
|
});
|
|
|
|
it("skips non-message types (tool_call, thinking, etc.)", () => {
|
|
const messages: Message[] = [
|
|
msg({ id: "1", content: "real prompt" }),
|
|
msg({ id: "2", content: "thought", type: "thinking" }),
|
|
msg({ id: "3", content: "ran cmd", type: "tool_execute" }),
|
|
];
|
|
expect(extractUserHistory(messages)).toEqual(["real prompt"]);
|
|
});
|
|
|
|
it("skips empty/whitespace content", () => {
|
|
const messages: Message[] = [
|
|
msg({ id: "1", content: "real" }),
|
|
msg({ id: "2", content: "" }),
|
|
msg({ id: "3", content: " " }),
|
|
];
|
|
expect(extractUserHistory(messages)).toEqual(["real"]);
|
|
});
|
|
|
|
it("collapses consecutive duplicates", () => {
|
|
const messages: Message[] = [
|
|
msg({ id: "1", content: "fix bug" }),
|
|
msg({ id: "2", content: "fix bug" }),
|
|
msg({ id: "3", content: "fix bug" }),
|
|
msg({ id: "4", content: "ship it" }),
|
|
];
|
|
expect(extractUserHistory(messages)).toEqual(["ship it", "fix bug"]);
|
|
});
|
|
|
|
it("keeps non-consecutive duplicates", () => {
|
|
const messages: Message[] = [
|
|
msg({ id: "1", content: "a" }),
|
|
msg({ id: "2", content: "b" }),
|
|
msg({ id: "3", content: "a" }),
|
|
];
|
|
expect(extractUserHistory(messages)).toEqual(["a", "b", "a"]);
|
|
});
|
|
|
|
it("keeps duplicates separated by agent/tool turns (adjacency is in original stream)", () => {
|
|
const messages: Message[] = [
|
|
msg({ id: "1", content: "fix bug" }),
|
|
msg({ id: "2", content: "working on it", author_type: "agent" }),
|
|
msg({ id: "3", content: "ran cmd", type: "tool_execute" }),
|
|
msg({ id: "4", content: "fix bug" }),
|
|
];
|
|
expect(extractUserHistory(messages)).toEqual(["fix bug", "fix bug"]);
|
|
});
|
|
|
|
it("returns empty for empty input", () => {
|
|
expect(extractUserHistory([])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("navigateHistory", () => {
|
|
const empty: HistoryState = { index: null };
|
|
|
|
it("returns null when there is no history", () => {
|
|
expect(navigateHistory(empty, "up", 0)).toBeNull();
|
|
expect(navigateHistory(empty, "down", 0)).toBeNull();
|
|
});
|
|
|
|
it("first ArrowUp enters history at index 0", () => {
|
|
expect(navigateHistory(empty, "up", 3)).toEqual({ index: 0 });
|
|
});
|
|
|
|
it("subsequent ArrowUp moves further back", () => {
|
|
expect(navigateHistory({ index: 0 }, "up", 3)).toEqual({ index: 1 });
|
|
expect(navigateHistory({ index: 1 }, "up", 3)).toEqual({ index: 2 });
|
|
});
|
|
|
|
it("ArrowUp at the oldest entry returns null", () => {
|
|
expect(navigateHistory({ index: 2 }, "up", 3)).toBeNull();
|
|
});
|
|
|
|
it("ArrowDown from null defers (returns null)", () => {
|
|
expect(navigateHistory(empty, "down", 3)).toBeNull();
|
|
});
|
|
|
|
it("ArrowDown moves toward newer entries", () => {
|
|
expect(navigateHistory({ index: 2 }, "down", 3)).toEqual({ index: 1 });
|
|
expect(navigateHistory({ index: 1 }, "down", 3)).toEqual({ index: 0 });
|
|
});
|
|
|
|
it("ArrowDown from index 0 exits history (restores draft)", () => {
|
|
expect(navigateHistory({ index: 0 }, "down", 3)).toEqual({ index: null });
|
|
});
|
|
});
|
|
|
|
describe("fuzzyScore", () => {
|
|
it("returns a positive score for an empty needle", () => {
|
|
expect(fuzzyScore("", "anything")).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("matches subsequences in order, case-insensitive", () => {
|
|
expect(fuzzyScore("fb", "fix bug")).not.toBeNull();
|
|
expect(fuzzyScore("FB", "Fix Bug")).not.toBeNull();
|
|
});
|
|
|
|
it("returns null when characters cannot be matched in order", () => {
|
|
expect(fuzzyScore("xyz", "fix bug")).toBeNull();
|
|
expect(fuzzyScore("gf", "fix bug")).toBeNull(); // g comes after f in haystack? no - 'g' is in 'bug', 'f' is before
|
|
});
|
|
|
|
it("ranks word-boundary matches above mid-word ones", () => {
|
|
const boundary = fuzzyScore("fb", "fix bug")!;
|
|
const midword = fuzzyScore("fb", "affable")!;
|
|
expect(boundary).not.toBeNull();
|
|
expect(midword).not.toBeNull();
|
|
expect(boundary).toBeGreaterThan(midword);
|
|
});
|
|
|
|
it("ranks consecutive matches above scattered ones", () => {
|
|
const consecutive = fuzzyScore("fix", "fix bug")!;
|
|
const scattered = fuzzyScore("fix", "f i x in mix")!;
|
|
expect(consecutive).toBeGreaterThan(scattered);
|
|
});
|
|
});
|
|
|
|
describe("searchHistory", () => {
|
|
it("returns every entry unchanged for an empty query", () => {
|
|
const history = ["c", "b", "a"];
|
|
expect(searchHistory(history, "")).toEqual([
|
|
{ index: 0, content: "c", score: 0 },
|
|
{ index: 1, content: "b", score: 0 },
|
|
{ index: 2, content: "a", score: 0 },
|
|
]);
|
|
});
|
|
|
|
it("filters out non-matching entries", () => {
|
|
const history = ["fix bug", "ship it", "rename foo"];
|
|
const hits = searchHistory(history, "fb");
|
|
expect(hits.map((h) => h.content)).toEqual(["fix bug"]);
|
|
});
|
|
|
|
it("orders matches best-first (consecutive chars beat scattered)", () => {
|
|
const history = ["find the bug later", "fb shortcut win"];
|
|
const hits = searchHistory(history, "fb");
|
|
expect(hits[0].content).toBe("fb shortcut win");
|
|
});
|
|
|
|
it("preserves the original index so callers can jump history position", () => {
|
|
const history = ["a", "fb match", "c"];
|
|
const [hit] = searchHistory(history, "fb");
|
|
expect(hit.index).toBe(1);
|
|
});
|
|
});
|