Files
fusion/packages/dashboard/app/hooks/useFileMention.ts
Fusion (runfusion.ai) 12a6ee9af1 feat(FN-5218): add hash mentions to chat composers and popups
Merges FN-5218: adds hash mention support (`#`) in chat composers, grouping task and file reference results in a unified popup, with wiring into `ChatView` and `QuickChatFAB`, plus test coverage and docs. The hook `useFileMention` is extended and refactored, and the file mention popup UI is updated

Fusion-Task-Id: FN-5218
2026-05-20 04:12:40 -07:00

310 lines
8.6 KiB
TypeScript

import { useState, useCallback, useEffect, useRef, type KeyboardEvent } from "react";
import { fetchTasks, searchFiles } from "../api";
import type { Column } from "@fusion/core";
export interface FileSearchItem {
path: string;
name: string;
}
export interface TaskSearchItem {
id: string;
title: string;
column: string;
}
export type HashMentionItem =
| { kind: "task"; task: TaskSearchItem }
| { kind: "file"; file: FileSearchItem };
export interface UseFileMentionOptions {
projectId?: string;
workspace?: string;
}
export interface UseFileMentionReturn {
mentionActive: boolean;
tasks: TaskSearchItem[];
files: FileSearchItem[];
combinedItems: HashMentionItem[];
loading: boolean;
mentionQuery: string;
selectedIndex: number;
setSelectedIndex: (index: number) => void;
detectMention: (text: string, cursorPosition: number) => void;
selectTask: (task: TaskSearchItem, currentText: string) => string;
selectFile: (file: FileSearchItem, currentText: string) => string;
dismissMention: () => void;
handleKeyDown: (event: KeyboardEvent<HTMLElement>, currentText: string) => boolean;
}
const DEBOUNCE_MS = 200;
const MAX_TASK_RESULTS = 8;
const MAX_FILE_RESULTS = 8;
function createAbortError(): DOMException {
return new DOMException("The operation was aborted.", "AbortError");
}
function withAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) {
return Promise.reject(createAbortError());
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(createAbortError());
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(error) => {
signal.removeEventListener("abort", onAbort);
reject(error);
},
);
});
}
function replaceCurrentMention(currentText: string, mentionStartIndex: number, replacement: string): string {
const beforeMention = currentText.slice(0, mentionStartIndex);
const afterMention = currentText.slice(mentionStartIndex + 1);
const mentionEndMatch = afterMention.match(/[\s]|$/);
const mentionEndIndex = mentionEndMatch ? mentionEndMatch.index ?? afterMention.length : afterMention.length;
const afterCurrentMention = afterMention.slice(mentionEndIndex);
return `${beforeMention}${replacement}${afterCurrentMention}`;
}
/**
* Hook to manage `#` hash-mention state and interactions.
*
* Detects `#` triggers in text input and provides combined task + file search,
* keyboard navigation, and selection support for the shared popup.
*/
export function useFileMention(options: UseFileMentionOptions = {}): UseFileMentionReturn {
const { projectId, workspace = "project" } = options;
const [mentionActive, setMentionActive] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
const [tasks, setTasks] = useState<TaskSearchItem[]>([]);
const [files, setFiles] = useState<FileSearchItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortController = useRef<AbortController | null>(null);
const combinedItems: HashMentionItem[] = [
...tasks.map((task) => ({ kind: "task" as const, task })),
...files.map((file) => ({ kind: "file" as const, file })),
];
useEffect(() => {
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
abortController.current?.abort();
};
}, []);
const detectMention = useCallback((text: string, cursorPosition: number) => {
if (cursorPosition < 0 || cursorPosition > text.length) {
setMentionActive(false);
return;
}
const isPathChar = (char: string): boolean => /[a-zA-Z0-9/_.-]/.test(char);
for (let i = cursorPosition - 1; i >= 0; i--) {
if (text[i] === "#") {
if (i === 0) {
const query = text.slice(i + 1, cursorPosition);
setMentionStartIndex(i);
setMentionQuery(query);
setSelectedIndex(0);
setMentionActive(true);
return;
}
const charBefore = text[i - 1];
if (/[\s,.;:!?'"()[\]{}]/.test(charBefore)) {
const query = text.slice(i + 1, cursorPosition);
setMentionStartIndex(i);
setMentionQuery(query);
setSelectedIndex(0);
setMentionActive(true);
return;
}
setMentionActive(false);
return;
}
if (!isPathChar(text[i])) {
setMentionActive(false);
return;
}
}
setMentionActive(false);
}, []);
const dismissMention = useCallback(() => {
abortController.current?.abort();
setMentionActive(false);
setMentionQuery("");
setMentionStartIndex(-1);
setTasks([]);
setFiles([]);
setSelectedIndex(0);
setLoading(false);
}, []);
const performSearch = useCallback(
async (query: string) => {
if (!query.trim()) {
setTasks([]);
setFiles([]);
setLoading(false);
return;
}
abortController.current?.abort();
const controller = new AbortController();
abortController.current = controller;
try {
setLoading(true);
const [taskResult, fileResult] = await Promise.allSettled([
withAbort(fetchTasks(20, 0, projectId, query), controller.signal),
withAbort(searchFiles(query, workspace, projectId), controller.signal),
]);
if (controller.signal.aborted) {
return;
}
const nextTasks = taskResult.status === "fulfilled"
? taskResult.value.slice(0, MAX_TASK_RESULTS).map((task) => ({
id: task.id,
title: task.title ?? "",
column: task.column as Column,
}))
: [];
const nextFiles = fileResult.status === "fulfilled" ? fileResult.value.files.slice(0, MAX_FILE_RESULTS) : [];
setTasks(nextTasks);
setFiles(nextFiles);
setSelectedIndex(0);
} catch (err) {
if ((err as DOMException).name !== "AbortError") {
setTasks([]);
setFiles([]);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
},
[workspace, projectId],
);
useEffect(() => {
if (!mentionActive) return;
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
debounceTimer.current = setTimeout(() => {
void performSearch(mentionQuery);
}, DEBOUNCE_MS);
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, [mentionQuery, mentionActive, performSearch]);
const selectTask = useCallback(
(task: TaskSearchItem, currentText: string): string => {
if (!mentionActive || mentionStartIndex < 0) {
return currentText;
}
return replaceCurrentMention(currentText, mentionStartIndex, `#${task.id}`);
},
[mentionActive, mentionStartIndex],
);
const selectFile = useCallback(
(file: FileSearchItem, currentText: string): string => {
if (!mentionActive || mentionStartIndex < 0) {
return currentText;
}
return replaceCurrentMention(currentText, mentionStartIndex, `#${file.path}`);
},
[mentionActive, mentionStartIndex],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>, _currentText: string): boolean => {
if (!mentionActive || combinedItems.length === 0) {
return false;
}
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, combinedItems.length - 1));
return true;
case "ArrowUp":
event.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
return true;
case "Enter":
case "Tab":
if (combinedItems[selectedIndex]) {
event.preventDefault();
return true;
}
return false;
case "Escape":
event.preventDefault();
dismissMention();
return true;
default:
return false;
}
},
[mentionActive, combinedItems, selectedIndex, dismissMention],
);
return {
mentionActive,
tasks,
files,
combinedItems,
loading,
mentionQuery,
selectedIndex,
setSelectedIndex,
detectMention,
selectTask,
selectFile,
dismissMention,
handleKeyDown,
};
}