From 5d43186a2b645d33a3ee9743822c8f2082a56825 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Wed, 11 Feb 2026 21:41:23 -0800 Subject: [PATCH] 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. --- .../objects/[name]/entries/[id]/route.ts | 304 +++++++++++ .../app/api/workspace/search-index/route.ts | 302 +++++++++++ .../components/workspace/document-view.tsx | 29 +- .../workspace/entry-detail-modal.tsx | 504 ++++++++++++++++++ .../components/workspace/markdown-editor.tsx | 35 +- .../app/components/workspace/object-table.tsx | 8 +- .../components/workspace/slash-command.tsx | 181 +++++-- apps/web/app/globals.css | 14 + apps/web/app/workspace/page.tsx | 208 +++++++- apps/web/lib/search-index.ts | 127 +++++ apps/web/lib/workspace-links.ts | 99 ++++ 11 files changed, 1719 insertions(+), 92 deletions(-) create mode 100644 apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts create mode 100644 apps/web/app/api/workspace/search-index/route.ts create mode 100644 apps/web/app/components/workspace/entry-detail-modal.tsx create mode 100644 apps/web/lib/search-index.ts create mode 100644 apps/web/lib/workspace-links.ts diff --git a/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts b/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts new file mode 100644 index 00000000000..2fd0ce7f7fa --- /dev/null +++ b/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts @@ -0,0 +1,304 @@ +import { duckdbQuery, duckdbPath, parseRelationValue } from "@/lib/workspace"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +// --- Types --- + +type ObjectRow = { + id: string; + name: string; + description?: string; + icon?: string; + default_view?: string; + display_field?: string; +}; + +type FieldRow = { + id: string; + name: string; + type: string; + description?: string; + required?: boolean; + enum_values?: string; + enum_colors?: string; + enum_multiple?: boolean; + related_object_id?: string; + relationship_type?: string; + sort_order?: number; +}; + +// --- Helpers --- + +function sqlEscape(s: string): string { + return s.replace(/'/g, "''"); +} + +function tryParseJson(value: unknown): unknown { + if (typeof value !== "string") {return value;} + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function resolveDisplayField(obj: ObjectRow, fields: FieldRow[]): string { + if (obj.display_field) {return obj.display_field;} + const nameField = fields.find( + (f) => /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name), + ); + if (nameField) {return nameField.name;} + const textField = fields.find((f) => f.type === "text"); + if (textField) {return textField.name;} + return fields[0]?.name ?? "id"; +} + +// --- Route handler --- + +export async function GET( + _req: Request, + { params }: { params: Promise<{ name: string; id: string }> }, +) { + const { name, id } = await params; + + if (!duckdbPath()) { + return Response.json({ error: "DuckDB not found" }, { status: 404 }); + } + + // Validate inputs + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + return Response.json({ error: "Invalid object name" }, { status: 400 }); + } + if (!id || id.length > 64) { + return Response.json({ error: "Invalid entry ID" }, { status: 400 }); + } + + // Fetch object + const objects = duckdbQuery( + `SELECT * FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`, + ); + if (objects.length === 0) { + return Response.json({ error: `Object '${name}' not found` }, { status: 404 }); + } + const obj = objects[0]; + + // Fetch fields + const fields = duckdbQuery( + `SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`, + ); + + // Fetch entry field values + const entryRows = duckdbQuery<{ + entry_id: string; + created_at: string; + updated_at: string; + field_name: string; + value: string | null; + }>( + `SELECT e.id as entry_id, e.created_at, e.updated_at, + f.name as field_name, ef.value + FROM entries e + JOIN entry_fields ef ON ef.entry_id = e.id + JOIN fields f ON f.id = ef.field_id + WHERE e.id = '${sqlEscape(id)}' + AND e.object_id = '${sqlEscape(obj.id)}'`, + ); + + if (entryRows.length === 0) { + // Check if entry exists at all + const exists = duckdbQuery<{ cnt: number }>( + `SELECT COUNT(*) as cnt FROM entries WHERE id = '${sqlEscape(id)}' AND object_id = '${sqlEscape(obj.id)}'`, + ); + if (!exists[0] || exists[0].cnt === 0) { + return Response.json({ error: "Entry not found" }, { status: 404 }); + } + } + + // Pivot into a single record + const entry: Record = { entry_id: id }; + for (const row of entryRows) { + entry.created_at ??= row.created_at; + entry.updated_at ??= row.updated_at; + if (row.field_name) {entry[row.field_name] = row.value;} + } + + // Parse enum JSON strings in fields + const parsedFields = fields.map((f) => ({ + ...f, + enum_values: f.enum_values ? tryParseJson(f.enum_values) : undefined, + enum_colors: f.enum_colors ? tryParseJson(f.enum_colors) : undefined, + })); + + // Resolve relation labels for this entry + const relationLabels: Record> = {}; + const relatedObjectNames: Record = {}; + + const relationFields = fields.filter( + (f) => f.type === "relation" && f.related_object_id, + ); + + for (const rf of relationFields) { + const relatedObjs = duckdbQuery( + `SELECT * FROM objects WHERE id = '${sqlEscape(rf.related_object_id!)}' LIMIT 1`, + ); + if (relatedObjs.length === 0) {continue;} + const relObj = relatedObjs[0]; + relatedObjectNames[rf.name] = relObj.name; + + const val = entry[rf.name]; + if (val == null || val === "") { + relationLabels[rf.name] = {}; + continue; + } + + const ids = parseRelationValue(String(val)); + if (ids.length === 0) { + relationLabels[rf.name] = {}; + continue; + } + + const relFields = duckdbQuery( + `SELECT * FROM fields WHERE object_id = '${sqlEscape(relObj.id)}' ORDER BY sort_order`, + ); + const displayFieldName = resolveDisplayField(relObj, relFields); + + const idList = ids.map((i) => `'${sqlEscape(i)}'`).join(","); + const displayRows = duckdbQuery<{ entry_id: string; value: string }>( + `SELECT e.id as entry_id, ef.value + FROM entries e + JOIN entry_fields ef ON ef.entry_id = e.id + JOIN fields f ON f.id = ef.field_id + WHERE e.id IN (${idList}) + AND f.object_id = '${sqlEscape(relObj.id)}' + AND f.name = '${sqlEscape(displayFieldName)}'`, + ); + + const labelMap: Record = {}; + for (const row of displayRows) { + labelMap[row.entry_id] = row.value || row.entry_id; + } + for (const i of ids) { + if (!labelMap[i]) {labelMap[i] = i;} + } + relationLabels[rf.name] = labelMap; + } + + // Enrich fields with related object names + const enrichedFields = parsedFields.map((f) => ({ + ...f, + related_object_name: + f.type === "relation" ? relatedObjectNames[f.name] : undefined, + })); + + // Find reverse relations: other objects linking TO this entry + const reverseRelations = findReverseRelationsForEntry(obj.id, id); + + const effectiveDisplayField = resolveDisplayField(obj, fields); + + return Response.json({ + object: obj, + fields: enrichedFields, + entry, + relationLabels, + reverseRelations, + effectiveDisplayField, + }); +} + +// --- Reverse relations for a single entry --- + +type ReverseRelation = { + fieldName: string; + sourceObjectName: string; + sourceObjectId: string; + displayField: string; + links: Array<{ id: string; label: string }>; +}; + +/** + * Find entries in other objects that link TO this specific entry via relation fields. + */ +function findReverseRelationsForEntry( + objectId: string, + entryId: string, +): ReverseRelation[] { + // Find all relation fields in other objects that point to this object + const reverseFields = duckdbQuery< + { id: string; name: string; object_id: string; source_object_name: string } + >( + `SELECT f.id, f.name, f.object_id, o.name as source_object_name + FROM fields f + JOIN objects o ON o.id = f.object_id + WHERE f.type = 'relation' + AND f.related_object_id = '${sqlEscape(objectId)}'`, + ); + + if (reverseFields.length === 0) {return [];} + + const result: ReverseRelation[] = []; + + for (const rrf of reverseFields) { + // Find source entries that reference this specific entry ID + const refRows = duckdbQuery<{ source_entry_id: string; target_value: string }>( + `SELECT ef.entry_id as source_entry_id, ef.value as target_value + FROM entry_fields ef + WHERE ef.field_id = '${sqlEscape(rrf.id)}' + AND ef.value IS NOT NULL + AND ef.value != ''`, + ); + + // Filter to only rows that actually reference our entryId + const matchingSourceIds: string[] = []; + for (const row of refRows) { + const targetIds = parseRelationValue(row.target_value); + if (targetIds.includes(entryId)) { + matchingSourceIds.push(row.source_entry_id); + } + } + + if (matchingSourceIds.length === 0) {continue;} + + // Get source object's fields to resolve display labels + const sourceObj = duckdbQuery( + `SELECT * FROM objects WHERE id = '${sqlEscape(rrf.object_id)}' LIMIT 1`, + ); + if (sourceObj.length === 0) {continue;} + + const sourceFields = duckdbQuery( + `SELECT * FROM fields WHERE object_id = '${sqlEscape(rrf.object_id)}' ORDER BY sort_order`, + ); + const displayFieldName = resolveDisplayField(sourceObj[0], sourceFields); + + // Get display labels for matching source entries + const idList = matchingSourceIds.map((i) => `'${sqlEscape(i)}'`).join(","); + const displayRows = duckdbQuery<{ entry_id: string; value: string }>( + `SELECT ef.entry_id, ef.value + FROM entry_fields ef + JOIN fields f ON f.id = ef.field_id + WHERE ef.entry_id IN (${idList}) + AND f.name = '${sqlEscape(displayFieldName)}' + AND f.object_id = '${sqlEscape(rrf.object_id)}'`, + ); + + const displayMap: Record = {}; + for (const row of displayRows) { + displayMap[row.entry_id] = row.value || row.entry_id; + } + + const links = matchingSourceIds.map((sid) => ({ + id: sid, + label: displayMap[sid] || sid, + })); + + result.push({ + fieldName: rrf.name, + sourceObjectName: rrf.source_object_name, + sourceObjectId: rrf.object_id, + displayField: displayFieldName, + links, + }); + } + + return result; +} diff --git a/apps/web/app/api/workspace/search-index/route.ts b/apps/web/app/api/workspace/search-index/route.ts new file mode 100644 index 00000000000..979c9977764 --- /dev/null +++ b/apps/web/app/api/workspace/search-index/route.ts @@ -0,0 +1,302 @@ +import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs"; +import { join } from "node:path"; +import { + resolveDenchRoot, + parseSimpleYaml, + duckdbQuery, + duckdbPath, + isDatabaseFile, +} from "@/lib/workspace"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +// --- Types --- + +export type SearchIndexItem = { + /** Unique key: relative path for files, entryId for entries */ + id: string; + /** Primary display text (filename or display-field value) */ + label: string; + /** Secondary text (path for files, object name for entries) */ + sublabel?: string; + /** Item kind for grouping and icons */ + kind: "file" | "object" | "entry"; + /** Icon hint */ + icon?: string; + + // Entry-specific + objectName?: string; + entryId?: string; + /** First few field key-value pairs for search and preview */ + fields?: Record; + + // File/object-specific + path?: string; + nodeType?: "document" | "folder" | "file" | "report" | "database"; +}; + +// --- DB types --- + +type ObjectRow = { + id: string; + name: string; + description?: string; + icon?: string; + default_view?: string; + display_field?: string; +}; + +type FieldRow = { + id: string; + name: string; + type: string; + sort_order?: number; +}; + +type EavRow = { + entry_id: string; + created_at: string; + updated_at: string; + field_name: string; + value: string | null; +}; + +// --- Helpers --- + +function sqlEscape(s: string): string { + return s.replace(/'/g, "''"); +} + +/** Determine the display field (same heuristic as the objects route). */ +function resolveDisplayField(obj: ObjectRow, fields: FieldRow[]): string { + if (obj.display_field) {return obj.display_field;} + + const nameField = fields.find( + (f) => /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name), + ); + if (nameField) {return nameField.name;} + + const textField = fields.find((f) => f.type === "text"); + if (textField) {return textField.name;} + + return fields[0]?.name ?? "id"; +} + +/** Flatten a tree recursively to produce file/object search items. */ +function flattenTree( + absDir: string, + relBase: string, + dbObjects: Map, + items: SearchIndexItem[], +) { + let entries: Dirent[]; + try { + entries = readdirSync(absDir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (entry.name.startsWith(".")) {continue;} + + const absPath = join(absDir, entry.name); + const relPath = relBase ? `${relBase}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + const dbObj = dbObjects.get(entry.name); + // Check for .object.yaml + const yamlPath = join(absPath, ".object.yaml"); + const hasYaml = existsSync(yamlPath); + + if (dbObj || hasYaml) { + let icon: string | undefined; + if (hasYaml) { + try { + const parsed = parseSimpleYaml( + readFileSync(yamlPath, "utf-8"), + ); + icon = parsed.icon as string | undefined; + } catch { + // ignore + } + } + + items.push({ + id: relPath, + label: entry.name, + sublabel: relPath, + kind: "object", + icon: icon ?? dbObj?.icon, + path: relPath, + nodeType: undefined, + }); + } else { + // Regular folder -- don't add as item, but recurse + } + + flattenTree(absPath, relPath, dbObjects, items); + } else if (entry.isFile()) { + const isReport = entry.name.endsWith(".report.json"); + const ext = entry.name.split(".").pop()?.toLowerCase(); + const isDocument = ext === "md" || ext === "mdx"; + const isDatabase = isDatabaseFile(entry.name); + + items.push({ + id: relPath, + label: entry.name.replace(/\.md$/, ""), + sublabel: relPath, + kind: "file", + path: relPath, + nodeType: isReport + ? "report" + : isDatabase + ? "database" + : isDocument + ? "document" + : "file", + }); + } + } +} + +/** Fetch all entries from all objects and produce search items. */ +function buildEntryItems(): SearchIndexItem[] { + const items: SearchIndexItem[] = []; + + const objects = duckdbQuery( + "SELECT * FROM objects ORDER BY name", + ); + + for (const obj of objects) { + const fields = duckdbQuery( + `SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`, + ); + const displayField = resolveDisplayField(obj, fields); + // Pick the first few text-like fields for searchable preview (max 4) + const previewFields = fields + .filter((f) => !["relation", "richtext"].includes(f.type)) + .slice(0, 4); + + // Try PIVOT view first, then raw EAV + let entries: Record[] = duckdbQuery( + `SELECT * FROM v_${obj.name} ORDER BY created_at DESC LIMIT 500`, + ); + + if (entries.length === 0) { + const rawRows = duckdbQuery( + `SELECT e.id as entry_id, e.created_at, e.updated_at, + f.name as field_name, ef.value + FROM entries e + JOIN entry_fields ef ON ef.entry_id = e.id + JOIN fields f ON f.id = ef.field_id + WHERE e.object_id = '${sqlEscape(obj.id)}' + ORDER BY e.created_at DESC + LIMIT 2500`, + ); + + // Pivot manually + const grouped = new Map>(); + for (const row of rawRows) { + let entry = grouped.get(row.entry_id); + if (!entry) { + entry = { entry_id: row.entry_id }; + grouped.set(row.entry_id, entry); + } + if (row.field_name) {entry[row.field_name] = row.value;} + } + entries = Array.from(grouped.values()); + } + + for (const entry of entries) { + const entryId = String(entry.entry_id ?? ""); + if (!entryId) {continue;} + + const displayValue = String(entry[displayField] ?? ""); + const fieldPreview: Record = {}; + for (const f of previewFields) { + const val = entry[f.name]; + if (val != null && val !== "") { + fieldPreview[f.name] = String(val); + } + } + + items.push({ + id: `entry:${obj.name}:${entryId}`, + label: displayValue || `(${obj.name} entry)`, + sublabel: obj.name, + kind: "entry", + icon: obj.icon, + objectName: obj.name, + entryId, + fields: Object.keys(fieldPreview).length > 0 ? fieldPreview : undefined, + }); + } + } + + return items; +} + +// --- Route handler --- + +export async function GET() { + const items: SearchIndexItem[] = []; + + // 1. Files + objects from tree + const root = resolveDenchRoot(); + if (root) { + const dbObjects = new Map(); + if (duckdbPath()) { + const objs = duckdbQuery( + "SELECT * FROM objects", + ); + for (const o of objs) {dbObjects.set(o.name, o);} + } + + const knowledgeDir = join(root, "knowledge"); + if (existsSync(knowledgeDir)) { + flattenTree(knowledgeDir, "knowledge", dbObjects, items); + } + + const reportsDir = join(root, "reports"); + if (existsSync(reportsDir)) { + flattenTree(reportsDir, "reports", dbObjects, items); + } + + // Top-level files + try { + const topLevel = readdirSync(root, { withFileTypes: true }); + for (const entry of topLevel) { + if (!entry.isFile() || entry.name.startsWith(".")) {continue;} + const ext = entry.name.split(".").pop()?.toLowerCase(); + const isDoc = ext === "md" || ext === "mdx"; + const isDb = isDatabaseFile(entry.name); + const isReport = entry.name.endsWith(".report.json"); + + items.push({ + id: entry.name, + label: entry.name.replace(/\.md$/, ""), + sublabel: entry.name, + kind: "file", + path: entry.name, + nodeType: isReport + ? "report" + : isDb + ? "database" + : isDoc + ? "document" + : "file", + }); + } + } catch { + // skip + } + } + + // 2. Entries from all objects + if (duckdbPath()) { + items.push(...buildEntryItems()); + } + + return Response.json({ items }); +} diff --git a/apps/web/app/components/workspace/document-view.tsx b/apps/web/app/components/workspace/document-view.tsx index 0e18ee07268..905763a53e7 100644 --- a/apps/web/app/components/workspace/document-view.tsx +++ b/apps/web/app/components/workspace/document-view.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState } from "react"; +import { useState, useCallback, type MouseEvent as ReactMouseEvent } from "react"; import dynamic from "next/dynamic"; import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks"; -import type { TreeNode } from "./slash-command"; +import { isWorkspaceLink } from "@/lib/workspace-links"; +import type { TreeNode, MentionSearchFn } from "./slash-command"; // Load markdown renderer client-only to avoid SSR issues with ESM-only packages const MarkdownContent = dynamic( @@ -58,6 +59,7 @@ type DocumentViewProps = { tree?: TreeNode[]; onSave?: () => void; onNavigate?: (path: string) => void; + searchFn?: MentionSearchFn; }; export function DocumentView({ @@ -67,6 +69,7 @@ export function DocumentView({ tree, onSave, onNavigate, + searchFn, }: DocumentViewProps) { const [editMode, setEditMode] = useState(!!filePath); @@ -91,6 +94,7 @@ export function DocumentView({ onSave={onSave} onNavigate={onNavigate} onSwitchToRead={() => setEditMode(false)} + searchFn={searchFn} /> ); @@ -99,8 +103,27 @@ export function DocumentView({ // Check if the markdown contains embedded report-json blocks const hasReports = hasReportBlocks(markdownBody); + // Intercept workspace-internal links in read mode (delegated click handler) + const handleLinkClick = useCallback( + (event: ReactMouseEvent) => { + if (!onNavigate) {return;} + const target = event.target as HTMLElement; + const link = target.closest("a"); + if (!link) {return;} + const href = link.getAttribute("href"); + if (!href) {return;} + if (isWorkspaceLink(href)) { + event.preventDefault(); + event.stopPropagation(); + onNavigate(href); + } + }, + [onNavigate], + ); + return ( -
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
{/* Header row with title + edit button */}
{displayTitle && ( diff --git a/apps/web/app/components/workspace/entry-detail-modal.tsx b/apps/web/app/components/workspace/entry-detail-modal.tsx new file mode 100644 index 00000000000..897984b6188 --- /dev/null +++ b/apps/web/app/components/workspace/entry-detail-modal.tsx @@ -0,0 +1,504 @@ +"use client"; + +import { useEffect, useState, useCallback, useRef } from "react"; +import { buildEntryLink } from "@/lib/workspace-links"; + +// --- Types --- + +type Field = { + id: string; + name: string; + type: string; + enum_values?: string[]; + enum_colors?: string[]; + enum_multiple?: boolean; + related_object_id?: string; + relationship_type?: string; + related_object_name?: string; + sort_order?: number; +}; + +type ReverseRelation = { + fieldName: string; + sourceObjectName: string; + sourceObjectId: string; + displayField: string; + links: Array<{ id: string; label: string }>; +}; + +type EntryDetailData = { + object: { + id: string; + name: string; + description?: string; + icon?: string; + }; + fields: Field[]; + entry: Record; + relationLabels?: Record>; + reverseRelations?: ReverseRelation[]; + effectiveDisplayField?: string; +}; + +type EntryDetailModalProps = { + objectName: string; + entryId: string; + members?: Array<{ id: string; name: string; email: string; role: string }>; + onClose: () => void; + /** Navigate to another entry (opens new modal). */ + onNavigateEntry?: (objectName: string, entryId: string) => void; + /** Navigate to an object table view. */ + onNavigateObject?: (objectName: string) => void; +}; + +// --- Helpers --- + +function parseRelationValue(value: string | null | undefined): string[] { + if (!value) {return [];} + const trimmed = value.trim(); + if (!trimmed) {return [];} + if (trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) {return parsed.map(String).filter(Boolean);} + } catch { + // not JSON + } + } + return [trimmed]; +} + +// --- Cell renderers (lightweight variants of object-table ones) --- + +function EnumBadge({ + value, + enumValues, + enumColors, +}: { + value: string; + enumValues?: string[]; + enumColors?: string[]; +}) { + const idx = enumValues?.indexOf(value) ?? -1; + const color = idx >= 0 && enumColors ? enumColors[idx] : "#94a3b8"; + return ( + + {value} + + ); +} + +function UserBadge({ + value, + members, +}: { + value: unknown; + members?: Array<{ id: string; name: string }>; +}) { + const memberId = String(value); + const member = members?.find((m) => m.id === memberId); + return ( + + + {(member?.name ?? memberId).charAt(0).toUpperCase()} + + {member?.name ?? memberId} + + ); +} + +function RelationChips({ + value, + field, + relationLabels, + onNavigateEntry, +}: { + value: unknown; + field: Field; + relationLabels?: Record>; + onNavigateEntry?: (objectName: string, entryId: string) => void; +}) { + const fieldLabels = relationLabels?.[field.name]; + const ids = parseRelationValue(String(value)); + if (ids.length === 0) {return ;} + + return ( + + {ids.map((id) => { + const label = fieldLabels?.[id] ?? id; + const handleClick = field.related_object_name && onNavigateEntry + ? () => onNavigateEntry(field.related_object_name!, id) + : undefined; + return ( + + ); + })} + + ); +} + +function EmptyValue() { + return ( + -- + ); +} + +/** Render a set of reverse relation links (incoming references from another object). */ +function ReverseRelationSection({ + relation, + onNavigateEntry, +}: { + relation: ReverseRelation; + onNavigateEntry?: (objectName: string, entryId: string) => void; +}) { + const displayLinks = relation.links.slice(0, 10); + const overflow = relation.links.length - displayLinks.length; + + return ( +
+ +
+ {displayLinks.map((link) => ( + + ))} + {overflow > 0 && ( + + +{overflow} more + + )} +
+
+ ); +} + +function FieldValue({ + value, + field, + members, + relationLabels, + onNavigateEntry, +}: { + value: unknown; + field: Field; + members?: Array<{ id: string; name: string }>; + relationLabels?: Record>; + onNavigateEntry?: (objectName: string, entryId: string) => void; +}) { + if (value === null || value === undefined || value === "") {return ;} + + switch (field.type) { + case "enum": + return ( + + ); + case "boolean": { + const isTrue = value === true || value === "true" || value === "1" || value === "yes"; + return {isTrue ? "Yes" : "No"}; + } + case "user": + return ; + case "relation": + return ( + + ); + case "email": + return ( + + {String(value)} + + ); + case "richtext": + return {String(value)}; + case "number": + return {String(value)}; + case "date": + return {String(value)}; + default: + return {String(value)}; + } +} + +// --- Modal Component --- + +export function EntryDetailModal({ + objectName, + entryId, + members, + onClose, + onNavigateEntry, + onNavigateObject, +}: EntryDetailModalProps) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const backdropRef = useRef(null); + + // Fetch entry data + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + async function load() { + try { + const res = await fetch( + `/api/workspace/objects/${encodeURIComponent(objectName)}/entries/${encodeURIComponent(entryId)}`, + ); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Failed to load" })); + if (!cancelled) { + setError(err.error ?? "Failed to load entry"); + setLoading(false); + } + return; + } + const json = await res.json(); + if (!cancelled) { + setData(json); + setLoading(false); + } + } catch { + if (!cancelled) { + setError("Network error"); + setLoading(false); + } + } + } + + load(); + return () => { cancelled = true; }; + }, [objectName, entryId]); + + // Close on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") {onClose();} + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + // Close on backdrop click + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === backdropRef.current) {onClose();} + }, + [onClose], + ); + + const displayField = data?.effectiveDisplayField; + const title = displayField && data?.entry[displayField] + ? String(data.entry[displayField]) + : `${objectName} entry`; + + return ( +
+
+ {/* Header */} +
+
+ {/* Object badge */} + +

+ {loading ? "Loading..." : title} +

+
+ +
+ + {/* Content */} +
+ {loading && ( +
+
+
+ )} + {error && ( +
+

{error}

+
+ )} + {data && !loading && ( +
+ {data.fields.map((field) => { + const value = data.entry[field.name]; + return ( +
+ +
+ +
+
+ ); + })} + + {/* Reverse relations (incoming links from other objects) */} + {data.reverseRelations && data.reverseRelations.length > 0 && ( +
+
+ Linked from +
+ {data.reverseRelations.map((rr) => ( + + ))} +
+ )} + + {/* Timestamps */} + {(data.entry.created_at != null || data.entry.updated_at != null) && ( +
+ {data.entry.created_at != null && ( + Created: {String(data.entry.created_at as string)} + )} + {data.entry.updated_at != null && ( + Updated: {String(data.entry.updated_at as string)} + )} +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/components/workspace/markdown-editor.tsx b/apps/web/app/components/workspace/markdown-editor.tsx index 9a693a09ae5..0d7a8e5024c 100644 --- a/apps/web/app/components/workspace/markdown-editor.tsx +++ b/apps/web/app/components/workspace/markdown-editor.tsx @@ -16,7 +16,8 @@ import Placeholder from "@tiptap/extension-placeholder"; import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import { ReportBlockNode, preprocessReportBlocks, postprocessReportBlocks } from "./report-block-node"; -import { createSlashCommand, createFileMention, type TreeNode } from "./slash-command"; +import { createSlashCommand, createWorkspaceMention, createFileMention, type TreeNode, type MentionSearchFn } from "./slash-command"; +import { isWorkspaceLink } from "@/lib/workspace-links"; // --- Types --- @@ -31,6 +32,8 @@ export type MarkdownEditorProps = { onNavigate?: (path: string) => void; /** Switch to read-only mode (renders a "Read" button in the top bar). */ onSwitchToRead?: () => void; + /** Optional search function from useSearchIndex for fuzzy @ mention search. */ + searchFn?: MentionSearchFn; }; // --- Main component --- @@ -49,6 +52,7 @@ export function MarkdownEditor({ onSave, onNavigate, onSwitchToRead, + searchFn, }: MarkdownEditorProps) { const [saving, setSaving] = useState(false); const [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle"); @@ -60,9 +64,15 @@ export function MarkdownEditor({ // Preserve frontmatter so save can prepend it back const frontmatterRef = useRef(extractFrontmatter(rawContent ?? "")); - // "/" for block commands, "@" for file mentions + // "/" for block commands, "@" for workspace search (files + entries) const slashCommand = useMemo(() => createSlashCommand(), []); - const fileMention = useMemo(() => createFileMention(tree), [tree]); + const fileMention = useMemo( + () => searchFn ? createWorkspaceMention(searchFn) : createFileMention(tree), + // searchFn from useSearchIndex is a stable ref-based function, so this + // only re-runs on initial mount or if tree changes as fallback. + // eslint-disable-next-line react-hooks/exhaustive-deps + [searchFn, tree], + ); const editor = useEditor({ extensions: [ @@ -81,7 +91,12 @@ export function MarkdownEditor({ }), Link.configure({ openOnClick: false, - HTMLAttributes: { class: "editor-link" }, + autolink: true, + HTMLAttributes: { + class: "editor-link", + // Prevent browser from following workspace links as real URLs + rel: "noopener", + }, }), Table.configure({ resizable: false }), TableRow, @@ -212,7 +227,9 @@ export function MarkdownEditor({ }; }, [editor, insertUploadedImages]); - // Handle link clicks for workspace navigation + // Handle link clicks for workspace navigation. + // Links are real URLs like /workspace?path=... so clicking them navigates + // within the same tab. We intercept to avoid a full page reload. useEffect(() => { if (!editor || !onNavigate) {return;} @@ -224,8 +241,8 @@ export function MarkdownEditor({ const href = link.getAttribute("href"); if (!href) {return;} - // Workspace-internal link (relative path, no protocol) - if (!href.startsWith("http://") && !href.startsWith("https://") && !href.startsWith("mailto:")) { + // Intercept /workspace?... links to handle via client-side state + if (isWorkspaceLink(href)) { event.preventDefault(); event.stopPropagation(); onNavigate(href); @@ -233,8 +250,8 @@ export function MarkdownEditor({ }; const editorElement = editor.view.dom; - editorElement.addEventListener("click", handleClick); - return () => editorElement.removeEventListener("click", handleClick); + editorElement.addEventListener("click", handleClick, true); + return () => editorElement.removeEventListener("click", handleClick, true); }, [editor, onNavigate]); // Save handler diff --git a/apps/web/app/components/workspace/object-table.tsx b/apps/web/app/components/workspace/object-table.tsx index 893919afb8a..8105d9812af 100644 --- a/apps/web/app/components/workspace/object-table.tsx +++ b/apps/web/app/components/workspace/object-table.tsx @@ -33,6 +33,7 @@ type ObjectTableProps = { relationLabels?: Record>; reverseRelations?: ReverseRelation[]; onNavigateToObject?: (objectName: string) => void; + onEntryClick?: (entryId: string) => void; }; // --- Helpers --- @@ -352,6 +353,7 @@ export function ObjectTable({ relationLabels, reverseRelations, onNavigateToObject, + onEntryClick, }: ObjectTableProps) { const [sort, setSort] = useState(null); @@ -478,11 +480,15 @@ export function ObjectTable({ {sortedEntries.map((entry, idx) => ( { + const eid = String(entry.entry_id ?? ""); + if (eid && onEntryClick) {onEntryClick(eid);} + }} onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; diff --git a/apps/web/app/components/workspace/slash-command.tsx b/apps/web/app/components/workspace/slash-command.tsx index b46bad03bff..03d217038e5 100644 --- a/apps/web/app/components/workspace/slash-command.tsx +++ b/apps/web/app/components/workspace/slash-command.tsx @@ -14,6 +14,8 @@ 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"); @@ -32,25 +34,14 @@ export type TreeNode = { type SlashItem = { title: string; description?: string; + badge?: string; icon: React.ReactNode; - category: "file" | "block"; + category: "file" | "block" | "entry"; command: (props: { editor: Editor; range: Range }) => void; }; -// --- Helpers --- - -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; -} +/** Search function signature accepted by createWorkspaceMention. */ +export type MentionSearchFn = (query: string, limit?: number) => SearchIndexItem[]; function nodeTypeIcon(type: string) { switch (type) { @@ -152,8 +143,72 @@ const reportIcon = ( ); +const entryIcon = ( + + + + + +); + // --- 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 [ { @@ -300,35 +355,7 @@ function buildBlockCommands(): SlashItem[] { ]; } -function buildFileItems(tree: TreeNode[]): SlashItem[] { - const flatFiles = flattenTree(tree); - return flatFiles.map((node) => ({ - title: node.name.replace(/\.md$/, ""), - description: node.path, - icon: nodeTypeIcon(node.type), - category: "file" as const, - command: ({ editor, range }: { editor: Editor; range: Range }) => { - const label = node.name.replace(/\.md$/, ""); - // Insert as structured content so the link mark is applied properly - // (raw HTML strings get escaped by the Markdown extension) - editor - .chain() - .focus() - .deleteRange(range) - .insertContent({ - type: "text", - text: label, - marks: [ - { - type: "link", - attrs: { href: node.path, target: null }, - }, - ], - }) - .run(); - }, - })); -} +// buildFileItems removed -- replaced by searchItemToSlashItem + search index // --- Popup Component --- @@ -397,14 +424,19 @@ const CommandList = forwardRef( {items.map((item, index) => (
); } @@ -372,6 +542,9 @@ function ContentRenderer({ onNavigateToObject, onRefreshObject, onRefreshTree, + onNavigate, + onOpenEntry, + searchFn, }: { content: ContentState; workspaceExists: boolean; @@ -382,6 +555,9 @@ function ContentRenderer({ onNavigateToObject: (objectName: string) => void; onRefreshObject: () => void; onRefreshTree: () => void; + onNavigate: (href: string) => void; + onOpenEntry: (objectName: string, entryId: string) => void; + searchFn: (query: string, limit?: number) => import("@/lib/search-index").SearchIndexItem[]; }) { switch (content.kind) { case "loading": @@ -404,6 +580,7 @@ function ContentRenderer({ members={members} onNavigateToObject={onNavigateToObject} onRefreshObject={onRefreshObject} + onOpenEntry={onOpenEntry} /> ); @@ -415,13 +592,8 @@ function ContentRenderer({ filePath={activePath ?? undefined} tree={tree} onSave={onRefreshTree} - onNavigate={(path) => { - // Find the node in the tree and navigate to it - const node = findNode(tree, path); - if (node) { - onNodeSelect(node); - } - }} + onNavigate={onNavigate} + searchFn={searchFn} /> ); @@ -473,11 +645,13 @@ function ObjectView({ members, onNavigateToObject, onRefreshObject, + onOpenEntry, }: { data: ObjectData; members?: Array<{ id: string; name: string; email: string; role: string }>; onNavigateToObject: (objectName: string) => void; onRefreshObject: () => void; + onOpenEntry?: (objectName: string, entryId: string) => void; }) { const [updatingDisplayField, setUpdatingDisplayField] = useState(false); @@ -493,7 +667,6 @@ function ObjectView({ }, ); if (res.ok) { - // Refresh the object data to get updated relation labels onRefreshObject(); } } catch { @@ -503,7 +676,6 @@ function ObjectView({ } }; - // Fields eligible to be the display field (text-like types) const displayFieldCandidates = data.fields.filter( (f) => !["relation", "boolean", "richtext"].includes(f.type), ); @@ -554,7 +726,6 @@ function ObjectView({ {data.fields.length} fields - {/* Relation info badges */} {hasRelationFields && ( - {/* Display field selector */} {displayFieldCandidates.length > 0 && (
onOpenEntry(data.object.name, entryId) : undefined} /> )}
@@ -755,7 +926,6 @@ function WelcomeView({ tree: TreeNode[]; onNodeSelect: (node: TreeNode) => void; }) { - // Collect all objects and documents for quick access const objects: TreeNode[] = []; const documents: TreeNode[] = []; @@ -780,7 +950,6 @@ function WelcomeView({ Select an item from the sidebar, or browse the sections below.

- {/* Objects section */} {objects.length > 0 && (

)} - {/* Documents section */} {documents.length > 0 && (

; + path?: string; + nodeType?: "document" | "folder" | "file" | "report" | "database"; +}; + +// --- Fuse.js config --- + +const FUSE_OPTIONS: ConstructorParameters>[1] = { + keys: [ + { name: "label", weight: 3 }, + { name: "sublabel", weight: 1 }, + { name: "objectName", weight: 1.5 }, + // Search within field values for entries + { name: "fieldValues", weight: 2 }, + ], + threshold: 0.4, + distance: 200, + includeScore: true, + shouldSort: true, + minMatchCharLength: 1, +}; + +/** Flatten field values into a searchable string for Fuse.js. */ +function enrichForSearch( + items: SearchIndexItem[], +): Array { + return items.map((item) => ({ + ...item, + fieldValues: item.fields + ? Object.values(item.fields).join(" ") + : undefined, + })); +} + +// --- Hook --- + +/** + * Hook that fetches the workspace search index and provides fuzzy search. + * Refetches when `refreshSignal` changes (wire to tree watcher refresh count). + */ +export function useSearchIndex(refreshSignal?: number) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const mountedRef = useRef(true); + + const fetchIndex = useCallback(async () => { + try { + const res = await fetch("/api/workspace/search-index"); + const data = await res.json(); + if (mountedRef.current) { + setItems(data.items ?? []); + setLoading(false); + } + } catch { + if (mountedRef.current) {setLoading(false);} + } + }, []); + + useEffect(() => { + mountedRef.current = true; + fetchIndex(); + return () => { + mountedRef.current = false; + }; + }, [fetchIndex, refreshSignal]); + + // Build the Fuse instance whenever items change + const fuse = useMemo(() => { + if (items.length === 0) {return null;} + const enriched = enrichForSearch(items); + return new Fuse(enriched, FUSE_OPTIONS); + }, [items]); + + /** Inner search implementation (recreated when fuse/items change). */ + const searchImpl = useCallback( + (query: string, limit = 20): SearchIndexItem[] => { + if (!query.trim()) { + // No query: return first N items, files/objects first, then entries + const sorted = [...items].toSorted((a, b) => { + const kindOrder = { object: 0, file: 1, entry: 2 }; + return (kindOrder[a.kind] ?? 9) - (kindOrder[b.kind] ?? 9); + }); + return sorted.slice(0, limit); + } + + if (!fuse) {return [];} + + const results = fuse.search(query, { limit }); + return results.map((r) => r.item); + }, + [fuse, items], + ); + + // Keep a ref to the latest implementation so the tiptap extension + // (which captures the search function at creation time) always calls + // the current version, not a stale closure. + const searchImplRef = useRef(searchImpl); + searchImplRef.current = searchImpl; + + /** + * Stable search function -- identity never changes, but always delegates + * to the latest searchImpl via ref. Safe to capture in closures/extensions. + */ + const search = useCallback( + (query: string, limit?: number): SearchIndexItem[] => { + return searchImplRef.current(query, limit); + }, + [], + ); + + return { items, loading, search }; +} diff --git a/apps/web/lib/workspace-links.ts b/apps/web/lib/workspace-links.ts new file mode 100644 index 00000000000..57358cd3ff7 --- /dev/null +++ b/apps/web/lib/workspace-links.ts @@ -0,0 +1,99 @@ +/** + * 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; +}