fix(web): keep pasted hash text literal (#2743)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { PluginKey } from "@tiptap/pm/state";
|
||||
import { exitSuggestion } from "@tiptap/suggestion";
|
||||
import type {
|
||||
SuggestionKeyDownProps,
|
||||
SuggestionOptions,
|
||||
@@ -18,6 +19,140 @@ const EMPTY_ENTITY_REFERENCE_STATE: MenuState<EntityReference> = {
|
||||
|
||||
export const EntityReferenceSuggestionPluginKey = new PluginKey("entityReferenceSuggestion");
|
||||
|
||||
type EntityReferenceSuggestionRange = {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
type EntityReferenceSuggestionTransaction = {
|
||||
docChanged?: boolean;
|
||||
getMeta: (key: string) => unknown;
|
||||
};
|
||||
|
||||
type PendingEntityReferenceInput = {
|
||||
from: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type EntityReferenceInputGate = {
|
||||
recordTextInput: (from: number, to: number, text: string) => void;
|
||||
recordDeletion: () => void;
|
||||
shouldShow: (
|
||||
range: EntityReferenceSuggestionRange,
|
||||
transaction: EntityReferenceSuggestionTransaction,
|
||||
) => boolean;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
function isExternalEntityReferenceTransaction(
|
||||
transaction: EntityReferenceSuggestionTransaction,
|
||||
): boolean {
|
||||
const uiEvent = transaction.getMeta("uiEvent");
|
||||
return (
|
||||
uiEvent === "paste" ||
|
||||
uiEvent === "drop" ||
|
||||
transaction.getMeta("paste") === true ||
|
||||
transaction.getMeta("drop") === true
|
||||
);
|
||||
}
|
||||
|
||||
function isActiveEntityReferenceRange(
|
||||
range: EntityReferenceSuggestionRange,
|
||||
activeRange: EntityReferenceSuggestionRange | null,
|
||||
): boolean {
|
||||
return activeRange !== null && range.from === activeRange.from && range.to >= range.from + 1;
|
||||
}
|
||||
|
||||
function matchesDirectEntityReferenceInput(
|
||||
range: EntityReferenceSuggestionRange,
|
||||
input: PendingEntityReferenceInput,
|
||||
activeRange: EntityReferenceSuggestionRange | null,
|
||||
): boolean {
|
||||
const hashOffset = input.text.lastIndexOf("#");
|
||||
const triggerFrom = hashOffset >= 0 ? input.from + hashOffset : null;
|
||||
const isDirectNewTrigger =
|
||||
triggerFrom !== null && range.from === triggerFrom && range.to >= triggerFrom + 1;
|
||||
const isDirectContinuation = isActiveEntityReferenceRange(range, activeRange);
|
||||
return isDirectNewTrigger || isDirectContinuation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the direct text input that can create or extend a # suggestion.
|
||||
* ProseMirror does not mark every programmatic transaction with an origin, so
|
||||
* requiring a matching handleTextInput callback keeps restored and pasted
|
||||
* content literal without changing the editor's normal insertion behavior.
|
||||
*/
|
||||
export function createEntityReferenceInputGate(): EntityReferenceInputGate {
|
||||
let activeRange: EntityReferenceSuggestionRange | null = null;
|
||||
let pendingInput: PendingEntityReferenceInput | null = null;
|
||||
let pendingDeletion = false;
|
||||
|
||||
const reset = () => {
|
||||
activeRange = null;
|
||||
pendingInput = null;
|
||||
pendingDeletion = false;
|
||||
};
|
||||
|
||||
const recordTextInput = (from: number, _to: number, text: string) => {
|
||||
pendingInput = { from, text };
|
||||
pendingDeletion = false;
|
||||
};
|
||||
|
||||
const recordDeletion = () => {
|
||||
pendingInput = null;
|
||||
pendingDeletion = true;
|
||||
};
|
||||
|
||||
const shouldShow = (
|
||||
range: EntityReferenceSuggestionRange,
|
||||
transaction: EntityReferenceSuggestionTransaction,
|
||||
) => {
|
||||
if (isExternalEntityReferenceTransaction(transaction)) {
|
||||
reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
const input = pendingInput;
|
||||
const deletion = pendingDeletion;
|
||||
pendingInput = null;
|
||||
pendingDeletion = false;
|
||||
|
||||
// A document-changing transaction without a matching text-input callback
|
||||
// is a paste, draft restore, history recall, or programmatic update.
|
||||
if (!input && !deletion && transaction.docChanged) {
|
||||
reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
const isActiveRange = isActiveEntityReferenceRange(range, activeRange);
|
||||
if (deletion) {
|
||||
if (!isActiveRange) {
|
||||
reset();
|
||||
return false;
|
||||
}
|
||||
activeRange = { ...range };
|
||||
return true;
|
||||
}
|
||||
if (!input) {
|
||||
return Boolean(isActiveRange);
|
||||
}
|
||||
|
||||
if (!matchesDirectEntityReferenceInput(range, input, activeRange)) {
|
||||
reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
activeRange = { ...range };
|
||||
return true;
|
||||
};
|
||||
|
||||
return { recordTextInput, recordDeletion, shouldShow, reset };
|
||||
}
|
||||
|
||||
export function isEntityReferenceQueryAllowed(query: string): boolean {
|
||||
return query.length === 0 || !/^\s/u.test(query);
|
||||
}
|
||||
|
||||
export function handleEntityReferenceMenuKeyDown(args: {
|
||||
event: KeyboardEvent;
|
||||
items: readonly EntityReference[];
|
||||
@@ -43,6 +178,7 @@ export function handleEntityReferenceMenuKeyDown(args: {
|
||||
export function createEntityReferenceSuggestion(
|
||||
setMenuState: (state: MenuState<EntityReference>) => void,
|
||||
onKeyDown: (event: KeyboardEvent) => boolean,
|
||||
inputGate: EntityReferenceInputGate = createEntityReferenceInputGate(),
|
||||
): Partial<SuggestionOptions<EntityReference>> {
|
||||
return {
|
||||
char: "#",
|
||||
@@ -58,6 +194,13 @@ export function createEntityReferenceSuggestion(
|
||||
hasCodeMark: $from.marks().some((mark) => mark.type.name === "code"),
|
||||
});
|
||||
},
|
||||
shouldShow: ({ range, query, transaction }) => {
|
||||
if (!isEntityReferenceQueryAllowed(query)) {
|
||||
inputGate.reset();
|
||||
return false;
|
||||
}
|
||||
return inputGate.shouldShow(range, transaction);
|
||||
},
|
||||
items: () => [],
|
||||
command: ({ editor, range, props }) => {
|
||||
editor
|
||||
@@ -85,11 +228,14 @@ export function createEntityReferenceSuggestion(
|
||||
onKeyDown(keyDown: SuggestionKeyDownProps) {
|
||||
if (keyDown.event.key === "Escape") {
|
||||
setMenuState(EMPTY_ENTITY_REFERENCE_STATE);
|
||||
inputGate.reset();
|
||||
exitSuggestion(keyDown.view, EntityReferenceSuggestionPluginKey);
|
||||
return true;
|
||||
}
|
||||
return onKeyDown(keyDown.event);
|
||||
},
|
||||
onExit() {
|
||||
inputGate.reset();
|
||||
setMenuState(EMPTY_ENTITY_REFERENCE_STATE);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { EditorContent } from "@tiptap/react";
|
||||
import { exitSuggestion } from "@tiptap/suggestion";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
@@ -34,6 +36,7 @@ import type { MentionItem } from "@/hooks/use-inline-mention";
|
||||
import type { SlashCommand } from "./slash-command-types";
|
||||
import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
import { useEntityReferenceComposer } from "./use-entity-reference-composer";
|
||||
import { EntityReferenceSuggestionPluginKey } from "./tiptap-entity-reference-suggestion";
|
||||
import type { ImagePasteIssue } from "./clipboard-attachments";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -351,6 +354,15 @@ function useMenuHandlers() {
|
||||
};
|
||||
}
|
||||
|
||||
function useEntityReferenceMenuClose(editor: Editor | null, close: () => void) {
|
||||
return useCallback(() => {
|
||||
if (editor) {
|
||||
exitSuggestion(editor.view, EntityReferenceSuggestionPluginKey);
|
||||
}
|
||||
close();
|
||||
}, [editor, close]);
|
||||
}
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────
|
||||
|
||||
export const TipTapInput = forwardRef<TipTapInputHandle, TipTapInputProps>(function TipTapInput(
|
||||
@@ -412,6 +424,8 @@ export const TipTapInput = forwardRef<TipTapInputHandle, TipTapInputProps>(funct
|
||||
mentionSuggestion,
|
||||
slashSuggestion,
|
||||
entityReferenceSuggestion: entityReferences.suggestion,
|
||||
onTextInput: entityReferences.onTextInput,
|
||||
onBeforeInput: entityReferences.onBeforeInput,
|
||||
slashCommands,
|
||||
isSuggestionMenuOpen,
|
||||
getHistory,
|
||||
@@ -420,6 +434,7 @@ export const TipTapInput = forwardRef<TipTapInputHandle, TipTapInputProps>(funct
|
||||
ref,
|
||||
});
|
||||
const { closeReverseSearch } = overlay;
|
||||
const closeEntityReferenceMenu = useEntityReferenceMenuClose(editor, entityReferences.close);
|
||||
const handleReverseSearchSelect = useCallback(
|
||||
(index: number) => {
|
||||
applyHistoryEntry(index);
|
||||
@@ -437,6 +452,7 @@ export const TipTapInput = forwardRef<TipTapInputHandle, TipTapInputProps>(funct
|
||||
history={history}
|
||||
isDraining={isDraining}
|
||||
onReverseSearchSelect={handleReverseSearchSelect}
|
||||
onEntityReferenceClose={closeEntityReferenceMenu}
|
||||
/>
|
||||
<EditorContextProvider value={{ sessionId, taskId: taskId ?? null }}>
|
||||
<div ref={editorWrapperRef} className="h-full">
|
||||
@@ -457,6 +473,7 @@ type TipTapPopupsProps = {
|
||||
history: readonly MessageHistoryEntry[];
|
||||
isDraining: boolean;
|
||||
onReverseSearchSelect: (index: number) => void;
|
||||
onEntityReferenceClose: () => void;
|
||||
};
|
||||
|
||||
function TipTapPopups({
|
||||
@@ -466,6 +483,7 @@ function TipTapPopups({
|
||||
history,
|
||||
isDraining,
|
||||
onReverseSearchSelect,
|
||||
onEntityReferenceClose,
|
||||
}: TipTapPopupsProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -490,7 +508,7 @@ function TipTapPopups({
|
||||
error={entityReferences.error}
|
||||
onRetry={entityReferences.retry}
|
||||
onSelect={entityReferences.selectReference}
|
||||
onClose={entityReferences.close}
|
||||
onClose={onEntityReferenceClose}
|
||||
setSelectedIndex={entityReferences.setSelectedIndex}
|
||||
/>
|
||||
<SlashCommandMenu
|
||||
|
||||
@@ -2,8 +2,10 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { MentionItem } from "@/hooks/use-inline-mention";
|
||||
import type { EntityReference } from "@/lib/types/entity-reference";
|
||||
import {
|
||||
createEntityReferenceInputGate,
|
||||
createEntityReferenceSuggestion,
|
||||
handleEntityReferenceMenuKeyDown,
|
||||
isEntityReferenceQueryAllowed,
|
||||
} from "./tiptap-entity-reference-suggestion";
|
||||
import { createMentionSuggestion } from "./tiptap-suggestion";
|
||||
import * as entityReferenceSuggestions from "./tiptap-entity-reference-suggestion";
|
||||
@@ -79,6 +81,113 @@ describe("entity reference suggestion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("entity reference trigger gating", () => {
|
||||
it("starts only after direct # input and keeps a manually typed query active", () => {
|
||||
const gate = createEntityReferenceInputGate();
|
||||
const transaction = { getMeta: () => undefined };
|
||||
|
||||
gate.recordTextInput(1, 1, "#");
|
||||
expect(gate.shouldShow({ from: 1, to: 2 }, transaction)).toBe(true);
|
||||
gate.recordTextInput(2, 2, "auth issue");
|
||||
expect(gate.shouldShow({ from: 1, to: 9 }, transaction)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a direct trigger to include text that was already present", () => {
|
||||
const gate = createEntityReferenceInputGate();
|
||||
const transaction = { getMeta: () => undefined };
|
||||
|
||||
gate.recordTextInput(1, 1, "#");
|
||||
expect(gate.shouldShow({ from: 1, to: 8 }, transaction)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not start or continue a suggestion from pasted or dropped text", () => {
|
||||
const gate = createEntityReferenceInputGate();
|
||||
const transaction = {
|
||||
getMeta: (key: string) => (key === "uiEvent" ? "paste" : undefined),
|
||||
};
|
||||
|
||||
gate.recordTextInput(1, 1, "#");
|
||||
expect(gate.shouldShow({ from: 1, to: 2 }, transaction)).toBe(false);
|
||||
expect(gate.shouldShow({ from: 1, to: 8 }, { getMeta: () => undefined })).toBe(false);
|
||||
|
||||
gate.recordTextInput(1, 1, "#");
|
||||
expect(
|
||||
gate.shouldShow(
|
||||
{ from: 1, to: 2 },
|
||||
{ getMeta: (key: string) => (key === "uiEvent" ? "drop" : undefined) },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an active range while direct deletion changes the query", () => {
|
||||
const gate = createEntityReferenceInputGate();
|
||||
const transaction = { docChanged: true, getMeta: () => undefined };
|
||||
|
||||
gate.recordTextInput(1, 1, "#");
|
||||
expect(gate.shouldShow({ from: 1, to: 2 }, transaction)).toBe(true);
|
||||
|
||||
gate.recordDeletion();
|
||||
expect(gate.shouldShow({ from: 1, to: 2 }, transaction)).toBe(true);
|
||||
|
||||
gate.recordTextInput(2, 2, "a");
|
||||
expect(gate.shouldShow({ from: 1, to: 3 }, transaction)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an active range for a non-document transaction", () => {
|
||||
const gate = createEntityReferenceInputGate();
|
||||
const transaction = { getMeta: () => undefined };
|
||||
|
||||
gate.recordTextInput(1, 1, "#");
|
||||
expect(gate.shouldShow({ from: 1, to: 2 }, transaction)).toBe(true);
|
||||
expect(gate.shouldShow({ from: 1, to: 2 }, transaction)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a space after a bare # but allows spaces in a query", () => {
|
||||
expect(isEntityReferenceQueryAllowed("")).toBe(true);
|
||||
expect(isEntityReferenceQueryAllowed(" ")).toBe(false);
|
||||
expect(isEntityReferenceQueryAllowed("auth issue")).toBe(true);
|
||||
});
|
||||
|
||||
it("closes a bare trigger on space while keeping multi-word queries active", () => {
|
||||
const gate = createEntityReferenceInputGate();
|
||||
const suggestion = createEntityReferenceSuggestion(vi.fn(), vi.fn(), gate);
|
||||
const transaction = { docChanged: true, getMeta: () => undefined } as never;
|
||||
|
||||
gate.recordTextInput(1, 1, "#");
|
||||
expect(
|
||||
suggestion.shouldShow?.({
|
||||
editor: {} as never,
|
||||
range: { from: 1, to: 2 },
|
||||
query: "",
|
||||
text: "#",
|
||||
transaction,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
gate.recordTextInput(2, 2, " ");
|
||||
expect(
|
||||
suggestion.shouldShow?.({
|
||||
editor: {} as never,
|
||||
range: { from: 1, to: 3 },
|
||||
query: " ",
|
||||
text: "# ",
|
||||
transaction,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
gate.recordTextInput(3, 3, "#auth issue");
|
||||
expect(
|
||||
suggestion.shouldShow?.({
|
||||
editor: {} as never,
|
||||
range: { from: 3, to: 14 },
|
||||
query: "auth issue",
|
||||
text: "#auth issue",
|
||||
transaction,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entity reference suggestion lifecycle", () => {
|
||||
it("replaces only the active # range with an atom and trailing space", () => {
|
||||
const suggestion = createEntityReferenceSuggestion(vi.fn(), vi.fn());
|
||||
@@ -116,6 +225,8 @@ describe("entity reference suggestion lifecycle", () => {
|
||||
const setMenuState = vi.fn();
|
||||
const suggestion = createEntityReferenceSuggestion(setMenuState, vi.fn());
|
||||
const lifecycle = suggestion.render?.();
|
||||
const transaction = { setMeta: vi.fn(() => ({ id: "exit" })) };
|
||||
const dispatch = vi.fn();
|
||||
const props = {
|
||||
editor: {} as never,
|
||||
range: { from: 1, to: 2 },
|
||||
@@ -134,7 +245,7 @@ describe("entity reference suggestion lifecycle", () => {
|
||||
);
|
||||
|
||||
const handled = lifecycle?.onKeyDown?.({
|
||||
view: {} as never,
|
||||
view: { state: { tr: transaction }, dispatch } as never,
|
||||
event: new KeyboardEvent("keydown", { key: "Escape" }),
|
||||
range: props.range,
|
||||
});
|
||||
@@ -147,6 +258,8 @@ describe("entity reference suggestion lifecycle", () => {
|
||||
command: null,
|
||||
});
|
||||
expect(props.command).not.toHaveBeenCalled();
|
||||
expect(transaction.setMeta).toHaveBeenCalledWith(expect.anything(), { exit: true });
|
||||
expect(dispatch).toHaveBeenCalledWith({ id: "exit" });
|
||||
});
|
||||
|
||||
it("allows # at a text-block boundary but rejects tokens and code", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
||||
import { useEntityReferenceSearch } from "@/hooks/use-entity-reference-search";
|
||||
import type { EntityReference } from "@/lib/types/entity-reference";
|
||||
import {
|
||||
createEntityReferenceInputGate,
|
||||
createEntityReferenceSuggestion,
|
||||
handleEntityReferenceMenuKeyDown,
|
||||
} from "./tiptap-entity-reference-suggestion";
|
||||
@@ -29,6 +30,7 @@ export function useEntityReferenceComposer({
|
||||
workspaceId,
|
||||
sessionId,
|
||||
}: UseEntityReferenceComposerOptions) {
|
||||
const inputGate = useMemo(() => createEntityReferenceInputGate(), []);
|
||||
const [menu, setMenu] = useState<MenuState<EntityReference>>(EMPTY_REFERENCE_MENU);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const searchEnabled =
|
||||
@@ -56,8 +58,9 @@ export function useEntityReferenceComposer({
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
inputGate.reset();
|
||||
void Promise.resolve().then(() => setMenu(EMPTY_REFERENCE_MENU));
|
||||
}, [enabled, sessionId, workspaceId]);
|
||||
}, [enabled, inputGate, sessionId, workspaceId]);
|
||||
|
||||
const selectReference = useCallback((reference: EntityReference) => {
|
||||
commandRef.current?.(reference);
|
||||
@@ -75,8 +78,8 @@ export function useEntityReferenceComposer({
|
||||
[selectReference],
|
||||
);
|
||||
const suggestion = useMemo(
|
||||
() => createEntityReferenceSuggestion(setMenu, onKeyDown),
|
||||
[onKeyDown],
|
||||
() => createEntityReferenceSuggestion(setMenu, onKeyDown, inputGate),
|
||||
[inputGate, onKeyDown],
|
||||
);
|
||||
const close = useCallback(() => setMenu(EMPTY_REFERENCE_MENU), []);
|
||||
const hasWorkspace = Boolean(workspaceId);
|
||||
@@ -94,5 +97,9 @@ export function useEntityReferenceComposer({
|
||||
setSelectedIndex,
|
||||
selectReference,
|
||||
close,
|
||||
onTextInput: inputGate.recordTextInput,
|
||||
onBeforeInput: (inputType: string) => {
|
||||
if (inputType.startsWith("delete")) inputGate.recordDeletion();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ type UseTipTapEditorOptions = {
|
||||
onBlur?: () => void;
|
||||
sessionId: string | null;
|
||||
onImagePaste?: (files: File[], issue?: ImagePasteIssue) => void;
|
||||
onTextInput?: (from: number, to: number, text: string) => void;
|
||||
onBeforeInput?: (inputType: string) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mentionSuggestion: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -116,6 +118,8 @@ function useTipTapRefs(opts: UseTipTapEditorOptions) {
|
||||
const disabledRef = useRef(opts.disabled);
|
||||
const onChangeRef = useRef(opts.onChange);
|
||||
const onImagePasteRef = useRef(opts.onImagePaste);
|
||||
const onTextInputRef = useRef(opts.onTextInput);
|
||||
const onBeforeInputRef = useRef(opts.onBeforeInput);
|
||||
const sessionIdRef = useRef(opts.sessionId);
|
||||
const planModeEnabledRef = useRef(opts.planModeEnabled);
|
||||
const onPlanModeChangeRef = useRef(opts.onPlanModeChange);
|
||||
@@ -133,6 +137,8 @@ function useTipTapRefs(opts: UseTipTapEditorOptions) {
|
||||
disabledRef.current = opts.disabled;
|
||||
onChangeRef.current = opts.onChange;
|
||||
onImagePasteRef.current = opts.onImagePaste;
|
||||
onTextInputRef.current = opts.onTextInput;
|
||||
onBeforeInputRef.current = opts.onBeforeInput;
|
||||
sessionIdRef.current = opts.sessionId;
|
||||
planModeEnabledRef.current = opts.planModeEnabled;
|
||||
onPlanModeChangeRef.current = opts.onPlanModeChange;
|
||||
@@ -149,6 +155,8 @@ function useTipTapRefs(opts: UseTipTapEditorOptions) {
|
||||
disabledRef,
|
||||
onChangeRef,
|
||||
onImagePasteRef,
|
||||
onTextInputRef,
|
||||
onBeforeInputRef,
|
||||
sessionIdRef,
|
||||
planModeEnabledRef,
|
||||
onPlanModeChangeRef,
|
||||
@@ -204,6 +212,8 @@ function buildEditorProps(args: {
|
||||
onFocus: (() => void) | undefined;
|
||||
onBlur: (() => void) | undefined;
|
||||
onImagePasteRef: React.RefObject<((files: File[], issue?: ImagePasteIssue) => void) | undefined>;
|
||||
onTextInputRef: React.RefObject<((from: number, to: number, text: string) => void) | undefined>;
|
||||
onBeforeInputRef: React.RefObject<((inputType: string) => void) | undefined>;
|
||||
}) {
|
||||
return {
|
||||
attributes: {
|
||||
@@ -220,6 +230,15 @@ function buildEditorProps(args: {
|
||||
},
|
||||
handlePaste: (view: import("@tiptap/pm/view").EditorView, event: ClipboardEvent) =>
|
||||
handleEditorPaste(view, event, args.onImagePasteRef),
|
||||
handleTextInput: (
|
||||
_view: import("@tiptap/pm/view").EditorView,
|
||||
from: number,
|
||||
to: number,
|
||||
text: string,
|
||||
) => {
|
||||
args.onTextInputRef.current?.(from, to, text);
|
||||
return false;
|
||||
},
|
||||
handleDOMEvents: {
|
||||
focus: () => {
|
||||
args.onFocus?.();
|
||||
@@ -229,6 +248,11 @@ function buildEditorProps(args: {
|
||||
args.onBlur?.();
|
||||
return false;
|
||||
},
|
||||
beforeinput: (_view: import("@tiptap/pm/view").EditorView, event: Event) => {
|
||||
const inputType = (event as InputEvent).inputType;
|
||||
if (inputType?.startsWith("delete")) args.onBeforeInputRef.current?.(inputType);
|
||||
return false;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -271,6 +295,8 @@ export function useTipTapEditor(opts: UseTipTapEditorOptions) {
|
||||
onFocus: opts.onFocus,
|
||||
onBlur: opts.onBlur,
|
||||
onImagePasteRef: refs.onImagePasteRef,
|
||||
onTextInputRef: refs.onTextInputRef,
|
||||
onBeforeInputRef: refs.onBeforeInputRef,
|
||||
}),
|
||||
onUpdate: ({ editor: e }) => {
|
||||
if (isSyncingRef.current || !initialSyncDoneRef.current) return;
|
||||
|
||||
@@ -439,4 +439,66 @@ test.describe("Entity reference composer", () => {
|
||||
await expect(testPage.getByTestId("entity-reference-menu")).toHaveCount(0);
|
||||
await expect(editor).toHaveText("#Literal passthrough reference");
|
||||
});
|
||||
|
||||
test("pasted hash text stays literal and dismissed entity reference stays closed", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const target = linearReference("linear-lifecycle", "ENG-401", "Lifecycle Reference Target");
|
||||
const pastedText = "#2730 explicit profile overridden seems an issue";
|
||||
const active = await createReadyTask(apiClient, seedData, "Entity Reference Lifecycle Task");
|
||||
const session = await openTaskChat(testPage, active.id);
|
||||
const editor = visibleEditor(session.activeChat());
|
||||
const menu = testPage.getByTestId("entity-reference-menu");
|
||||
|
||||
await testPage.route("**/api/v1/workspaces/*/mentions/search?*", async (route) => {
|
||||
const query = new URL(route.request().url()).searchParams.get("q") ?? "";
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(externalSearchResponse(query, [target])),
|
||||
});
|
||||
});
|
||||
await testPage.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await testPage.evaluate(async (text) => navigator.clipboard.writeText(text), pastedText);
|
||||
|
||||
const searchObserved = testPage
|
||||
.waitForRequest((request) => new URL(request.url()).pathname.endsWith("/mentions/search"), {
|
||||
timeout: 750,
|
||||
})
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
await editor.click();
|
||||
await editor.press("Control+V");
|
||||
|
||||
expect(await searchObserved).toBe(false);
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(editor).toHaveText(pastedText);
|
||||
|
||||
await editor.pressSequentially(" #E2E");
|
||||
await expect(referenceOption(testPage, target.title)).toBeVisible({ timeout: 10_000 });
|
||||
await editor.press("Backspace");
|
||||
await expect(referenceOption(testPage, target.title)).toBeVisible({ timeout: 10_000 });
|
||||
await editor.pressSequentially("E");
|
||||
await editor.press("ArrowLeft");
|
||||
await editor.press("Delete");
|
||||
await expect(referenceOption(testPage, target.title)).toBeVisible({ timeout: 10_000 });
|
||||
await editor.pressSequentially("E");
|
||||
await editor.press("Escape");
|
||||
await expect(menu).toHaveCount(0);
|
||||
await editor.pressSequentially(" continued");
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(editor).toHaveText(
|
||||
/#2730 explicit profile overridden seems an issue #E2E continued/,
|
||||
);
|
||||
|
||||
await editor.pressSequentially(" #Late");
|
||||
await expect(referenceOption(testPage, target.title)).toBeVisible({ timeout: 10_000 });
|
||||
await testPage.locator("body").click({ position: { x: 4, y: 4 } });
|
||||
await expect(menu).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
spec: docs/specs/ui/entity-reference-composer.md
|
||||
created: 2026-08-17
|
||||
status: completed
|
||||
---
|
||||
|
||||
# Implementation Plan: Entity Reference Trigger Lifecycle
|
||||
|
||||
## Overview
|
||||
|
||||
Restrict the `#` menu to direct user entry and connect all dismiss actions to the TipTap suggestion state. Preserve multi-word search and result selection.
|
||||
|
||||
## Confirmed root cause
|
||||
|
||||
`createEntityReferenceSuggestion` accepts every document transaction that leaves a valid `#query` before the cursor. It does not identify the input source.
|
||||
|
||||
As a result, a paste transaction can start the suggestion. An isolated Chromium test reproduced this behavior with `#2730 explicit profile overridden seems an issue`.
|
||||
|
||||
The test expected zero `entity-reference-menu` elements. The current code kept one element mounted.
|
||||
|
||||
The Escape handler clears only React menu state and returns `true`. TipTap 3.19 treats this return value as proof that the consumer also cleared suggestion state.
|
||||
|
||||
The consumer does not clear that state. The outside-close callback has the same split state. A later editor transaction can show the menu again.
|
||||
|
||||
The suggestion also uses `allowSpaces: true` for multi-word queries. This option keeps a bare `# ` range active unless Kandev rejects leading query whitespace.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Input-origin gate
|
||||
|
||||
- Add a small origin gate for the entity-reference suggestion.
|
||||
- Observe direct text input without consuming the editor event.
|
||||
- Start a closed suggestion only for a new `#` from direct text input.
|
||||
- Continue an existing active query across later direct input transactions.
|
||||
- Reject paste, drop, draft restore, history recall, and programmatic content updates.
|
||||
- Reject whitespace as the first query character. Preserve spaces after the first query character.
|
||||
|
||||
The gate stays local to the `#` entity-reference path. It does not change the `@` mention or `/` command paths.
|
||||
|
||||
### One dismissal path
|
||||
|
||||
- Use the supported TipTap `exitSuggestion` API with `EntityReferenceSuggestionPluginKey`.
|
||||
- Route Escape and popup outside-close actions through this path.
|
||||
- Clear the React menu state from the suggestion `onExit` callback.
|
||||
- Keep the draft, cursor focus, result selection, and submit configuration unchanged.
|
||||
- Permit a later direct `#` entry after the dismissed range ends.
|
||||
|
||||
### Mobile design contract
|
||||
|
||||
Desktop and phone use the same TipTap editor and suggestion state. This fix does not change the menu layout, touch targets, scroll owner, or viewport rules.
|
||||
|
||||
The existing mobile entity-reference composer is the nearest mobile example. It supplies touch selection, viewport containment, internal scrolling, and 44 px rows.
|
||||
|
||||
No mobile-specific presentation is necessary. Re-run the existing mobile test to verify selection and geometry after the shared lifecycle change.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** Direct `#` input starts a closed suggestion. Paste, drop, and programmatic input do not start it.
|
||||
- **File:** `apps/web/components/task/chat/tiptap-suggestion.test.ts` or a focused sibling test.
|
||||
- **How:** Use a pure input-origin gate with explicit transaction and input sequences.
|
||||
- **What:** Escape and outside-close exit both the TipTap state and React state.
|
||||
- **File:** `apps/web/components/task/chat/tiptap-suggestion.test.ts` and `apps/web/components/task/chat/use-entity-reference-composer.test.ts`.
|
||||
- **How:** Assert the plugin exit call, the closed state, continued literal input, and a later valid trigger.
|
||||
- **What:** A bare `#` followed by space closes, while `#multi word` stays active.
|
||||
- **File:** `apps/web/components/task/chat/tiptap-suggestion.test.ts`.
|
||||
- **How:** Use table-driven query-range cases.
|
||||
|
||||
The browser regression must fail before the production change. It must pass after the change.
|
||||
|
||||
## E2E tests
|
||||
|
||||
- **Scenario:** The user pastes `#2730 explicit profile overridden seems an issue` into task chat.
|
||||
- **File:** `apps/web/e2e/tests/chat/entity-reference-composer.spec.ts`.
|
||||
- **What to verify:** The text stays literal. The menu does not open. No mention-search request starts.
|
||||
- **Scenario:** The user types a valid query, presses Escape, and continues to type in the same range.
|
||||
- **File:** `apps/web/e2e/tests/chat/entity-reference-composer.spec.ts`.
|
||||
- **What to verify:** The menu closes, stays closed, and does not change the draft. A later valid trigger can open the menu.
|
||||
- **Mobile regression:** Re-run `apps/web/e2e/tests/chat/mobile-entity-reference-composer.spec.ts`.
|
||||
- **What to verify:** Touch selection, menu containment, scrolling, and the submitted reference remain unchanged.
|
||||
|
||||
## Verification results
|
||||
|
||||
The diagnostic E2E run failed as expected before production changes. The
|
||||
managed runner removed its isolated processes. After implementation, the
|
||||
focused desktop regression passed, the existing mobile regression passed, 41
|
||||
focused unit tests passed, type checking passed, lint passed with zero
|
||||
warnings, the internationalization check passed, and `git diff --check`
|
||||
passed. A PR review fixup also covered direct Backspace/Delete edits inside an
|
||||
active query and direct `#` insertion before existing text.
|
||||
|
||||
## Implementation waves
|
||||
|
||||
- [x] [Task 01: Correct the entity-reference trigger lifecycle](task-01-correct-trigger-lifecycle.md)
|
||||
|
||||
Execution is sequential in the primary conversation. No subagents are authorized.
|
||||
|
||||
## Risks and boundaries
|
||||
|
||||
- Browser input events and ProseMirror transactions have different lifecycles. The origin gate must observe input without blocking normal insertion.
|
||||
- Mobile virtual keyboards and input-method editors can use text input without a physical key event. The gate must not depend on `keydown` alone.
|
||||
- Escape and outside-close can dispatch a metadata-only transaction. The code must not dispatch twice or restore a stale menu state.
|
||||
- Multi-word title search requires spaces after the first query character.
|
||||
- This fix does not change backend search, provider results, reference persistence, `@` mentions, or `/` commands.
|
||||
- This fix does not convert pasted keys into reference chips.
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
id: "01-correct-trigger-lifecycle"
|
||||
title: "Correct entity-reference trigger lifecycle"
|
||||
status: completed
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/entity-reference-composer.md"
|
||||
---
|
||||
|
||||
# Task 01: Correct Entity-Reference Trigger Lifecycle
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Direct user entry of a valid `#` opens the menu. Paste, drop, restored drafts, history recall, and programmatic updates do not open it.
|
||||
- Escape and popup outside-close exit the TipTap suggestion state. Continued input in the dismissed range does not reopen the menu.
|
||||
- A bare `#` followed by space becomes literal. Multi-word queries and Enter or Tab selection remain unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
1. If dependencies are absent, run `cd apps && pnpm install --frozen-lockfile`.
|
||||
2. RED: Run `cd apps/web && pnpm e2e:run tests/chat/entity-reference-composer.spec.ts -- --grep "pasted hash text" --retries=0` before production changes.
|
||||
3. Unit: Run `cd apps && pnpm --filter @kandev/web test -- components/task/chat/tiptap-suggestion.test.ts components/task/chat/use-entity-reference-composer.test.ts components/task/chat/use-tiptap-editor.test.ts`.
|
||||
4. Type check: Run `cd apps/web && pnpm run typecheck`.
|
||||
5. Desktop GREEN: Run `cd apps/web && pnpm e2e:run tests/chat/entity-reference-composer.spec.ts -- --grep "pasted hash text|dismissed entity reference" --retries=0`.
|
||||
6. Mobile regression: Run `cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-entity-reference-composer.spec.ts -- --retries=0`.
|
||||
7. Repository check: Run `git diff --check` from the repository root.
|
||||
|
||||
Verify that Playwright discovers the expected tests before you use either browser command as evidence. The managed runner builds and removes its isolated processes.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task/chat/tiptap-entity-reference-suggestion.ts`
|
||||
- `apps/web/components/task/chat/use-entity-reference-composer.ts`
|
||||
- `apps/web/components/task/chat/tiptap-input.tsx`
|
||||
- `apps/web/components/task/chat/use-tiptap-editor.ts`
|
||||
- `apps/web/components/task/chat/tiptap-suggestion.test.ts`
|
||||
- `apps/web/components/task/chat/use-entity-reference-composer.test.ts`
|
||||
- `apps/web/components/task/chat/use-tiptap-editor.test.ts`
|
||||
- `apps/web/e2e/tests/chat/entity-reference-composer.spec.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. The input gate, dismissal state, and browser regression form one TDD slice.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Behavioral contract: `docs/specs/ui/entity-reference-composer.md`.
|
||||
- Root-cause trace and frontend design: `plan.md`.
|
||||
- TipTap lifecycle code: `apps/web/components/task/chat/tiptap-entity-reference-suggestion.ts`.
|
||||
- Editor input path: `apps/web/components/task/chat/use-tiptap-editor.ts`.
|
||||
- Existing browser coverage: `apps/web/e2e/tests/chat/entity-reference-composer.spec.ts`.
|
||||
- Mobile regression: `apps/web/e2e/tests/chat/mobile-entity-reference-composer.spec.ts`.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the RED result, implementation summary, changed files, exact test results, cleanup evidence, blockers, and risks. Update this task and `plan.md`.
|
||||
|
||||
## Results
|
||||
|
||||
- RED unit run: the new input-gate assertions failed because the gate and
|
||||
suggestion-plugin exit path were not implemented yet.
|
||||
- Unit: 41 tests passed across the three focused TipTap test files.
|
||||
- Type check: `pnpm run typecheck` passed.
|
||||
- Lint: `pnpm run lint` passed with zero warnings.
|
||||
- Internationalization: `pnpm run i18n:check` passed.
|
||||
- Desktop E2E: the pasted-hash and dismissed-menu regression passed.
|
||||
- Mobile E2E: the existing entity-reference composer test passed.
|
||||
- PR fixup: direct Backspace/Delete edits keep the active menu open, and a
|
||||
direct `#` before existing text remains eligible for a suggestion.
|
||||
- Repository check: `git diff --check` passed.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
status: shipped
|
||||
created: 2026-07-21
|
||||
updated: 2026-08-08
|
||||
updated: 2026-08-17
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
@@ -15,6 +15,7 @@ Users discuss external work items in agent chat, but plain ticket keys and pull-
|
||||
|
||||
- Task chat and Quick Chat support a `#` entity-reference trigger in their shared TipTap composer. Passthrough, task creation, comments, plans, Office text inputs, and other editors remain unchanged.
|
||||
- Typing `#` at the start of a text block or after whitespace opens a search menu directly above the composer. Its rendered bottom edge stays anchored to the composer even when only a short result set is visible. A `#` inside another token or a code block remains literal text.
|
||||
- A trigger starts only when the user enters a new `#` character. Pasted, dropped, restored, or programmatically inserted text remains literal and does not open the menu.
|
||||
- After the user types at least one query character, search covers the active workspace's connected, searchable sources:
|
||||
- Jira tickets;
|
||||
- Linear issues;
|
||||
@@ -28,7 +29,10 @@ Users discuss external work items in agent chat, but plain ticket keys and pull-
|
||||
- Search is debounced, cancels or ignores stale requests, and caps work per provider and configured workspace/project scope. Each connected provider continues examining available in-scope results until it finds the requested number of matches, has no more results, reaches a search bound, or the request is canceled. One disconnected, slow, rate-limited, or failing provider never hides successful results from other providers.
|
||||
- Cancellation of a superseded search is a normal client outcome. The backend
|
||||
does not report it as an internal server failure.
|
||||
- Arrow Up and Arrow Down move the active result. Tab or Enter selects it. Pointer or touch selection has the same behavior. Escape closes the menu without changing or sending the draft.
|
||||
- Arrow Up and Arrow Down move the active result. Tab or Enter selects it. Pointer or touch selection has the same behavior.
|
||||
- Escape closes the menu without changing or sending the draft. Continued input in the dismissed range cannot reopen it. A new valid `#` entry can open it again.
|
||||
- A space immediately after a bare `#` closes the menu and leaves the text literal. Spaces after a query character remain part of a multi-word search.
|
||||
- If no result is selectable, Enter follows the configured composer behavior. The menu closes when the cursor leaves the trigger range.
|
||||
- Selection replaces only the active `#query` range with an inline reference chip, appends a trailing space, keeps focus in the composer, and never submits or queues the message.
|
||||
- A chip displays a stable key when one exists (for example `#ENG-123` or `#123`) and otherwise a concise title. It retains a title snapshot and source label for disambiguation.
|
||||
- Submitted references remain clickable in sent user messages through a canonical HTTP(S) URL whose origin matches the workspace's validated provider configuration.
|
||||
@@ -126,7 +130,13 @@ Message responses and queue entries expose the validated array through `metadata
|
||||
4. **Results** — one or more provider groups contain selectable results; partial provider failures may also be shown non-disruptively.
|
||||
5. **Empty** — all successful providers returned no hits and no request is in flight.
|
||||
|
||||
Typing updates Primed/Searching/Results/Empty. Escape, moving outside the trigger range, selection, session change, or composer unmount returns to Closed. A response whose query generation is no longer current has no state transition.
|
||||
Only direct entry of a valid `#` moves Closed to Primed. Paste, drop, draft restore, history recall, and programmatic content changes keep the menu Closed.
|
||||
|
||||
Typing updates Primed/Searching/Results/Empty. Escape dismisses the active trigger and returns to Closed. Later input in that range cannot reopen it.
|
||||
|
||||
Moving outside the trigger range, entering space after a bare `#`, selection, session change, or composer unmount returns to Closed. A new valid `#` can start a new trigger.
|
||||
|
||||
A response whose query generation is no longer current has no state transition.
|
||||
|
||||
## Permissions
|
||||
|
||||
@@ -159,6 +169,11 @@ Typing updates Primed/Searching/Results/Empty. Escape, moving outside the trigge
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** task chat in workspace A with connected Jira and GitHub providers, **WHEN** the user types `#auth`, **THEN** the menu above the composer shows grouped workspace-A Jira and GitHub hits without Kandev tasks or workspace-B results.
|
||||
- **GIVEN** a draft that contains `#2730 explicit profile overridden`, **WHEN** the user pastes the draft into task chat, **THEN** the text remains literal and no entity-reference menu or search request starts.
|
||||
- **GIVEN** a closed menu after pasted, restored, or programmatically inserted hash text, **WHEN** the user types a new valid `#auth` trigger, **THEN** the menu opens for the new trigger.
|
||||
- **GIVEN** an open entity-reference menu, **WHEN** the user presses Escape and continues to type in the same range, **THEN** the draft stays unchanged and the menu stays closed.
|
||||
- **GIVEN** a primed menu for a bare `#`, **WHEN** the user types a space, **THEN** the menu closes and the literal `# ` remains in the draft.
|
||||
- **GIVEN** an active multi-word query and no selectable result, **WHEN** the user presses Enter, **THEN** the composer follows its configured Enter behavior and the menu does not stay active outside the trigger range.
|
||||
- **GIVEN** a search returns only one short result group, **WHEN** the menu renders, **THEN** its bottom edge remains directly anchored to the composer instead of reserving unused menu height.
|
||||
- **GIVEN** GitLab is not configured or cannot search the active workspace, **WHEN** another source returns results, **THEN** the menu omits GitLab rather than showing an unavailable GitLab section.
|
||||
- **GIVEN** Quick Chat and a matching Linear issue, **WHEN** the user presses Arrow Down and Tab, **THEN** the active range becomes one inline reference chip, focus stays in the composer, and no message is sent.
|
||||
@@ -196,7 +211,11 @@ Typing updates Primed/Searching/Results/Empty. Escape, moving outside the trigge
|
||||
- Raw provider query-language syntax, advanced filters, user-controlled result pagination, or a full-screen global search page.
|
||||
- Implementing the plugin manifest contribution, workspace permission/grant, Kandev-to-plugin search RPC, or loading plugin-provided sources in this release. The normalized registry and DTO MUST remain transport-neutral so a later plugin bridge can implement the same provider contract without changing composer/message formats. Search-provider contribution is distinct from the plugin-to-Kandev Host data API and its `api_read` capabilities.
|
||||
- Cross-workspace search.
|
||||
- Changing the Enter or Tab selection behavior when a result is selectable.
|
||||
- Removing spaces from valid multi-word work-item searches.
|
||||
|
||||
## Implementation plan
|
||||
## Implementation plans
|
||||
|
||||
[Backend failure containment](../../plans/backend-failure-containment/plan.md)
|
||||
- [Entity reference composer](../../plans/entity-reference-composer/plan.md)
|
||||
- [Backend failure containment](../../plans/backend-failure-containment/plan.md)
|
||||
- [Entity reference trigger lifecycle](../../plans/entity-reference-trigger-lifecycle/plan.md)
|
||||
|
||||
Reference in New Issue
Block a user