openclaw/apps/web/app/components/workspace/slash-command.tsx
kumarabhirup dee323b7ad
fix lint/build errors and bump to 2026.2.15-1.4
- Fix all oxlint errors (curly, no-unused-vars, no-base-to-string,
  no-floating-promises, approx-constant, restrict-template-expressions)
- Fix TS build errors: rewrite update-cli.ts as thin wrapper over
  submodules, restore missing chat abort helpers in chat.ts
- Fix web build: wrap handleNewSession in async for ChatPanelHandle,
  add missing safeString helper to entry-detail-modal
- Bump version to 2026.2.15-1.4 and publish

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 00:30:13 -08:00

671 lines
20 KiB
TypeScript

"use client";
import {
useState,
useEffect,
useRef,
useCallback,
useLayoutEffect,
forwardRef,
useImperativeHandle,
} from "react";
import { Extension } from "@tiptap/core";
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
import { PluginKey } from "@tiptap/pm/state";
import { createPortal } from "react-dom";
import type { Editor, Range } from "@tiptap/core";
import type { SearchIndexItem } from "@/lib/search-index";
import { buildEntryLink, buildFileLink } from "@/lib/workspace-links";
// Unique plugin keys so both suggestions can coexist
const slashCommandPluginKey = new PluginKey("slashCommand");
const fileMentionPluginKey = new PluginKey("fileMention");
// --- Types ---
export type TreeNode = {
name: string;
path: string;
type: "object" | "document" | "folder" | "file" | "database" | "report";
icon?: string;
children?: TreeNode[];
};
type SlashItem = {
title: string;
description?: string;
badge?: string;
icon: React.ReactNode;
category: "file" | "block" | "entry";
command: (props: { editor: Editor; range: Range }) => void;
};
/** Search function signature accepted by createWorkspaceMention. */
export type MentionSearchFn = (query: string, limit?: number) => SearchIndexItem[];
function nodeTypeIcon(type: string) {
switch (type) {
case "document":
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
);
case "object":
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
</svg>
);
case "report":
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10" />
<line x1="18" x2="18" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>
);
default:
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
);
}
}
// --- Block command icons ---
const headingIcon = (level: number) => (
<span className="slash-cmd-icon-text">H{level}</span>
);
const bulletListIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" x2="21" y1="6" y2="6" /><line x1="8" x2="21" y1="12" y2="12" /><line x1="8" x2="21" y1="18" y2="18" />
<line x1="3" x2="3.01" y1="6" y2="6" /><line x1="3" x2="3.01" y1="12" y2="12" /><line x1="3" x2="3.01" y1="18" y2="18" />
</svg>
);
const orderedListIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="10" x2="21" y1="6" y2="6" /><line x1="10" x2="21" y1="12" y2="12" /><line x1="10" x2="21" y1="18" y2="18" />
<path d="M4 6h1v4" /><path d="M4 10h2" /><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
</svg>
);
const blockquoteIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z" />
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3z" />
</svg>
);
const codeBlockIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
</svg>
);
const horizontalRuleIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="2" x2="22" y1="12" y2="12" />
</svg>
);
const imageIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
);
const tableIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
</svg>
);
const taskListIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="5" width="6" height="6" rx="1" /><path d="m3 17 2 2 4-4" /><line x1="13" x2="21" y1="6" y2="6" /><line x1="13" x2="21" y1="18" y2="18" />
</svg>
);
const reportIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10" />
<line x1="18" x2="18" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>
);
const entryIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8Z" />
<path d="M15 3v4a2 2 0 0 0 2 2h4" />
<path d="M10 16h4" /><path d="M10 12h4" />
</svg>
);
// --- Build items ---
/** Convert a SearchIndexItem to a SlashItem for the @ mention popup. */
function searchItemToSlashItem(item: SearchIndexItem): SlashItem {
if (item.kind === "entry") {
const label = item.label || `(${item.objectName} entry)`;
return {
title: label,
description: item.fields
? Object.entries(item.fields)
.slice(0, 2)
.map(([k, v]) => `${k}: ${v}`)
.join(" | ")
: undefined,
badge: item.objectName,
icon: entryIcon,
category: "entry",
command: ({ editor, range }: { editor: Editor; range: Range }) => {
const href = buildEntryLink(item.objectName!, item.entryId!);
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "text",
text: label,
marks: [{ type: "link", attrs: { href, target: null } }],
})
.run();
},
};
}
const nodeType = item.nodeType ?? (item.kind === "object" ? "object" : "file");
const label = item.label || item.path || item.id;
return {
title: label,
description: item.sublabel,
icon: nodeTypeIcon(nodeType),
category: "file",
command: ({ editor, range }: { editor: Editor; range: Range }) => {
const href = buildFileLink(item.path ?? item.id);
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "text",
text: label,
marks: [
{ type: "link", attrs: { href, target: null } },
],
})
.run();
},
};
}
function buildBlockCommands(): SlashItem[] {
return [
{
title: "Heading 1",
description: "Large section heading",
icon: headingIcon(1),
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
},
},
{
title: "Heading 2",
description: "Medium section heading",
icon: headingIcon(2),
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
},
},
{
title: "Heading 3",
description: "Small section heading",
icon: headingIcon(3),
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
},
},
{
title: "Bullet List",
description: "Unordered list",
icon: bulletListIcon,
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: "Numbered List",
description: "Ordered list",
icon: orderedListIcon,
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: "Task List",
description: "Checklist with checkboxes",
icon: taskListIcon,
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: "Blockquote",
description: "Quote block",
icon: blockquoteIcon,
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
},
},
{
title: "Code Block",
description: "Fenced code block",
icon: codeBlockIcon,
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
},
},
{
title: "Horizontal Rule",
description: "Divider line",
icon: horizontalRuleIcon,
category: "block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: "Image",
description: "Insert image from URL",
icon: imageIcon,
category: "block",
command: ({ editor, range }) => {
const url = window.prompt("Image URL:");
if (url) {
editor.chain().focus().deleteRange(range).setImage({ src: url }).run();
}
},
},
{
title: "Table",
description: "Insert a 3x3 table",
icon: tableIcon,
category: "block",
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
},
},
{
title: "Report Chart",
description: "Interactive report-json block",
icon: reportIcon,
category: "block",
command: ({ editor, range }) => {
const template = JSON.stringify(
{
version: 1,
title: "New Report",
panels: [
{
id: "panel-1",
title: "Chart",
type: "bar",
sql: "SELECT 1 as x, 10 as y",
mapping: { xAxis: "x", yAxis: ["y"] },
},
],
},
null,
2,
);
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "reportBlock",
attrs: { config: template },
})
.run();
},
},
];
}
// buildFileItems removed -- replaced by searchItemToSlashItem + search index
// --- Popup Component ---
type CommandListRef = {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
};
type CommandListProps = {
items: SlashItem[];
command: (item: SlashItem) => void;
};
const CommandList = forwardRef<CommandListRef, CommandListProps>(
({ items, command }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
// Scroll selected into view
useEffect(() => {
const el = listRef.current?.children[selectedIndex] as HTMLElement | undefined;
el?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (item) {
command(item);
}
},
[items, command],
);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
setSelectedIndex((i) => (i + items.length - 1) % items.length);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((i) => (i + 1) % items.length);
return true;
}
if (event.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
},
}));
if (items.length === 0) {
return (
<div className="slash-cmd-popup">
<div className="slash-cmd-empty">No results</div>
</div>
);
}
return (
<div className="slash-cmd-popup" ref={listRef}>
{items.map((item, index) => (
<button
type="button"
key={`${item.category}-${item.title}-${index}`}
className={`slash-cmd-item ${index === selectedIndex ? "slash-cmd-item-active" : ""}`}
onClick={() => selectItem(index)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span className="slash-cmd-item-icon">{item.icon}</span>
<span className="slash-cmd-item-body">
<span className="slash-cmd-item-title">
{item.title}
{item.badge && (
<span className="slash-cmd-item-badge">{item.badge}</span>
)}
</span>
{item.description && (
<span className="slash-cmd-item-desc">{item.description}</span>
)}
</span>
</button>
))}
</div>
);
},
);
CommandList.displayName = "CommandList";
// --- Floating wrapper that renders into a portal ---
function SlashPopupRenderer({
items,
command,
clientRect,
componentRef,
}: {
items: SlashItem[];
command: (item: SlashItem) => void;
clientRect: (() => DOMRect | null) | null | undefined;
componentRef: React.RefObject<CommandListRef | null>;
}) {
const popupRef = useRef<HTMLDivElement>(null);
// Position popup near the cursor
useLayoutEffect(() => {
if (!popupRef.current || !clientRect) {return;}
const rect = clientRect();
if (!rect) {return;}
const el = popupRef.current;
el.style.position = "fixed";
el.style.left = `${rect.left}px`;
el.style.top = `${rect.bottom + 4}px`;
el.style.zIndex = "50";
}, [clientRect, items]);
return createPortal(
<div ref={popupRef}>
<CommandList ref={componentRef} items={items} command={command} />
</div>,
document.body,
);
}
// --- Shared suggestion render factory ---
function createSuggestionRenderer() {
return () => {
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof import("react-dom/client").createRoot> | null = null;
const componentRef: React.RefObject<CommandListRef | null> = { current: null };
return {
onStart: (props: {
items: SlashItem[];
command: (item: SlashItem) => void;
clientRect?: (() => DOMRect | null) | null;
}) => {
container = document.createElement("div");
document.body.appendChild(container);
void import("react-dom/client").then(({ createRoot }) => {
root = createRoot(container!);
root.render(
<SlashPopupRenderer
items={props.items}
command={props.command}
clientRect={props.clientRect}
componentRef={componentRef}
/>,
);
});
},
onUpdate: (props: {
items: SlashItem[];
command: (item: SlashItem) => void;
clientRect?: (() => DOMRect | null) | null;
}) => {
root?.render(
<SlashPopupRenderer
items={props.items}
command={props.command}
clientRect={props.clientRect}
componentRef={componentRef}
/>,
);
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
root?.unmount();
container?.remove();
container = null;
root = null;
return true;
}
return componentRef.current?.onKeyDown(props) ?? false;
},
onExit: () => {
root?.unmount();
container?.remove();
container = null;
root = null;
},
};
};
}
// --- Extension factories ---
/**
* "/" slash command -- markdown block commands only (headings, lists, code, etc.)
*/
export function createSlashCommand() {
const blockCommands = buildBlockCommands();
return Extension.create({
name: "slashCommand",
addOptions() {
return {
suggestion: {
char: "/",
pluginKey: slashCommandPluginKey,
startOfLine: false,
command: ({ editor, range, props: item }: { editor: Editor; range: Range; props: SlashItem }) => {
item.command({ editor, range });
},
items: ({ query }: { query: string }) => {
const q = query.toLowerCase();
if (!q) {return blockCommands;}
return blockCommands.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
(item.description?.toLowerCase().includes(q) ?? false),
);
},
render: createSuggestionRenderer(),
} satisfies Partial<SuggestionOptions<SlashItem>>,
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
}
/**
* "@" mention command -- unified workspace search (files + objects + entries).
* Accepts a search function from the useSearchIndex hook for fast fuzzy matching.
*/
export function createWorkspaceMention(searchFn: MentionSearchFn) {
return Extension.create({
name: "fileMention",
addOptions() {
return {
suggestion: {
char: "@",
pluginKey: fileMentionPluginKey,
startOfLine: false,
command: ({ editor, range, props: item }: { editor: Editor; range: Range; props: SlashItem }) => {
item.command({ editor, range });
},
items: ({ query }: { query: string }) => {
const results = searchFn(query, 15);
return results.map(searchItemToSlashItem);
},
render: createSuggestionRenderer(),
} satisfies Partial<SuggestionOptions<SlashItem>>,
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
}
/**
* "@" mention command -- legacy file-only cross-linking (fallback).
* @deprecated Use createWorkspaceMention with useSearchIndex instead.
*/
export function createFileMention(tree: TreeNode[]) {
function flattenTree(nodes: TreeNode[]): TreeNode[] {
const result: TreeNode[] = [];
for (const node of nodes) {
if (node.type !== "folder") {result.push(node);}
if (node.children) {result.push(...flattenTree(node.children));}
}
return result;
}
const flatFiles = flattenTree(tree);
const searchItems: SearchIndexItem[] = flatFiles.map((node) => ({
id: node.path,
label: node.name.replace(/\.md$/, ""),
sublabel: node.path,
kind: (node.type === "object" ? "object" : "file") as SearchIndexItem["kind"],
path: node.path,
nodeType: node.type as SearchIndexItem["nodeType"],
}));
const searchFn: MentionSearchFn = (query, limit = 15) => {
if (!query) {return searchItems.slice(0, limit);}
const q = query.toLowerCase();
return searchItems
.filter(
(item) =>
item.label.toLowerCase().includes(q) ||
(item.sublabel?.toLowerCase().includes(q) ?? false),
)
.slice(0, limit);
};
return createWorkspaceMention(searchFn);
}