Files
kandev/apps/web/components/task/chat/use-tiptap-editor.test.ts
T
João Salavisa f3aeab020a feat(web): chat history nav with ArrowUp/Down and Ctrl+R fuzzy search (#1246)
* 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>
2026-06-02 17:00:38 +01:00

187 lines
5.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { decideSubmitShortcut } from "./use-tiptap-editor";
import { decideHistoryNav } from "./tiptap-editor-history";
describe("decideSubmitShortcut", () => {
describe("submitKey=enter", () => {
it("submits on Enter when no suggestion menu is open", () => {
expect(
decideSubmitShortcut({
pressed: "enter",
disabled: false,
submitKey: "enter",
isSuggestionMenuOpen: false,
}),
).toBe("submit");
});
// Regression: when slash/@ suggestion popup is open and the user presses
// Enter to pick the highlighted item, the keymap must defer to the
// suggestion plugin instead of submitting the message.
it("defers to the suggestion plugin when the menu is open", () => {
expect(
decideSubmitShortcut({
pressed: "enter",
disabled: false,
submitKey: "enter",
isSuggestionMenuOpen: true,
}),
).toBe("defer");
});
it("does not submit on Mod-Enter (Mod-Enter is treated as a newline path)", () => {
expect(
decideSubmitShortcut({
pressed: "mod-enter",
disabled: false,
submitKey: "enter",
isSuggestionMenuOpen: false,
}),
).toBe("defer");
});
});
describe("submitKey=cmd_enter", () => {
it("does not submit on Enter — defers (suggestion or newline)", () => {
expect(
decideSubmitShortcut({
pressed: "enter",
disabled: false,
submitKey: "cmd_enter",
isSuggestionMenuOpen: false,
}),
).toBe("defer");
});
it("submits on Mod-Enter when no menu is open", () => {
expect(
decideSubmitShortcut({
pressed: "mod-enter",
disabled: false,
submitKey: "cmd_enter",
isSuggestionMenuOpen: false,
}),
).toBe("submit");
});
// Mod-Enter is not a suggestion-pick key (handleMenuKeyDown only handles
// Enter and Tab) so the menu state is intentionally ignored — Mod-Enter
// always submits in cmd_enter mode.
it("submits on Mod-Enter even when the menu is open", () => {
expect(
decideSubmitShortcut({
pressed: "mod-enter",
disabled: false,
submitKey: "cmd_enter",
isSuggestionMenuOpen: true,
}),
).toBe("submit");
});
});
describe("disabled", () => {
it("consumes Enter without submitting when the input is disabled", () => {
expect(
decideSubmitShortcut({
pressed: "enter",
disabled: true,
submitKey: "enter",
isSuggestionMenuOpen: false,
}),
).toBe("consume-noop");
});
it("consumes Mod-Enter without submitting when the input is disabled", () => {
expect(
decideSubmitShortcut({
pressed: "mod-enter",
disabled: true,
submitKey: "cmd_enter",
isSuggestionMenuOpen: false,
}),
).toBe("consume-noop");
});
});
});
describe("decideHistoryNav", () => {
const base = {
disabled: false,
isSuggestionMenuOpen: false,
isReverseSearchOpen: false,
atBoundary: true,
historyLength: 3,
state: { index: null as number | null },
};
it("defers when disabled", () => {
expect(decideHistoryNav({ ...base, direction: "up", disabled: true })).toEqual({
kind: "defer",
});
});
it("defers when the slash/@ menu is open", () => {
expect(decideHistoryNav({ ...base, direction: "up", isSuggestionMenuOpen: true })).toEqual({
kind: "defer",
});
});
it("defers when the reverse-search overlay owns focus", () => {
expect(decideHistoryNav({ ...base, direction: "up", isReverseSearchOpen: true })).toEqual({
kind: "defer",
});
});
it("defers when history is empty", () => {
expect(decideHistoryNav({ ...base, direction: "up", historyLength: 0 })).toEqual({
kind: "defer",
});
});
it("defers when caret is not at the textblock boundary", () => {
expect(decideHistoryNav({ ...base, direction: "up", atBoundary: false })).toEqual({
kind: "defer",
});
});
it("applies index 0 on first ArrowUp", () => {
expect(decideHistoryNav({ ...base, direction: "up" })).toEqual({
kind: "apply",
index: 0,
});
});
it("walks back on subsequent ArrowUp", () => {
expect(decideHistoryNav({ ...base, direction: "up", state: { index: 1 } })).toEqual({
kind: "apply",
index: 2,
});
});
it("consumes ArrowUp at the oldest entry (no cursor escape)", () => {
expect(decideHistoryNav({ ...base, direction: "up", state: { index: 2 } })).toEqual({
kind: "consume-noop",
});
});
it("defers ArrowDown when not in history mode (let cursor move normally)", () => {
expect(decideHistoryNav({ ...base, direction: "down" })).toEqual({
kind: "defer",
});
});
it("walks forward on ArrowDown while in history", () => {
expect(decideHistoryNav({ ...base, direction: "down", state: { index: 2 } })).toEqual({
kind: "apply",
index: 1,
});
});
it("exits history (restores draft) on ArrowDown from index 0", () => {
expect(decideHistoryNav({ ...base, direction: "down", state: { index: 0 } })).toEqual({
kind: "apply",
index: null,
});
});
});