relayLeadInboxMessages only processes unread messages after provisioningComplete, but CLI marks permission_request messages as read after native delivery -- before our relay runs. Move permission_request inbox scan BEFORE provisioningComplete check. Scan ALL messages (including read=true), track processed IDs via processedPermissionRequestIds Set on ProvisioningRun to prevent re-emitting. Also look up both alive and provisioning runs so the scan works during team bootstrap.
375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
import { type Dispatch, type SetStateAction, useCallback, useRef, useState } from 'react';
|
|
|
|
import {
|
|
getSuggestionInsertionText,
|
|
getSuggestionTriggerChar,
|
|
} from '@renderer/utils/mentionSuggestions';
|
|
|
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
|
|
|
interface UseMentionDetectionOptions {
|
|
value: string;
|
|
onValueChange: (v: string) => void;
|
|
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
|
/** Supported trigger characters, e.g. ['@', '#'] */
|
|
triggerChars?: string[];
|
|
/** Enable or disable individual triggers dynamically. */
|
|
isTriggerEnabled?: (triggerChar: string) => boolean;
|
|
/** Additional validation for trigger matches before opening the dropdown. */
|
|
isTriggerMatchValid?: (trigger: MentionTrigger, text: string) => boolean;
|
|
}
|
|
|
|
export interface DropdownPosition {
|
|
top: number;
|
|
left: number;
|
|
}
|
|
|
|
interface UseMentionDetectionResult {
|
|
isOpen: boolean;
|
|
activeTriggerChar: string | null;
|
|
query: string;
|
|
selectedIndex: number;
|
|
setSelectedIndex: Dispatch<SetStateAction<number>>;
|
|
dropdownPosition: DropdownPosition | null;
|
|
selectSuggestion: (s: MentionSuggestion) => void;
|
|
dismiss: () => void;
|
|
handleKeyDown: (
|
|
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
|
suggestionCount: number,
|
|
onSelectSuggestion: (index: number) => void
|
|
) => void;
|
|
handleChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
|
handleSelect: (e: React.SyntheticEvent<HTMLTextAreaElement>) => void;
|
|
/** Getter for trigger index — use at call time to avoid stale closure (returns -1 if no active trigger) */
|
|
getTriggerIndex: () => number;
|
|
}
|
|
|
|
interface MentionTrigger {
|
|
triggerIndex: number;
|
|
triggerChar: string;
|
|
query: string;
|
|
}
|
|
|
|
/**
|
|
* CSS properties to copy from textarea to mirror div for accurate caret measurement.
|
|
*/
|
|
const MIRROR_PROPS = [
|
|
'boxSizing',
|
|
'width',
|
|
'overflowX',
|
|
'overflowY',
|
|
'borderTopWidth',
|
|
'borderRightWidth',
|
|
'borderBottomWidth',
|
|
'borderLeftWidth',
|
|
'paddingTop',
|
|
'paddingRight',
|
|
'paddingBottom',
|
|
'paddingLeft',
|
|
'fontStyle',
|
|
'fontVariant',
|
|
'fontWeight',
|
|
'fontStretch',
|
|
'fontSize',
|
|
'lineHeight',
|
|
'fontFamily',
|
|
'textAlign',
|
|
'textTransform',
|
|
'textIndent',
|
|
'letterSpacing',
|
|
'wordSpacing',
|
|
] as const;
|
|
|
|
const MENTION_DROPDOWN_OFFSET_PX = 10;
|
|
|
|
/**
|
|
* Calculates caret coordinates relative to the textarea element
|
|
* using a mirror div technique.
|
|
*
|
|
* @param textarea - The textarea DOM element
|
|
* @param position - Caret position in text
|
|
* @param text - Text content (override textarea.value for pre-render accuracy)
|
|
*/
|
|
export function getCaretCoordinates(
|
|
textarea: HTMLTextAreaElement,
|
|
position: number,
|
|
text?: string
|
|
): { top: number; left: number; height: number } {
|
|
const content = text ?? textarea.value;
|
|
const computed = window.getComputedStyle(textarea);
|
|
|
|
const mirror = document.createElement('div');
|
|
mirror.style.position = 'absolute';
|
|
mirror.style.visibility = 'hidden';
|
|
mirror.style.whiteSpace = 'pre-wrap';
|
|
mirror.style.overflowWrap = 'break-word';
|
|
mirror.style.overflow = 'hidden';
|
|
|
|
for (const prop of MIRROR_PROPS) {
|
|
mirror.style.setProperty(prop, computed.getPropertyValue(prop));
|
|
}
|
|
|
|
mirror.textContent = content.substring(0, position);
|
|
|
|
const span = document.createElement('span');
|
|
span.textContent = content.substring(position) || '.';
|
|
mirror.appendChild(span);
|
|
|
|
document.body.appendChild(mirror);
|
|
|
|
const lineHeight = parseInt(computed.lineHeight) || parseInt(computed.fontSize) * 1.2;
|
|
const borderTop = parseInt(computed.borderTopWidth) || 0;
|
|
|
|
const coords = {
|
|
top: span.offsetTop + borderTop - textarea.scrollTop,
|
|
left: span.offsetLeft + (parseInt(computed.borderLeftWidth) || 0) - textarea.scrollLeft,
|
|
height: lineHeight,
|
|
};
|
|
|
|
document.body.removeChild(mirror);
|
|
return coords;
|
|
}
|
|
|
|
/**
|
|
* Scans backwards from cursor position to find an active trigger.
|
|
* Returns null if no valid trigger found.
|
|
*
|
|
* Rules:
|
|
* - trigger must be at start of text or preceded by whitespace
|
|
* - Text between trigger and cursor must not contain spaces
|
|
*/
|
|
export function findMentionTrigger(
|
|
text: string,
|
|
cursorPos: number,
|
|
triggerChars: string[] = ['@']
|
|
): MentionTrigger | null {
|
|
if (cursorPos <= 0) return null;
|
|
|
|
const beforeCursor = text.slice(0, cursorPos);
|
|
const allowedTriggerChars = new Set(triggerChars);
|
|
|
|
// Scan backwards to find @
|
|
for (let i = beforeCursor.length - 1; i >= 0; i--) {
|
|
const char = beforeCursor[i];
|
|
|
|
// If we hit whitespace or newline before finding a trigger, no valid trigger
|
|
if (char === ' ' || char === '\t' || char === '\n' || char === '\r') return null;
|
|
|
|
if (allowedTriggerChars.has(char)) {
|
|
// trigger must be at start or after whitespace/newline
|
|
if (i > 0) {
|
|
const preceding = beforeCursor[i - 1];
|
|
if (preceding !== ' ' && preceding !== '\t' && preceding !== '\n' && preceding !== '\r') {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const query = beforeCursor.slice(i + 1);
|
|
return { triggerIndex: i, triggerChar: char, query };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function useMentionDetection({
|
|
value,
|
|
onValueChange,
|
|
textareaRef,
|
|
triggerChars = ['@'],
|
|
isTriggerEnabled,
|
|
isTriggerMatchValid,
|
|
}: UseMentionDetectionOptions): UseMentionDetectionResult {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [activeTriggerChar, setActiveTriggerChar] = useState<string | null>(null);
|
|
const [query, setQuery] = useState('');
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
|
|
const triggerIndexRef = useRef<number>(-1);
|
|
const activeTriggerCharRef = useRef<string | null>(null);
|
|
// Track current query in a ref so detectTrigger can avoid resetting selectedIndex
|
|
// on redundant selectionchange events (e.g. after ArrowDown/Up keyboard navigation)
|
|
const queryRef = useRef('');
|
|
|
|
const dismiss = useCallback(() => {
|
|
setIsOpen(false);
|
|
setActiveTriggerChar(null);
|
|
setQuery('');
|
|
setSelectedIndex(0);
|
|
setDropdownPosition(null);
|
|
triggerIndexRef.current = -1;
|
|
activeTriggerCharRef.current = null;
|
|
queryRef.current = '';
|
|
}, []);
|
|
|
|
const computeDropdownPosition = useCallback(
|
|
(triggerIdx: number, text: string): void => {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) return;
|
|
const coords = getCaretCoordinates(textarea, triggerIdx, text);
|
|
setDropdownPosition({
|
|
top: coords.top + coords.height + MENTION_DROPDOWN_OFFSET_PX,
|
|
left: 0,
|
|
});
|
|
},
|
|
[textareaRef]
|
|
);
|
|
|
|
const selectSuggestion = useCallback(
|
|
(s: MentionSuggestion) => {
|
|
const textarea = textareaRef.current;
|
|
const triggerChar = activeTriggerCharRef.current;
|
|
if (!textarea || triggerIndexRef.current < 0 || !triggerChar) return;
|
|
|
|
const before = value.slice(0, triggerIndexRef.current);
|
|
const after = value.slice(triggerIndexRef.current + 1 + queryRef.current.length);
|
|
const suggestionText = getSuggestionInsertionText(s);
|
|
const expectedTriggerChar = getSuggestionTriggerChar(s);
|
|
const insertionBody =
|
|
triggerChar === expectedTriggerChar && suggestionText.startsWith(triggerChar)
|
|
? suggestionText
|
|
: `${triggerChar}${suggestionText}`;
|
|
const insertion = `${insertionBody} `;
|
|
const newValue = before + insertion + after;
|
|
const newCursorPos = before.length + insertion.length;
|
|
|
|
onValueChange(newValue);
|
|
dismiss();
|
|
|
|
// Set cursor position after React re-render
|
|
requestAnimationFrame(() => {
|
|
textarea.focus();
|
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
|
});
|
|
},
|
|
[value, onValueChange, textareaRef, dismiss]
|
|
);
|
|
|
|
/**
|
|
* Detects whether cursor is inside a trigger region and opens/dismisses the dropdown.
|
|
*
|
|
* Called from handleSelect (selectionchange) — must NOT reset selectedIndex when
|
|
* the trigger is already active with the same query, otherwise ArrowDown/Up navigation
|
|
* gets immediately undone by the selectionchange event that follows keydown.
|
|
*/
|
|
const detectTrigger = useCallback(
|
|
(cursorPos: number) => {
|
|
const trigger = findMentionTrigger(value, cursorPos, triggerChars);
|
|
const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false;
|
|
const isValid = trigger ? (isTriggerMatchValid?.(trigger, value) ?? true) : false;
|
|
if (trigger && isEnabled && isValid) {
|
|
const sameQuery =
|
|
triggerIndexRef.current === trigger.triggerIndex &&
|
|
activeTriggerCharRef.current === trigger.triggerChar &&
|
|
queryRef.current === trigger.query;
|
|
triggerIndexRef.current = trigger.triggerIndex;
|
|
activeTriggerCharRef.current = trigger.triggerChar;
|
|
queryRef.current = trigger.query;
|
|
setActiveTriggerChar(trigger.triggerChar);
|
|
setQuery(trigger.query);
|
|
setIsOpen(true);
|
|
// Only reset selection when trigger/query actually changed —
|
|
// preserves keyboard navigation index across redundant selectionchange events
|
|
if (!sameQuery) {
|
|
setSelectedIndex(0);
|
|
}
|
|
computeDropdownPosition(trigger.triggerIndex, value);
|
|
} else {
|
|
dismiss();
|
|
}
|
|
},
|
|
[value, triggerChars, isTriggerEnabled, isTriggerMatchValid, dismiss, computeDropdownPosition]
|
|
);
|
|
|
|
const handleChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const newValue = e.target.value;
|
|
onValueChange(newValue);
|
|
|
|
// Detect trigger based on cursor position after the change
|
|
const cursorPos = e.target.selectionStart;
|
|
const trigger = findMentionTrigger(newValue, cursorPos, triggerChars);
|
|
const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false;
|
|
const isValid = trigger ? (isTriggerMatchValid?.(trigger, newValue) ?? true) : false;
|
|
if (trigger && isEnabled && isValid) {
|
|
triggerIndexRef.current = trigger.triggerIndex;
|
|
activeTriggerCharRef.current = trigger.triggerChar;
|
|
queryRef.current = trigger.query;
|
|
setActiveTriggerChar(trigger.triggerChar);
|
|
setQuery(trigger.query);
|
|
setIsOpen(true);
|
|
// Text changed — always reset selection to first item
|
|
setSelectedIndex(0);
|
|
computeDropdownPosition(trigger.triggerIndex, newValue);
|
|
} else {
|
|
dismiss();
|
|
}
|
|
},
|
|
[
|
|
onValueChange,
|
|
triggerChars,
|
|
isTriggerEnabled,
|
|
isTriggerMatchValid,
|
|
dismiss,
|
|
computeDropdownPosition,
|
|
]
|
|
);
|
|
|
|
const handleSelect = useCallback(
|
|
(e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
|
const target = e.target as HTMLTextAreaElement;
|
|
detectTrigger(target.selectionStart);
|
|
},
|
|
[detectTrigger]
|
|
);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(
|
|
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
|
suggestionCount: number,
|
|
onSelectSuggestion: (index: number) => void
|
|
) => {
|
|
if (!isOpen || suggestionCount === 0) return;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => (prev + 1) % suggestionCount);
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => (prev - 1 + suggestionCount) % suggestionCount);
|
|
break;
|
|
case 'Enter':
|
|
if (!e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onSelectSuggestion(selectedIndex);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
e.preventDefault();
|
|
dismiss();
|
|
break;
|
|
}
|
|
},
|
|
[isOpen, selectedIndex, dismiss]
|
|
);
|
|
|
|
const getTriggerIndex = useCallback(() => triggerIndexRef.current, []);
|
|
|
|
return {
|
|
isOpen,
|
|
activeTriggerChar,
|
|
query,
|
|
selectedIndex,
|
|
setSelectedIndex,
|
|
dropdownPosition,
|
|
selectSuggestion,
|
|
dismiss,
|
|
handleKeyDown,
|
|
handleChange,
|
|
handleSelect,
|
|
getTriggerIndex,
|
|
};
|
|
}
|