openclaw/apps/web/lib/workspace-links.ts
kumarabhirup 5d43186a2b
Dench workspace: unified @ mention search, entry detail modal, and workspace link system
New libraries:
- workspace-links.ts: builders, parsers, and type guards for workspace URLs
  (/workspace?path=... for files/objects, /workspace?entry=objName:id for entries).
  Replaces ad-hoc relative-path links with real navigable URLs.
- search-index.ts: useSearchIndex hook that fetches a unified search index from
  the API and provides Fuse.js-powered fuzzy search across files, objects, and
  database entries. Exposes a stable ref-based search function safe for capture
  in tiptap extensions.

New API routes:
- GET /api/workspace/search-index: builds a flat search index from the filesystem
  tree (knowledge/, reports/, top-level files) and all DuckDB object entries with
  display-field labels and preview fields.
- GET /api/workspace/objects/[name]/entries/[id]: fetches a single entry with
  all field values, resolved relation labels, reverse relations (incoming links
  from other objects), and the effective display field.

New component:
- EntryDetailModal: slide-over modal for viewing an individual entry's fields,
  enum badges, user avatars, clickable relation chips (navigate to related
  entries), reverse relation sections, and timestamps. Supports Escape to close
  and backdrop click dismiss.

Slash command refactor (slash-command.tsx):
- New createWorkspaceMention(searchFn) replaces the old file-only @ mention with
  unified search across files, objects, and entries.
- searchItemToSlashItem() converts search index items into tiptap suggestion
  items with proper icons, badges (object name pill for entries), and link
  insertion commands using real workspace URLs.
- Legacy createFileMention(tree) now delegates to createWorkspaceMention with a
  simple substring-match fallback for when no search index is available.

Editor integration (markdown-editor.tsx, document-view.tsx):
- MarkdownEditor accepts optional searchFn prop; when provided, uses
  createWorkspaceMention instead of the legacy createFileMention.
- Link click interception now uses the shared isWorkspaceLink() helper and
  registers handlers in capture phase for reliable interception.
- DocumentView forwards searchFn to editor and adds a delegated click handler
  in read mode to intercept workspace links and navigate via onNavigate.

Object table (object-table.tsx):
- Added onEntryClick prop; table rows are now clickable with cursor-pointer
  styling, firing the callback with the entry ID.

Workspace page (page.tsx):
- Integrates useSearchIndex hook and passes search function down to editor.
- Entry detail modal state with URL synchronization (?entry=objName:id param).
- New resolveNode() with fallback strategies: exact match, knowledge/ prefix
  toggle, and last-segment object name matching.
- Unified handleEditorNavigate() dispatches /workspace?entry=... to the modal
  and /workspace?path=... to file/object navigation.
- URL bar syncs with activePath via router.replace (no full page reloads).
- Top-level container click safety net catches any workspace link clicks that
  bubble up unhandled.

Styles (globals.css):
- Added .slash-cmd-item-badge for object-name pills in the @ mention popup.
2026-02-11 21:41:23 -08:00

100 lines
2.9 KiB
TypeScript

/**
* Workspace link utilities.
*
* All workspace links use REAL URLs so they work if the browser follows them:
* Files/docs: /workspace?path=knowledge/path/to/doc.md
* Objects: /workspace?path=knowledge/leads
* Entries: /workspace?entry=leads:abc123
*/
export type WorkspaceLink =
| { kind: "file"; path: string }
| { kind: "entry"; objectName: string; entryId: string };
// --- Builders ---
/** Build a real URL for an entry detail modal. */
export function buildEntryLink(objectName: string, entryId: string): string {
return `/workspace?entry=${encodeURIComponent(objectName)}:${encodeURIComponent(entryId)}`;
}
/** Build a real URL for a file or object in the workspace. */
export function buildFileLink(path: string): string {
return `/workspace?path=${encodeURIComponent(path)}`;
}
// --- Parsers ---
/** Parse a workspace URL into a structured link. Returns null if not a workspace link. */
export function parseWorkspaceLink(href: string): WorkspaceLink | null {
// Handle full or relative /workspace?... URLs
let url: URL | null = null;
try {
if (href.startsWith("/workspace")) {
url = new URL(href, "http://localhost");
} else if (href.includes("/workspace?")) {
url = new URL(href);
}
} catch {
// not a valid URL
}
if (url) {
const entryParam = url.searchParams.get("entry");
if (entryParam && entryParam.includes(":")) {
const colonIdx = entryParam.indexOf(":");
return {
kind: "entry",
objectName: entryParam.slice(0, colonIdx),
entryId: entryParam.slice(colonIdx + 1),
};
}
const pathParam = url.searchParams.get("path");
if (pathParam) {
return { kind: "file", path: pathParam };
}
}
// Legacy: handle old @entry/ format for backward compat
if (href.startsWith("@entry/")) {
const rest = href.slice("@entry/".length);
const slashIdx = rest.indexOf("/");
if (slashIdx > 0) {
return {
kind: "entry",
objectName: rest.slice(0, slashIdx),
entryId: rest.slice(slashIdx + 1),
};
}
}
return null;
}
/** Check if an href is a workspace link (either /workspace?... or legacy @entry/). */
export function isWorkspaceLink(href: string): boolean {
return (
href.startsWith("/workspace?") ||
href.startsWith("/workspace#") ||
href === "/workspace" ||
href.startsWith("@entry/")
);
}
/** Check if an href is a workspace-internal link (not external URL). */
export function isInternalLink(href: string): boolean {
return (
!href.startsWith("http://") &&
!href.startsWith("https://") &&
!href.startsWith("mailto:")
);
}
/** Check if an href is an entry link (any format). */
export function isEntryLink(href: string): boolean {
if (href.startsWith("@entry/")) {return true;}
if (href.startsWith("/workspace") && href.includes("entry=")) {return true;}
return false;
}