All checks were successful
Consolidate all feature and fix branches into main: - feat: orchestrator observability + mosaic rails integration (#422) - fix: post-422 CI and compose env follow-up (#423) - fix: orchestrator startup provider-key requirements (#425) - fix: BetterAuth OAuth2 flow and compose wiring (#426) - fix: BetterAuth UUID ID generation (#427) - test: web vitest localStorage/file warnings (#428) - fix: auth frontend remediation + review hardening (#421) - Plus numerous Docker, deploy, and auth fixes from develop Lockfile conflict resolved by regenerating from merged package.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
448 lines
13 KiB
TypeScript
448 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
import { apiRequest } from "@/lib/api/client";
|
|
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
|
|
|
|
interface LinkAutocompleteProps {
|
|
/**
|
|
* The textarea element to attach autocomplete to
|
|
*/
|
|
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
|
/**
|
|
* Callback when a link is selected
|
|
*/
|
|
onInsert: (linkText: string) => void;
|
|
}
|
|
|
|
interface AutocompleteState {
|
|
isOpen: boolean;
|
|
query: string;
|
|
position: { top: number; left: number };
|
|
triggerIndex: number;
|
|
}
|
|
|
|
interface SearchResult {
|
|
id: string;
|
|
slug: string;
|
|
title: string;
|
|
summary?: string | null;
|
|
}
|
|
|
|
/**
|
|
* LinkAutocomplete - Provides autocomplete for wiki-style links in markdown
|
|
*
|
|
* Detects when user types `[[` and shows a dropdown with matching entries.
|
|
* Arrow keys navigate, Enter selects, Esc cancels.
|
|
* Inserts `[[slug|title]]` on selection.
|
|
*/
|
|
export function LinkAutocomplete({
|
|
textareaRef,
|
|
onInsert,
|
|
}: LinkAutocompleteProps): React.ReactElement {
|
|
const [state, setState] = useState<AutocompleteState>({
|
|
isOpen: false,
|
|
query: "",
|
|
position: { top: 0, left: 0 },
|
|
triggerIndex: -1,
|
|
});
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [selectedIndex, setSelectedIndex] = useState<number>(0);
|
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
const [searchError, setSearchError] = useState<string | null>(null);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
|
const cursorSpanRef = useRef<HTMLSpanElement | null>(null);
|
|
|
|
// Refs for event handler to avoid stale closures when effects re-attach listeners
|
|
const stateRef = useRef(state);
|
|
const resultsRef = useRef(results);
|
|
const selectedIndexRef = useRef(selectedIndex);
|
|
const insertLinkRef = useRef<((result: SearchResult) => void) | null>(null);
|
|
stateRef.current = state;
|
|
resultsRef.current = results;
|
|
selectedIndexRef.current = selectedIndex;
|
|
|
|
/**
|
|
* Search for knowledge entries matching the query.
|
|
* Accepts an AbortSignal to allow cancellation of in-flight requests,
|
|
* preventing stale results from overwriting newer ones.
|
|
*/
|
|
const searchEntries = useCallback(async (query: string, signal: AbortSignal): Promise<void> => {
|
|
if (!query.trim()) {
|
|
setResults([]);
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await apiRequest<{
|
|
data: KnowledgeEntryWithTags[];
|
|
meta: {
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
totalPages: number;
|
|
};
|
|
}>(`/api/knowledge/search?q=${encodeURIComponent(query)}&limit=10`, {
|
|
method: "GET",
|
|
signal,
|
|
});
|
|
|
|
const searchResults: SearchResult[] = response.data.map((entry) => ({
|
|
id: entry.id,
|
|
slug: entry.slug,
|
|
title: entry.title,
|
|
summary: entry.summary,
|
|
}));
|
|
|
|
setResults(searchResults);
|
|
setSelectedIndex(0);
|
|
setSearchError(null);
|
|
} catch (error) {
|
|
// Ignore aborted requests - a newer search has superseded this one
|
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
return;
|
|
}
|
|
console.error("Failed to search entries:", error);
|
|
setResults([]);
|
|
setSearchError("Search unavailable — please try again");
|
|
} finally {
|
|
if (!signal.aborted) {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
/**
|
|
* Debounced search - waits 300ms after user stops typing.
|
|
* Cancels any in-flight request via AbortController before firing a new one,
|
|
* preventing race conditions where older results overwrite newer ones.
|
|
*/
|
|
const debouncedSearch = useCallback(
|
|
(query: string): void => {
|
|
if (searchTimeoutRef.current) {
|
|
clearTimeout(searchTimeoutRef.current);
|
|
}
|
|
|
|
// Abort any in-flight request from a previous search
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
|
|
searchTimeoutRef.current = setTimeout(() => {
|
|
// Create a new AbortController for this search request
|
|
const controller = new AbortController();
|
|
abortControllerRef.current = controller;
|
|
void searchEntries(query, controller.signal);
|
|
}, 300);
|
|
},
|
|
[searchEntries]
|
|
);
|
|
|
|
/**
|
|
* Calculate dropdown position relative to cursor.
|
|
* Uses a persistent off-screen mirror element (via refs) to avoid
|
|
* creating and removing DOM nodes on every keystroke, which causes
|
|
* layout thrashing.
|
|
*/
|
|
const calculateDropdownPosition = useCallback(
|
|
(textarea: HTMLTextAreaElement, cursorIndex: number): { top: number; left: number } => {
|
|
// Lazily create the mirror element once, then reuse it
|
|
if (!mirrorRef.current) {
|
|
const mirror = document.createElement("div");
|
|
mirror.style.position = "absolute";
|
|
mirror.style.visibility = "hidden";
|
|
mirror.style.height = "auto";
|
|
mirror.style.whiteSpace = "pre-wrap";
|
|
mirror.style.pointerEvents = "none";
|
|
document.body.appendChild(mirror);
|
|
mirrorRef.current = mirror;
|
|
|
|
const span = document.createElement("span");
|
|
span.textContent = "|";
|
|
cursorSpanRef.current = span;
|
|
}
|
|
|
|
const mirror = mirrorRef.current;
|
|
const cursorSpan = cursorSpanRef.current;
|
|
if (!cursorSpan) {
|
|
return { top: 0, left: 0 };
|
|
}
|
|
|
|
// Sync styles from the textarea so measurement is accurate
|
|
const styles = window.getComputedStyle(textarea);
|
|
const stylesToCopy = [
|
|
"fontFamily",
|
|
"fontSize",
|
|
"fontWeight",
|
|
"letterSpacing",
|
|
"lineHeight",
|
|
"padding",
|
|
"border",
|
|
"boxSizing",
|
|
"whiteSpace",
|
|
"wordWrap",
|
|
] as const;
|
|
|
|
stylesToCopy.forEach((prop) => {
|
|
const value = styles.getPropertyValue(prop);
|
|
if (value) {
|
|
mirror.style.setProperty(prop, value);
|
|
}
|
|
});
|
|
|
|
mirror.style.width = `${String(textarea.clientWidth)}px`;
|
|
|
|
// Update content: text before cursor + cursor marker span
|
|
const textBeforeCursor = textarea.value.substring(0, cursorIndex);
|
|
mirror.textContent = textBeforeCursor;
|
|
mirror.appendChild(cursorSpan);
|
|
|
|
const textareaRect = textarea.getBoundingClientRect();
|
|
const cursorSpanRect = cursorSpan.getBoundingClientRect();
|
|
|
|
const top = cursorSpanRect.top - textareaRect.top + textarea.scrollTop + 20;
|
|
const left = cursorSpanRect.left - textareaRect.left + textarea.scrollLeft;
|
|
|
|
return { top, left };
|
|
},
|
|
[]
|
|
);
|
|
|
|
/**
|
|
* Handle input changes in the textarea
|
|
*/
|
|
const handleInput = useCallback((): void => {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) return;
|
|
|
|
const cursorPos = textarea.selectionStart;
|
|
const text = textarea.value;
|
|
|
|
// Look for `[[` before cursor
|
|
const textBeforeCursor = text.substring(0, cursorPos);
|
|
const lastTrigger = textBeforeCursor.lastIndexOf("[[");
|
|
|
|
// Check if we're in an autocomplete context
|
|
if (lastTrigger !== -1) {
|
|
const textAfterTrigger = textBeforeCursor.substring(lastTrigger + 2);
|
|
|
|
// Check if there's a closing `]]` between trigger and cursor
|
|
const hasClosing = textAfterTrigger.includes("]]");
|
|
|
|
if (!hasClosing) {
|
|
// We're in autocomplete mode
|
|
const query = textAfterTrigger;
|
|
const position = calculateDropdownPosition(textarea, cursorPos);
|
|
|
|
setState({
|
|
isOpen: true,
|
|
query,
|
|
position,
|
|
triggerIndex: lastTrigger,
|
|
});
|
|
|
|
debouncedSearch(query);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Not in autocomplete mode
|
|
if (state.isOpen) {
|
|
setState({
|
|
isOpen: false,
|
|
query: "",
|
|
position: { top: 0, left: 0 },
|
|
triggerIndex: -1,
|
|
});
|
|
setResults([]);
|
|
}
|
|
}, [textareaRef, state.isOpen, calculateDropdownPosition, debouncedSearch]);
|
|
|
|
/**
|
|
* Handle keyboard navigation in the dropdown.
|
|
* Reads from refs to avoid stale closures when the effect
|
|
* that attaches this listener hasn't re-run yet.
|
|
*/
|
|
const handleKeyDown = useCallback((e: KeyboardEvent): void => {
|
|
if (!stateRef.current.isOpen) return;
|
|
|
|
const currentResults = resultsRef.current;
|
|
|
|
switch (e.key) {
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => (prev + 1) % currentResults.length);
|
|
break;
|
|
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => (prev - 1 + currentResults.length) % currentResults.length);
|
|
break;
|
|
|
|
case "Enter":
|
|
e.preventDefault();
|
|
if (currentResults.length > 0 && selectedIndexRef.current >= 0) {
|
|
const selected = currentResults[selectedIndexRef.current];
|
|
if (selected) {
|
|
insertLinkRef.current?.(selected);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "Escape":
|
|
e.preventDefault();
|
|
setState({
|
|
isOpen: false,
|
|
query: "",
|
|
position: { top: 0, left: 0 },
|
|
triggerIndex: -1,
|
|
});
|
|
setResults([]);
|
|
break;
|
|
}
|
|
}, []);
|
|
|
|
/**
|
|
* Insert the selected link into the textarea
|
|
*/
|
|
const insertLink = useCallback(
|
|
(result: SearchResult): void => {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) return;
|
|
|
|
const linkText = `[[${result.slug}|${result.title}]]`;
|
|
const text = textarea.value;
|
|
const before = text.substring(0, state.triggerIndex);
|
|
const after = text.substring(textarea.selectionStart);
|
|
const newText = before + linkText + after;
|
|
|
|
onInsert(newText);
|
|
|
|
// Close autocomplete
|
|
setState({
|
|
isOpen: false,
|
|
query: "",
|
|
position: { top: 0, left: 0 },
|
|
triggerIndex: -1,
|
|
});
|
|
setResults([]);
|
|
|
|
// Move cursor after the inserted link
|
|
const newCursorPos = state.triggerIndex + linkText.length;
|
|
setTimeout(() => {
|
|
textarea.focus();
|
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
|
}, 0);
|
|
},
|
|
[textareaRef, state.triggerIndex, onInsert]
|
|
);
|
|
insertLinkRef.current = insertLink;
|
|
|
|
/**
|
|
* Handle click on a result
|
|
*/
|
|
const handleResultClick = useCallback(
|
|
(result: SearchResult): void => {
|
|
insertLink(result);
|
|
},
|
|
[insertLink]
|
|
);
|
|
|
|
/**
|
|
* Setup event listeners
|
|
*/
|
|
useEffect(() => {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) return;
|
|
|
|
textarea.addEventListener("input", handleInput);
|
|
textarea.addEventListener("keydown", handleKeyDown as unknown as EventListener);
|
|
|
|
return (): void => {
|
|
textarea.removeEventListener("input", handleInput);
|
|
textarea.removeEventListener("keydown", handleKeyDown as unknown as EventListener);
|
|
};
|
|
}, [textareaRef, handleInput, handleKeyDown]);
|
|
|
|
/**
|
|
* Cleanup timeout, abort in-flight requests, and remove the
|
|
* persistent mirror element on unmount
|
|
*/
|
|
useEffect(() => {
|
|
return (): void => {
|
|
if (searchTimeoutRef.current) {
|
|
clearTimeout(searchTimeoutRef.current);
|
|
}
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
if (mirrorRef.current) {
|
|
document.body.removeChild(mirrorRef.current);
|
|
mirrorRef.current = null;
|
|
cursorSpanRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
if (!state.isOpen) {
|
|
return <></>;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={dropdownRef}
|
|
className="absolute z-50 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-64 overflow-y-auto min-w-[300px] max-w-[500px]"
|
|
style={{
|
|
top: `${String(state.position.top)}px`,
|
|
left: `${String(state.position.left)}px`,
|
|
}}
|
|
>
|
|
{isLoading ? (
|
|
<div className="p-3 text-sm text-gray-500 dark:text-gray-400">Searching...</div>
|
|
) : searchError ? (
|
|
<div className="p-3 text-sm text-amber-600 dark:text-amber-400">{searchError}</div>
|
|
) : results.length === 0 ? (
|
|
<div className="p-3 text-sm text-gray-500 dark:text-gray-400">
|
|
{state.query ? "No entries found" : "Start typing to search..."}
|
|
</div>
|
|
) : (
|
|
<ul className="py-1">
|
|
{results.map((result, index) => (
|
|
<li
|
|
key={result.id}
|
|
className={`px-3 py-2 cursor-pointer transition-colors ${
|
|
index === selectedIndex
|
|
? "bg-blue-50 dark:bg-blue-900/30"
|
|
: "hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
}`}
|
|
onClick={() => {
|
|
handleResultClick(result);
|
|
}}
|
|
onMouseEnter={() => {
|
|
setSelectedIndex(index);
|
|
}}
|
|
>
|
|
<div className="font-medium text-sm text-gray-900 dark:text-gray-100">
|
|
{result.title}
|
|
</div>
|
|
{result.summary && (
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
|
|
{result.summary}
|
|
</div>
|
|
)}
|
|
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">{result.slug}</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
<div className="px-3 py-2 text-xs text-gray-400 dark:text-gray-500 border-t border-gray-200 dark:border-gray-700">
|
|
↑↓ Navigate • Enter Select • Esc Cancel
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|