kumarabhirup 8341c6048c
feat(web): full UI redesign with light/dark theme, TanStack data tables, media rendering, and gateway-routed agent execution
Overhaul the Dench web app with a comprehensive visual redesign and several
major feature additions across the chat interface, workspace, and agent
runtime layer.

Theme & Design System
- Replace the dark-only palette with a full light/dark theme system that
  respects system preference via localStorage + inline script (no FOUC).
- Introduce new design tokens: glassmorphism surfaces, semantic colors
  (success/warning/error/info), object-type chip palettes, and a tiered
  shadow scale (sm/md/lg/xl).
- Add Instrument Serif + Inter via Google Fonts for a refined typographic
  hierarchy; headings use the serif face, body uses Inter.
- Rebrand UI from "Ironclaw" to "Dench" across the landing page and
  metadata.

Chat & Chain-of-Thought
- Rewrite the chain-of-thought component with inline media detection and
  rendering — images, video, audio, and PDFs referenced in agent output
  are now displayed directly in the conversation thread.
- Add status indicator parts (e.g. "Preparing response...",
  "Optimizing session context...") that render as subtle activity badges
  instead of verbose reasoning blocks.
- Integrate react-markdown with remark-gfm for proper markdown rendering
  in assistant messages (tables, strikethrough, autolinks, etc.).
- Improve report-block splitting and lazy-loaded ReportCard rendering.

Workspace
- Introduce @tanstack/react-table for the object table, replacing the
  hand-rolled table with full column sorting, fuzzy filtering via
  match-sorter-utils, row selection, and bulk actions.
- Add a new media viewer component for in-workspace image/video/PDF
  preview.
- New API routes: bulk-delete entries, field management (CRUD + reorder),
  raw-file serving endpoint for media assets.
- Redesign workspace sidebar, empty state, and entry detail modal with
  the new theme tokens and improved layout.

Agent Runtime
- Switch web agent execution from --local to gateway-routed mode so
  concurrent chat threads share the gateway's lane-based concurrency
  system, eliminating cross-process file-lock contention.
- Advertise "tool-events" capability during WebSocket handshake so the
  gateway streams tool start/update/result events to the UI.
- Add new agent callback hooks: onLifecycleStart, onCompactionStart/End,
  and onToolUpdate for richer real-time feedback.
- Forward media URLs emitted by agent events into the chat stream.

Dependencies
- Add @tanstack/match-sorter-utils and @tanstack/react-table to the web
  app.

Published as ironclaw@2026.2.10-1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 11:17:23 -08:00

267 lines
7.0 KiB
TypeScript

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<string, string>;
// 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<string, ObjectRow>,
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<ObjectRow>(
"SELECT * FROM objects ORDER BY name",
);
for (const obj of objects) {
const fields = duckdbQuery<FieldRow>(
`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<string, unknown>[] = duckdbQuery(
`SELECT * FROM v_${obj.name} ORDER BY created_at DESC LIMIT 500`,
);
if (entries.length === 0) {
const rawRows = duckdbQuery<EavRow>(
`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<string, Record<string, unknown>>();
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<string, string> = {};
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<string, ObjectRow>();
if (duckdbPath()) {
const objs = duckdbQuery<ObjectRow>(
"SELECT * FROM objects",
);
for (const o of objs) {dbObjects.set(o.name, o);}
}
// Scan entire dench root (the dench folder IS the knowledge base)
flattenTree(root, "", dbObjects, items);
}
// 2. Entries from all objects
if (duckdbPath()) {
items.push(...buildEntryItems());
}
return Response.json({ items });
}