552 lines
18 KiB
TypeScript
552 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
forwardRef,
|
|
useRef,
|
|
useCallback,
|
|
useState,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
} from "react";
|
|
import { EditorContent } from "@tiptap/react";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts";
|
|
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
|
import { getWebSocketClient } from "@/lib/ws/connection";
|
|
import { searchWorkspaceFiles } from "@/lib/ws/workspace-files";
|
|
import { EditorContextProvider } from "./editor-context";
|
|
import { MentionMenu } from "./mention-menu";
|
|
import { MessageHistorySearch } from "./message-history-search";
|
|
import { SlashCommandMenu } from "./slash-command-menu";
|
|
import { buildTaskMentionItems } from "./task-mention-items";
|
|
import { extractUserHistory } from "./message-history";
|
|
import { useDrainOlderMessages } from "./use-drain-older-messages";
|
|
import {
|
|
createMentionSuggestion,
|
|
createSlashSuggestion,
|
|
type MenuState,
|
|
type MentionSuggestionCallbacks,
|
|
type SlashSuggestionCallbacks,
|
|
} from "./tiptap-suggestion";
|
|
import { useTipTapEditor, type TipTapInputHandle } from "./use-tiptap-editor";
|
|
import type { MentionItem } from "@/hooks/use-inline-mention";
|
|
import type { SlashCommand } from "@/hooks/use-inline-slash";
|
|
import type { ContextFile } from "@/lib/state/context-files-store";
|
|
|
|
export type { TipTapInputHandle } from "./use-tiptap-editor";
|
|
|
|
// ── Props ───────────────────────────────────────────────────────────
|
|
|
|
type TipTapInputProps = {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
onSubmit?: () => void;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
planModeEnabled?: boolean;
|
|
submitKey?: "enter" | "cmd_enter";
|
|
onFocus?: () => void;
|
|
onBlur?: () => void;
|
|
// TipTap-specific
|
|
sessionId: string | null;
|
|
taskId?: string | null;
|
|
onAddContextFile?: (file: ContextFile) => void;
|
|
onToggleContextFile?: (file: ContextFile) => void;
|
|
planContextEnabled?: boolean;
|
|
onAgentCommand?: (commandName: string) => void;
|
|
onImagePaste?: (files: File[]) => void;
|
|
onPlanModeChange?: (enabled: boolean) => void;
|
|
};
|
|
|
|
// ── Filter items ────────────────────────────────────────────────────
|
|
function filterItems(items: MentionItem[], query: string): MentionItem[] {
|
|
if (!query) return items;
|
|
const lq = query.toLowerCase();
|
|
return items
|
|
.map((item) => {
|
|
const label = item.label.toLowerCase();
|
|
let score = 0;
|
|
if (label.startsWith(lq)) score = 100;
|
|
else if (label.split(/[\s\-_/]/).some((w) => w.startsWith(lq))) score = 50;
|
|
else if (label.includes(lq)) score = 25;
|
|
return { item, score };
|
|
})
|
|
.filter(({ score }) => score > 0)
|
|
.sort((a, b) => b.score - a.score)
|
|
.map(({ item }) => item);
|
|
}
|
|
|
|
// ── Menu keyboard navigation helper ──────────────────────────────
|
|
|
|
function handleMenuKeyDown<T>(
|
|
event: KeyboardEvent,
|
|
menu: MenuState<T>,
|
|
setIndex: React.Dispatch<React.SetStateAction<number>>,
|
|
indexRef: React.RefObject<number>,
|
|
): boolean {
|
|
if (!menu.isOpen) return false;
|
|
if (event.key === "ArrowDown") {
|
|
setIndex((i) => Math.min(i + 1, menu.items.length - 1));
|
|
return true;
|
|
}
|
|
if (event.key === "ArrowUp") {
|
|
setIndex((i) => Math.max(i - 1, 0));
|
|
return true;
|
|
}
|
|
if (event.key === "Enter" || event.key === "Tab") {
|
|
if (menu.items.length > 0 && menu.command) {
|
|
const item = menu.items[indexRef.current];
|
|
if (item) menu.command(item);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── Mention items fetcher hook ───────────────────────────────────────
|
|
|
|
async function fetchFileResults(
|
|
sessionId: string,
|
|
query: string,
|
|
cache: { query: string; results: string[] },
|
|
): Promise<string[]> {
|
|
const client = getWebSocketClient();
|
|
if (!client) return [];
|
|
const cacheKey = query || "__empty__";
|
|
if (cache.query === cacheKey) return cache.results;
|
|
const response = await searchWorkspaceFiles(client, sessionId, query || "", 20);
|
|
const results = response.files || [];
|
|
cache.query = cacheKey;
|
|
cache.results = results;
|
|
return results;
|
|
}
|
|
|
|
function useMentionItems(sessionId: string | null, taskId: string | null) {
|
|
const { prompts } = useCustomPrompts();
|
|
const storeApi = useAppStoreApi();
|
|
const promptsRef = useRef(prompts);
|
|
const sessionIdRef = useRef(sessionId);
|
|
const taskIdRef = useRef(taskId);
|
|
const lastFileSearchRef = useRef<{ query: string; results: string[] }>({
|
|
query: "",
|
|
results: [],
|
|
});
|
|
useLayoutEffect(() => {
|
|
promptsRef.current = prompts;
|
|
sessionIdRef.current = sessionId;
|
|
taskIdRef.current = taskId;
|
|
});
|
|
|
|
return useCallback(
|
|
async (query: string): Promise<MentionItem[]> => {
|
|
const allItems: MentionItem[] = [];
|
|
allItems.push(...buildTaskMentionItems(storeApi.getState(), taskIdRef.current));
|
|
allItems.push({
|
|
id: "__plan__",
|
|
kind: "plan",
|
|
label: "Plan",
|
|
description: "Include the plan as context",
|
|
onSelect: () => {},
|
|
});
|
|
for (const p of promptsRef.current) {
|
|
allItems.push({
|
|
id: p.id,
|
|
kind: "prompt",
|
|
label: p.name,
|
|
description: p.content.length > 100 ? p.content.slice(0, 100) + "..." : p.content,
|
|
onSelect: () => {},
|
|
});
|
|
}
|
|
const sid = sessionIdRef.current;
|
|
if (sid) {
|
|
try {
|
|
const files = await fetchFileResults(sid, query, lastFileSearchRef.current);
|
|
for (const filePath of files) {
|
|
allItems.push({
|
|
id: filePath,
|
|
kind: "file",
|
|
label: filePath,
|
|
description: "File",
|
|
onSelect: () => {},
|
|
});
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
return filterItems(allItems, query);
|
|
},
|
|
[storeApi],
|
|
);
|
|
}
|
|
|
|
// ── Suggestion configs hook ──────────────────────────────────────────
|
|
|
|
type SuggestionConfigsInput = {
|
|
sessionId: string | null;
|
|
taskId: string | null;
|
|
onAgentCommand?: (commandName: string) => void;
|
|
onMentionKeyDown: (event: KeyboardEvent) => boolean;
|
|
onSlashKeyDown: (event: KeyboardEvent) => boolean;
|
|
setMentionMenu: React.Dispatch<React.SetStateAction<MenuState<MentionItem>>>;
|
|
setSlashMenu: React.Dispatch<React.SetStateAction<MenuState<SlashCommand>>>;
|
|
};
|
|
|
|
function useSuggestionConfigs({
|
|
sessionId,
|
|
taskId,
|
|
onAgentCommand,
|
|
onMentionKeyDown,
|
|
onSlashKeyDown,
|
|
setMentionMenu,
|
|
setSlashMenu,
|
|
}: SuggestionConfigsInput) {
|
|
const agentCommands = useAppStore((state) =>
|
|
sessionId ? state.availableCommands.bySessionId[sessionId] : undefined,
|
|
);
|
|
const slashCommands = useMemo((): SlashCommand[] => {
|
|
if (!agentCommands || agentCommands.length === 0) return [];
|
|
return agentCommands
|
|
.filter((cmd) => !(cmd.description || "").includes("(bundled)"))
|
|
.map((cmd) => ({
|
|
id: `agent-${cmd.name}`,
|
|
label: `/${cmd.name}`,
|
|
description: cmd.description || `Run /${cmd.name} command`,
|
|
action: "agent" as const,
|
|
agentCommandName: cmd.name,
|
|
}));
|
|
}, [agentCommands]);
|
|
|
|
const getMentionItems = useMentionItems(sessionId, taskId);
|
|
const mentionCallbacks = useMemo(
|
|
(): MentionSuggestionCallbacks => ({ getItems: getMentionItems }),
|
|
[getMentionItems],
|
|
);
|
|
|
|
const onAgentCommandRef = useRef(onAgentCommand);
|
|
const slashCommandsRef = useRef(slashCommands);
|
|
useLayoutEffect(() => {
|
|
onAgentCommandRef.current = onAgentCommand;
|
|
slashCommandsRef.current = slashCommands;
|
|
});
|
|
const slashCallbacks = useMemo(
|
|
(): SlashSuggestionCallbacks => ({
|
|
getCommands: () => slashCommandsRef.current,
|
|
onAgentCommand: (name) => onAgentCommandRef.current?.(name),
|
|
}),
|
|
[],
|
|
);
|
|
|
|
/* eslint-disable react-hooks/refs -- mentionCallbacks/slashCallbacks capture refs for deferred access, not during render */
|
|
const mentionSuggestion = useMemo(
|
|
() => createMentionSuggestion(mentionCallbacks, setMentionMenu, onMentionKeyDown),
|
|
[mentionCallbacks, setMentionMenu, onMentionKeyDown],
|
|
);
|
|
const slashSuggestion = useMemo(
|
|
() => createSlashSuggestion(slashCallbacks, setSlashMenu, onSlashKeyDown),
|
|
[slashCallbacks, setSlashMenu, onSlashKeyDown],
|
|
);
|
|
/* eslint-enable react-hooks/refs */
|
|
|
|
return { mentionSuggestion, slashSuggestion };
|
|
}
|
|
|
|
// ── Menu state hook ──────────────────────────────────────────────────
|
|
|
|
function useMenuHandlers() {
|
|
const [mentionMenu, setMentionMenu] = useState<MenuState<MentionItem>>({
|
|
isOpen: false,
|
|
items: [],
|
|
query: "",
|
|
clientRect: null,
|
|
command: null,
|
|
});
|
|
const [slashMenu, setSlashMenu] = useState<MenuState<SlashCommand>>({
|
|
isOpen: false,
|
|
items: [],
|
|
query: "",
|
|
clientRect: null,
|
|
command: null,
|
|
});
|
|
const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0);
|
|
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
|
|
|
|
useEffect(() => {
|
|
void Promise.resolve().then(() => setMentionSelectedIndex(0));
|
|
}, [mentionMenu.items]);
|
|
useEffect(() => {
|
|
void Promise.resolve().then(() => setSlashSelectedIndex(0));
|
|
}, [slashMenu.items]);
|
|
|
|
const mentionSelectedIndexRef = useRef(mentionSelectedIndex);
|
|
const slashSelectedIndexRef = useRef(slashSelectedIndex);
|
|
const mentionKeyDownRef = useRef<((event: KeyboardEvent) => boolean) | null>(null);
|
|
const slashKeyDownRef = useRef<((event: KeyboardEvent) => boolean) | null>(null);
|
|
|
|
const mentionKeyDown = useCallback(
|
|
(event: KeyboardEvent) =>
|
|
handleMenuKeyDown(event, mentionMenu, setMentionSelectedIndex, mentionSelectedIndexRef),
|
|
[mentionMenu],
|
|
);
|
|
const slashKeyDown = useCallback(
|
|
(event: KeyboardEvent) =>
|
|
handleMenuKeyDown(event, slashMenu, setSlashSelectedIndex, slashSelectedIndexRef),
|
|
[slashMenu],
|
|
);
|
|
const onMentionKeyDown = useCallback(
|
|
(event: KeyboardEvent) => mentionKeyDownRef.current?.(event) ?? false,
|
|
[],
|
|
);
|
|
const onSlashKeyDown = useCallback(
|
|
(event: KeyboardEvent) => slashKeyDownRef.current?.(event) ?? false,
|
|
[],
|
|
);
|
|
|
|
useLayoutEffect(() => {
|
|
mentionSelectedIndexRef.current = mentionSelectedIndex;
|
|
slashSelectedIndexRef.current = slashSelectedIndex;
|
|
mentionKeyDownRef.current = mentionKeyDown;
|
|
slashKeyDownRef.current = slashKeyDown;
|
|
});
|
|
|
|
const handleMentionSelect = useCallback(
|
|
(item: MentionItem) => {
|
|
mentionMenu.command?.(item);
|
|
},
|
|
[mentionMenu],
|
|
);
|
|
const handleMentionClose = useCallback(
|
|
() => setMentionMenu({ isOpen: false, items: [], query: "", clientRect: null, command: null }),
|
|
[],
|
|
);
|
|
const handleSlashSelect = useCallback(
|
|
(cmd: SlashCommand) => {
|
|
slashMenu.command?.(cmd);
|
|
},
|
|
[slashMenu],
|
|
);
|
|
const handleSlashClose = useCallback(
|
|
() => setSlashMenu({ isOpen: false, items: [], query: "", clientRect: null, command: null }),
|
|
[],
|
|
);
|
|
|
|
return {
|
|
mentionMenu,
|
|
setMentionMenu,
|
|
slashMenu,
|
|
setSlashMenu,
|
|
mentionSelectedIndex,
|
|
setMentionSelectedIndex,
|
|
slashSelectedIndex,
|
|
setSlashSelectedIndex,
|
|
onMentionKeyDown,
|
|
onSlashKeyDown,
|
|
handleMentionSelect,
|
|
handleMentionClose,
|
|
handleSlashSelect,
|
|
handleSlashClose,
|
|
};
|
|
}
|
|
|
|
// ── Component ───────────────────────────────────────────────────────
|
|
|
|
export const TipTapInput = forwardRef<TipTapInputHandle, TipTapInputProps>(function TipTapInput(
|
|
{
|
|
value,
|
|
onChange,
|
|
onSubmit,
|
|
placeholder = "",
|
|
disabled = false,
|
|
className,
|
|
planModeEnabled = false,
|
|
onPlanModeChange,
|
|
submitKey = "cmd_enter",
|
|
onFocus,
|
|
onBlur,
|
|
sessionId,
|
|
taskId,
|
|
onAgentCommand,
|
|
onImagePaste,
|
|
},
|
|
ref,
|
|
) {
|
|
const menu = useMenuHandlers();
|
|
const { mentionSuggestion, slashSuggestion } = useSuggestionConfigs({
|
|
sessionId,
|
|
taskId: taskId ?? null,
|
|
onAgentCommand,
|
|
onMentionKeyDown: menu.onMentionKeyDown,
|
|
onSlashKeyDown: menu.onSlashKeyDown,
|
|
setMentionMenu: menu.setMentionMenu,
|
|
setSlashMenu: menu.setSlashMenu,
|
|
});
|
|
const isSuggestionMenuOpen =
|
|
(menu.mentionMenu.isOpen && menu.mentionMenu.items.length > 0) ||
|
|
(menu.slashMenu.isOpen && menu.slashMenu.items.length > 0);
|
|
const { history, getHistory } = useChatHistory(sessionId);
|
|
const { editorWrapperRef, ...overlay } = useReverseSearchOverlay(sessionId);
|
|
const { isDraining } = useDrainOlderMessages(sessionId, overlay.isReverseSearchOpen);
|
|
const { editor, applyHistoryEntry } = useTipTapEditor({
|
|
value,
|
|
onChange,
|
|
onSubmit,
|
|
placeholder,
|
|
disabled,
|
|
className,
|
|
planModeEnabled,
|
|
onPlanModeChange,
|
|
submitKey,
|
|
onFocus,
|
|
onBlur,
|
|
sessionId,
|
|
onImagePaste,
|
|
mentionSuggestion,
|
|
slashSuggestion,
|
|
isSuggestionMenuOpen,
|
|
getHistory,
|
|
onOpenReverseSearch: overlay.openReverseSearch,
|
|
isReverseSearchOpen: overlay.isReverseSearchOpen,
|
|
ref,
|
|
});
|
|
const { closeReverseSearch } = overlay;
|
|
const handleReverseSearchSelect = useCallback(
|
|
(index: number) => {
|
|
applyHistoryEntry(index);
|
|
closeReverseSearch();
|
|
editor?.commands.focus("end");
|
|
},
|
|
[applyHistoryEntry, closeReverseSearch, editor],
|
|
);
|
|
return (
|
|
<>
|
|
<TipTapPopups
|
|
menu={menu}
|
|
overlay={overlay}
|
|
history={history}
|
|
isDraining={isDraining}
|
|
onReverseSearchSelect={handleReverseSearchSelect}
|
|
/>
|
|
<EditorContextProvider value={{ sessionId, taskId: taskId ?? null }}>
|
|
<div ref={editorWrapperRef} className="h-full">
|
|
<EditorContent
|
|
editor={editor}
|
|
className="h-full [&_.tiptap]:h-full [&_.tiptap]:outline-none"
|
|
/>
|
|
</div>
|
|
</EditorContextProvider>
|
|
</>
|
|
);
|
|
});
|
|
|
|
type TipTapPopupsProps = {
|
|
menu: ReturnType<typeof useMenuHandlers>;
|
|
overlay: Omit<ReturnType<typeof useReverseSearchOverlay>, "editorWrapperRef">;
|
|
history: readonly string[];
|
|
isDraining: boolean;
|
|
onReverseSearchSelect: (index: number) => void;
|
|
};
|
|
|
|
function TipTapPopups({
|
|
menu,
|
|
overlay,
|
|
history,
|
|
isDraining,
|
|
onReverseSearchSelect,
|
|
}: TipTapPopupsProps) {
|
|
return (
|
|
<>
|
|
<MentionMenu
|
|
isOpen={menu.mentionMenu.isOpen}
|
|
isLoading={false}
|
|
clientRect={menu.mentionMenu.clientRect}
|
|
items={menu.mentionMenu.items}
|
|
query={menu.mentionMenu.query}
|
|
selectedIndex={menu.mentionSelectedIndex}
|
|
onSelect={menu.handleMentionSelect}
|
|
onClose={menu.handleMentionClose}
|
|
setSelectedIndex={menu.setMentionSelectedIndex}
|
|
/>
|
|
<SlashCommandMenu
|
|
isOpen={menu.slashMenu.isOpen}
|
|
clientRect={menu.slashMenu.clientRect}
|
|
commands={menu.slashMenu.items}
|
|
selectedIndex={menu.slashSelectedIndex}
|
|
onSelect={menu.handleSlashSelect}
|
|
onClose={menu.handleSlashClose}
|
|
setSelectedIndex={menu.setSlashSelectedIndex}
|
|
/>
|
|
{overlay.isReverseSearchOpen && (
|
|
<MessageHistorySearch
|
|
history={history}
|
|
isLoadingOlder={isDraining}
|
|
anchorRect={overlay.reverseSearchAnchor}
|
|
onClose={overlay.closeReverseSearch}
|
|
onSelect={onReverseSearchSelect}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function useMessageHistoryForSession(sessionId: string | null): string[] {
|
|
// Subscribe to the *derived* user history (not the raw messages array) via a
|
|
// shallow comparison: agent streaming tokens churn the messages array every
|
|
// frame but don't change the user's sent-message history, so the rich editor
|
|
// only re-renders when the history list itself changes.
|
|
return useAppStore(
|
|
useShallow((s) =>
|
|
extractUserHistory((sessionId ? s.messages.bySession[sessionId] : undefined) ?? []),
|
|
),
|
|
);
|
|
}
|
|
|
|
function useChatHistory(sessionId: string | null) {
|
|
const history = useMessageHistoryForSession(sessionId);
|
|
const historyRef = useRef(history);
|
|
useLayoutEffect(() => {
|
|
historyRef.current = history;
|
|
});
|
|
const getHistory = useCallback(() => historyRef.current, []);
|
|
return { history, getHistory };
|
|
}
|
|
|
|
function useReverseSearchOverlay(sessionId: string | null) {
|
|
const editorWrapperRef = useRef<HTMLDivElement>(null);
|
|
const [reverseSearchAnchor, setReverseSearchAnchor] = useState<DOMRect | null>(null);
|
|
const [isReverseSearchOpen, setIsReverseSearchOpen] = useState(false);
|
|
const sessionIdRef = useRef(sessionId);
|
|
useLayoutEffect(() => {
|
|
sessionIdRef.current = sessionId;
|
|
});
|
|
const openReverseSearch = useCallback(() => {
|
|
if (!sessionIdRef.current) return;
|
|
setReverseSearchAnchor(editorWrapperRef.current?.getBoundingClientRect() ?? null);
|
|
setIsReverseSearchOpen(true);
|
|
}, []);
|
|
const closeReverseSearch = useCallback(() => setIsReverseSearchOpen(false), []);
|
|
// The anchor rect is captured once at open time; dismiss on viewport
|
|
// changes rather than recompute, matching how the project's other
|
|
// fixed-position popups behave on resize/scroll. Bubbling-phase scroll
|
|
// only — capture-phase would also catch the overlay's own list scroll
|
|
// (e.g. from scrollIntoView during keyboard navigation), which would
|
|
// dismiss the overlay mid-browse.
|
|
useEffect(() => {
|
|
if (!isReverseSearchOpen) return;
|
|
window.addEventListener("resize", closeReverseSearch);
|
|
window.addEventListener("scroll", closeReverseSearch);
|
|
return () => {
|
|
window.removeEventListener("resize", closeReverseSearch);
|
|
window.removeEventListener("scroll", closeReverseSearch);
|
|
};
|
|
}, [isReverseSearchOpen, closeReverseSearch]);
|
|
return {
|
|
editorWrapperRef,
|
|
reverseSearchAnchor,
|
|
isReverseSearchOpen,
|
|
openReverseSearch,
|
|
closeReverseSearch,
|
|
};
|
|
}
|