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>
This commit is contained in:
kumarabhirup 2026-02-12 11:17:23 -08:00
parent 18fab85ae7
commit 8341c6048c
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
35 changed files with 5613 additions and 1753 deletions

View File

@ -86,6 +86,9 @@ export async function POST(req: Request) {
// onLifecycleEnd closes the text part (textStarted→false), so
// onClose can't rely on textStarted alone to detect "no output".
let everSentText = false;
// Track whether the status reasoning block is the one currently open
// so we can close it cleanly when real content arrives.
let statusReasoningActive = false;
/** Write an SSE event; silently no-ops if the stream was already cancelled. */
const writeEvent = (data: unknown) => {
@ -102,6 +105,7 @@ export async function POST(req: Request) {
id: currentReasoningId,
});
reasoningStarted = false;
statusReasoningActive = false;
}
};
@ -113,9 +117,38 @@ export async function POST(req: Request) {
}
};
/** Open a status reasoning block (auto-closes any existing one). */
const openStatusReasoning = (label: string) => {
closeReasoning();
closeText();
currentReasoningId = nextId("status");
writeEvent({
type: "reasoning-start",
id: currentReasoningId,
});
writeEvent({
type: "reasoning-delta",
id: currentReasoningId,
delta: label,
});
reasoningStarted = true;
statusReasoningActive = true;
};
try {
await runAgent(agentMessage, abortController.signal, {
onLifecycleStart: () => {
// Show immediate feedback — the agent has started working.
// This eliminates the "Streaming... (silence)" gap.
openStatusReasoning("Preparing response...");
},
onThinkingDelta: (delta) => {
// Close the status block if it's still the active one;
// real reasoning content is now arriving.
if (statusReasoningActive) {
closeReasoning();
}
if (!reasoningStarted) {
currentReasoningId = nextId("reasoning");
writeEvent({
@ -199,6 +232,30 @@ export async function POST(req: Request) {
}
},
onCompactionStart: () => {
// Show compaction status while the gateway is
// optimizing the session context (can take 10-30s).
openStatusReasoning("Optimizing session context...");
},
onCompactionEnd: (willRetry) => {
// Close the compaction status block. If the gateway
// will retry the prompt, leave the reasoning area open
// so the next status/thinking block follows smoothly.
if (statusReasoningActive) {
if (willRetry) {
// Append a note, keep block open for retry
writeEvent({
type: "reasoning-delta",
id: currentReasoningId,
delta: "\nRetrying with compacted context...",
});
} else {
closeReasoning();
}
}
},
onLifecycleEnd: () => {
closeReasoning();
closeText();

View File

@ -1,4 +1,9 @@
import { duckdbQuery, duckdbPath, parseRelationValue } from "@/lib/workspace";
import {
duckdbQuery,
duckdbExec,
duckdbPath,
parseRelationValue,
} from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@ -6,299 +11,501 @@ export const runtime = "nodejs";
// --- Types ---
type ObjectRow = {
id: string;
name: string;
description?: string;
icon?: string;
default_view?: string;
display_field?: string;
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;
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, "''");
return s.replace(/'/g, "''");
}
function tryParseJson(value: unknown): unknown {
if (typeof value !== "string") {return value;}
try {
return JSON.parse(value);
} catch {
return value;
}
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";
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 ---
// --- Route handlers ---
/**
* GET /api/workspace/objects/[name]/entries/[id]
* Returns a single entry with all field values, relation labels, and reverse relations.
*/
export async function GET(
_req: Request,
{ params }: { params: Promise<{ name: string; id: string }> },
_req: Request,
{ params }: { params: Promise<{ name: string; id: string }> },
) {
const { name, id } = await params;
const { name, id } = await params;
if (!duckdbPath()) {
return Response.json({ error: "DuckDB not found" }, { status: 404 });
}
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 });
}
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<ObjectRow>(
`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 object
const objects = duckdbQuery<ObjectRow>(
`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<FieldRow>(
`SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`,
);
// Fetch fields
const fields = duckdbQuery<FieldRow>(
`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,
// 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 });
}
}
if (entryRows.length === 0) {
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<string, unknown> = { 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;}
}
// Pivot into a single record
const entry: Record<string, unknown> = { 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,
}));
// 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<string, Record<string, string>> = {};
const relatedObjectNames: Record<string, string> = {};
// Resolve relation labels for this entry
const relationLabels: Record<string, Record<string, string>> =
{};
const relatedObjectNames: Record<string, string> = {};
const relationFields = fields.filter(
(f) => f.type === "relation" && f.related_object_id,
);
const relationFields = fields.filter(
(f) => f.type === "relation" && f.related_object_id,
);
for (const rf of relationFields) {
const relatedObjs = duckdbQuery<ObjectRow>(
`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;
for (const rf of relationFields) {
const relatedObjs = duckdbQuery<ObjectRow>(
`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 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 ids = parseRelationValue(String(val));
if (ids.length === 0) {
relationLabels[rf.name] = {};
continue;
}
const relFields = duckdbQuery<FieldRow>(
`SELECT * FROM fields WHERE object_id = '${sqlEscape(relObj.id)}' ORDER BY sort_order`,
);
const displayFieldName = resolveDisplayField(relObj, relFields);
const relFields = duckdbQuery<FieldRow>(
`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
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<string, string> = {};
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;
}
const labelMap: Record<string, string> = {};
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,
}));
// 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);
// Find reverse relations for this entry
const reverseRelations = findReverseRelationsForEntry(
obj.id,
id,
);
const effectiveDisplayField = resolveDisplayField(obj, fields);
const effectiveDisplayField = resolveDisplayField(obj, fields);
return Response.json({
object: obj,
fields: enrichedFields,
entry,
relationLabels,
reverseRelations,
effectiveDisplayField,
});
return Response.json({
object: obj,
fields: enrichedFields,
entry,
relationLabels,
reverseRelations,
effectiveDisplayField,
});
}
/**
* PATCH /api/workspace/objects/[name]/entries/[id]
* Update field values for an entry.
* Body: { fields: { [fieldName]: newValue } }
*/
export async function PATCH(
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 },
);
}
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
return Response.json(
{ error: "Invalid object name" },
{ status: 400 },
);
}
// Find object
const objects = duckdbQuery<{ id: string }>(
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
);
if (objects.length === 0) {
return Response.json(
{ error: `Object '${name}' not found` },
{ status: 404 },
);
}
const objectId = objects[0].id;
// Verify entry exists
const exists = duckdbQuery<{ cnt: number }>(
`SELECT COUNT(*) as cnt FROM entries WHERE id = '${sqlEscape(id)}' AND object_id = '${sqlEscape(objectId)}'`,
);
if (!exists[0] || exists[0].cnt === 0) {
return Response.json(
{ error: "Entry not found" },
{ status: 404 },
);
}
const body = await req.json();
const fieldUpdates: Record<string, string> =
body.fields ?? {};
// Get field IDs by name
const dbFields = duckdbQuery<{ id: string; name: string }>(
`SELECT id, name FROM fields WHERE object_id = '${sqlEscape(objectId)}'`,
);
const fieldMap = new Map(dbFields.map((f) => [f.name, f.id]));
let updatedCount = 0;
for (const [fieldName, value] of Object.entries(fieldUpdates)) {
const fieldId = fieldMap.get(fieldName);
if (!fieldId) {continue;}
const escapedValue =
value == null ? "NULL" : `'${sqlEscape(String(value))}'`;
// Try update first, then insert if no rows affected
const existingRows = duckdbQuery<{ cnt: number }>(
`SELECT COUNT(*) as cnt FROM entry_fields WHERE entry_id = '${sqlEscape(id)}' AND field_id = '${sqlEscape(fieldId)}'`,
);
if (existingRows[0]?.cnt > 0) {
duckdbExec(
`UPDATE entry_fields SET value = ${escapedValue} WHERE entry_id = '${sqlEscape(id)}' AND field_id = '${sqlEscape(fieldId)}'`,
);
} else {
duckdbExec(
`INSERT INTO entry_fields (entry_id, field_id, value) VALUES ('${sqlEscape(id)}', '${sqlEscape(fieldId)}', ${escapedValue})`,
);
}
updatedCount++;
}
// Touch updated_at on the entry
const now = new Date().toISOString();
duckdbExec(
`UPDATE entries SET updated_at = '${now}' WHERE id = '${sqlEscape(id)}'`,
);
return Response.json({ ok: true, updatedCount });
}
/**
* DELETE /api/workspace/objects/[name]/entries/[id]
* Delete a single entry and its field values.
*/
export async function DELETE(
_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 },
);
}
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
return Response.json(
{ error: "Invalid object name" },
{ status: 400 },
);
}
// Find object
const objects = duckdbQuery<{ id: string }>(
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
);
if (objects.length === 0) {
return Response.json(
{ error: `Object '${name}' not found` },
{ status: 404 },
);
}
const objectId = objects[0].id;
// Delete field values first, then entry
duckdbExec(
`DELETE FROM entry_fields WHERE entry_id = '${sqlEscape(id)}'`,
);
duckdbExec(
`DELETE FROM entries WHERE id = '${sqlEscape(id)}' AND object_id = '${sqlEscape(objectId)}'`,
);
return Response.json({ ok: true });
}
// --- Reverse relations for a single entry ---
type ReverseRelation = {
fieldName: string;
sourceObjectName: string;
sourceObjectId: string;
displayField: string;
links: Array<{ id: string; label: string }>;
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,
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
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 [];}
if (reverseFields.length === 0) {
return [];
}
const result: ReverseRelation[] = [];
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
for (const rrf of reverseFields) {
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);
}
}
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;}
if (matchingSourceIds.length === 0) {
continue;
}
// Get source object's fields to resolve display labels
const sourceObj = duckdbQuery<ObjectRow>(
`SELECT * FROM objects WHERE id = '${sqlEscape(rrf.object_id)}' LIMIT 1`,
);
if (sourceObj.length === 0) {continue;}
const sourceObj = duckdbQuery<ObjectRow>(
`SELECT * FROM objects WHERE id = '${sqlEscape(rrf.object_id)}' LIMIT 1`,
);
if (sourceObj.length === 0) {
continue;
}
const sourceFields = duckdbQuery<FieldRow>(
`SELECT * FROM fields WHERE object_id = '${sqlEscape(rrf.object_id)}' ORDER BY sort_order`,
);
const displayFieldName = resolveDisplayField(sourceObj[0], sourceFields);
const sourceFields = duckdbQuery<FieldRow>(
`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
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<string, string> = {};
for (const row of displayRows) {
displayMap[row.entry_id] = row.value || row.entry_id;
}
const displayMap: Record<string, string> = {};
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,
}));
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,
});
}
result.push({
fieldName: rrf.name,
sourceObjectName: rrf.source_object_name,
sourceObjectId: rrf.object_id,
displayField: displayFieldName,
links,
});
}
return result;
return result;
}

View File

@ -0,0 +1,72 @@
import { duckdbExec, duckdbQuery, duckdbPath } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
function sqlEscape(s: string): string {
return s.replace(/'/g, "''");
}
/**
* POST /api/workspace/objects/[name]/entries/bulk-delete
* Delete multiple entries at once.
* Body: { entryIds: string[] }
*/
export async function POST(
req: Request,
{ params }: { params: Promise<{ name: string }> },
) {
const { name } = await params;
if (!duckdbPath()) {
return Response.json(
{ error: "DuckDB not found" },
{ status: 404 },
);
}
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
return Response.json(
{ error: "Invalid object name" },
{ status: 400 },
);
}
const body = await req.json();
const entryIds: string[] = body.entryIds;
if (!Array.isArray(entryIds) || entryIds.length === 0) {
return Response.json(
{ error: "entryIds must be a non-empty array" },
{ status: 400 },
);
}
// Validate object exists
const objects = duckdbQuery<{ id: string }>(
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
);
if (objects.length === 0) {
return Response.json(
{ error: `Object '${name}' not found` },
{ status: 404 },
);
}
const objectId = objects[0].id;
const idList = entryIds
.map((id) => `'${sqlEscape(id)}'`)
.join(",");
// Delete field values first, then entries
duckdbExec(
`DELETE FROM entry_fields WHERE entry_id IN (${idList})`,
);
duckdbExec(
`DELETE FROM entries WHERE id IN (${idList}) AND object_id = '${sqlEscape(objectId)}'`,
);
return Response.json({
ok: true,
deletedCount: entryIds.length,
});
}

View File

@ -0,0 +1,95 @@
import { duckdbExec, duckdbQuery, duckdbPath } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
function sqlEscape(s: string): string {
return s.replace(/'/g, "''");
}
/**
* POST /api/workspace/objects/[name]/entries
* Create a new entry with optional field values.
* Body: { fields?: Record<string, string> }
*/
export async function POST(
req: Request,
{ params }: { params: Promise<{ name: string }> },
) {
const { name } = await params;
if (!duckdbPath()) {
return Response.json(
{ error: "DuckDB not found" },
{ status: 404 },
);
}
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
return Response.json(
{ error: "Invalid object name" },
{ status: 400 },
);
}
// Find object
const objects = duckdbQuery<{ id: string }>(
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
);
if (objects.length === 0) {
return Response.json(
{ error: `Object '${name}' not found` },
{ status: 404 },
);
}
const objectId = objects[0].id;
// Generate UUID for the new entry
const idRows = duckdbQuery<{ id: string }>(
"SELECT uuid()::VARCHAR as id",
);
const entryId = idRows[0]?.id;
if (!entryId) {
return Response.json(
{ error: "Failed to generate UUID" },
{ status: 500 },
);
}
// Create entry
const now = new Date().toISOString();
const ok = duckdbExec(
`INSERT INTO entries (id, object_id, created_at, updated_at) VALUES ('${sqlEscape(entryId)}', '${sqlEscape(objectId)}', '${now}', '${now}')`,
);
if (!ok) {
return Response.json(
{ error: "Failed to create entry" },
{ status: 500 },
);
}
// Insert field values if provided
let body: { fields?: Record<string, string> } = {};
try {
body = await req.json();
} catch {
// no body is fine
}
if (body.fields && typeof body.fields === "object") {
// Get field IDs by name
const dbFields = duckdbQuery<{ id: string; name: string }>(
`SELECT id, name FROM fields WHERE object_id = '${sqlEscape(objectId)}'`,
);
const fieldMap = new Map(dbFields.map((f) => [f.name, f.id]));
for (const [fieldName, value] of Object.entries(body.fields)) {
const fieldId = fieldMap.get(fieldName);
if (!fieldId || value == null) {continue;}
duckdbExec(
`INSERT INTO entry_fields (entry_id, field_id, value) VALUES ('${sqlEscape(entryId)}', '${sqlEscape(fieldId)}', '${sqlEscape(String(value))}')`,
);
}
}
return Response.json({ entryId, ok: true }, { status: 201 });
}

View File

@ -0,0 +1,96 @@
import { duckdbExec, duckdbQuery, duckdbPath } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
function sqlEscape(s: string): string {
return s.replace(/'/g, "''");
}
/**
* PATCH /api/workspace/objects/[name]/fields/[fieldId]
* Rename a field.
* Body: { name: string }
*/
export async function PATCH(
req: Request,
{
params,
}: { params: Promise<{ name: string; fieldId: string }> },
) {
const { name, fieldId } = await params;
if (!duckdbPath()) {
return Response.json(
{ error: "DuckDB not found" },
{ status: 404 },
);
}
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
return Response.json(
{ error: "Invalid object name" },
{ status: 400 },
);
}
const body = await req.json();
const newName: string = body.name;
if (
!newName ||
typeof newName !== "string" ||
newName.trim().length === 0
) {
return Response.json(
{ error: "Name is required" },
{ status: 400 },
);
}
// Validate object exists
const objects = duckdbQuery<{ id: string }>(
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
);
if (objects.length === 0) {
return Response.json(
{ error: `Object '${name}' not found` },
{ status: 404 },
);
}
const objectId = objects[0].id;
// Validate field exists and belongs to this object
const fieldExists = duckdbQuery<{ cnt: number }>(
`SELECT COUNT(*) as cnt FROM fields WHERE id = '${sqlEscape(fieldId)}' AND object_id = '${sqlEscape(objectId)}'`,
);
if (!fieldExists[0] || fieldExists[0].cnt === 0) {
return Response.json(
{ error: "Field not found" },
{ status: 404 },
);
}
// Check for duplicate name
const duplicateCheck = duckdbQuery<{ cnt: number }>(
`SELECT COUNT(*) as cnt FROM fields WHERE object_id = '${sqlEscape(objectId)}' AND name = '${sqlEscape(newName.trim())}' AND id != '${sqlEscape(fieldId)}'`,
);
if (duplicateCheck[0]?.cnt > 0) {
return Response.json(
{ error: "A field with that name already exists" },
{ status: 409 },
);
}
const ok = duckdbExec(
`UPDATE fields SET name = '${sqlEscape(newName.trim())}' WHERE id = '${sqlEscape(fieldId)}'`,
);
if (!ok) {
return Response.json(
{ error: "Failed to rename field" },
{ status: 500 },
);
}
return Response.json({ ok: true });
}

View File

@ -0,0 +1,64 @@
import { duckdbExec, duckdbQuery, duckdbPath } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
function sqlEscape(s: string): string {
return s.replace(/'/g, "''");
}
/**
* PATCH /api/workspace/objects/[name]/fields/reorder
* Reorder fields by updating sort_order.
* Body: { fieldOrder: string[] } array of field IDs in desired order
*/
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ name: string }> },
) {
const { name } = await params;
if (!duckdbPath()) {
return Response.json(
{ error: "DuckDB not found" },
{ status: 404 },
);
}
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
return Response.json(
{ error: "Invalid object name" },
{ status: 400 },
);
}
const body = await req.json();
const fieldOrder: string[] = body.fieldOrder;
if (!Array.isArray(fieldOrder) || fieldOrder.length === 0) {
return Response.json(
{ error: "fieldOrder must be a non-empty array" },
{ status: 400 },
);
}
// Validate object exists
const objects = duckdbQuery<{ id: string }>(
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
);
if (objects.length === 0) {
return Response.json(
{ error: `Object '${name}' not found` },
{ status: 404 },
);
}
const objectId = objects[0].id;
// Update sort_order for each field
for (let i = 0; i < fieldOrder.length; i++) {
duckdbExec(
`UPDATE fields SET sort_order = ${i} WHERE id = '${sqlEscape(fieldOrder[i])}' AND object_id = '${sqlEscape(objectId)}'`,
);
}
return Response.json({ ok: true });
}

View File

@ -0,0 +1,122 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { safeResolvePath, resolveDenchRoot } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const MIME_MAP: Record<string, string> = {
// Images
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
ico: "image/x-icon",
bmp: "image/bmp",
tiff: "image/tiff",
tif: "image/tiff",
avif: "image/avif",
heic: "image/heic",
heif: "image/heif",
// Video
mp4: "video/mp4",
webm: "video/webm",
mov: "video/quicktime",
avi: "video/x-msvideo",
mkv: "video/x-matroska",
// Audio
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
m4a: "audio/mp4",
// Documents
pdf: "application/pdf",
};
/**
* Resolve a file path, trying multiple strategies:
* 1. Absolute path the agent may read files from anywhere on the local machine
* (Photos library, Downloads, etc.), so we serve any readable absolute path.
* 2. Workspace-relative via safeResolvePath
* 3. Bare filename search common workspace subdirectories
*
* Security note: this is a local-only dev server; it never runs in production.
*/
function resolveFile(path: string): string | null {
// 1. Absolute path — serve directly if it exists on disk
if (path.startsWith("/")) {
const abs = resolve(path);
if (existsSync(abs)) {return abs;}
// Fall through to workspace-relative in case the leading / is accidental
}
// 2. Standard workspace-relative resolution
const resolved = safeResolvePath(path);
if (resolved) {return resolved;}
// 3. Try common subdirectories in case the path is a bare filename
const root = resolveDenchRoot();
if (!root) {return null;}
const rootAbs = resolve(root);
const basename = path.split("/").pop() ?? path;
if (basename === path) {
const subdirs = [
"assets",
"knowledge",
"manufacturing",
"uploads",
"files",
"images",
"media",
"reports",
"exports",
];
for (const sub of subdirs) {
const candidate = resolve(root, sub, basename);
if (
candidate.startsWith(rootAbs) &&
existsSync(candidate)
) {
return candidate;
}
}
}
return null;
}
/**
* GET /api/workspace/raw-file?path=...
* Serves a workspace file with the correct Content-Type for inline display.
* Used by the chain-of-thought component to render images, videos, and PDFs.
*/
export async function GET(req: Request) {
const url = new URL(req.url);
const path = url.searchParams.get("path");
if (!path) {
return new Response("Missing path", { status: 400 });
}
const absolute = resolveFile(path);
if (!absolute) {
return new Response("Not found", { status: 404 });
}
const ext = path.split(".").pop()?.toLowerCase() ?? "";
const contentType = MIME_MAP[ext] ?? "application/octet-stream";
try {
const buffer = readFileSync(absolute);
return new Response(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=3600",
},
});
} catch {
return new Response("Read error", { status: 500 });
}
}

View File

@ -253,44 +253,8 @@ export async function GET() {
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
}
// Scan entire dench root (the dench folder IS the knowledge base)
flattenTree(root, "", dbObjects, items);
}
// 2. Entries from all objects

View File

@ -57,7 +57,7 @@ function loadDbObjects(): Map<string, DbObject> {
return map;
}
/** Recursively build a tree of the knowledge/ directory. */
/** Recursively build a tree from a workspace directory. */
function buildTree(
absDir: string,
relativeBase: string,
@ -135,15 +135,6 @@ function buildTree(
return nodes;
}
/** Classify a top-level file's type. */
function classifyFileType(name: string): TreeNode["type"] {
if (name.endsWith(".report.json")) {return "report";}
if (isDatabaseFile(name)) {return "database";}
const ext = name.split(".").pop()?.toLowerCase();
if (ext === "md" || ext === "mdx") {return "document";}
return "file";
}
// --- Virtual folder builders ---
/** Parse YAML frontmatter from a SKILL.md file (lightweight). */
@ -326,44 +317,10 @@ export async function GET() {
// Load objects from DuckDB for smart directory detection
const dbObjects = loadDbObjects();
const knowledgeDir = join(root, "knowledge");
const reportsDir = join(root, "reports");
const tree: TreeNode[] = [];
// Build knowledge tree (real files first)
if (existsSync(knowledgeDir)) {
tree.push(...buildTree(knowledgeDir, "knowledge", dbObjects));
}
// Build reports tree
if (existsSync(reportsDir)) {
const reportNodes = buildTree(reportsDir, "reports", dbObjects);
if (reportNodes.length > 0) {
tree.push({
name: "reports",
path: "reports",
type: "folder",
children: reportNodes,
});
}
}
// Add top-level files (WORKSPACE.md, workspace_context.yaml, workspace.duckdb, etc.)
try {
const topLevel = readdirSync(root, { withFileTypes: true });
for (const entry of topLevel) {
if (!entry.isFile()) {continue;}
if (entry.name.startsWith(".")) {continue;}
tree.push({
name: entry.name,
path: entry.name,
type: classifyFileType(entry.name),
});
}
} catch {
// skip if root unreadable
}
// Scan the entire dench root -- the dench folder IS the knowledge base.
// All top-level directories (manufacturing, knowledge, reports, etc.)
// and files are visible in the sidebar.
const tree = buildTree(root, "", dbObjects);
// Workspace root files (USER.md, SOUL.md, etc.) -- editable but reserved
const workspaceRootFiles = buildWorkspaceRootFiles();

File diff suppressed because it is too large Load Diff

View File

@ -33,7 +33,7 @@ import type { PanelConfig } from "./types";
// --- Color palette derived from CSS variables + accessible defaults ---
const CHART_PALETTE = [
"#e85d3a", // accent
"#2563eb", // accent
"#60a5fa", // blue
"#22c55e", // green
"#f59e0b", // amber
@ -56,25 +56,25 @@ type ChartPanelProps = {
const axisStyle = {
fontSize: 11,
fill: "#888",
fill: "var(--color-text-muted)",
};
const gridStyle = {
stroke: "#262626",
stroke: "var(--color-border-strong)",
strokeDasharray: "3 3",
};
function tooltipStyle() {
return {
contentStyle: {
background: "#141414",
border: "1px solid #262626",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: 8,
fontSize: 12,
color: "#ededed",
color: "var(--color-text)",
},
itemStyle: { color: "#ededed" },
labelStyle: { color: "#888", marginBottom: 4 },
itemStyle: { color: "var(--color-text)" },
labelStyle: { color: "var(--color-text-muted)", marginBottom: 4 },
};
}
@ -115,7 +115,7 @@ function CartesianChart({
config: PanelConfig;
data: Record<string, unknown>[];
compact?: boolean;
ChartComponent: typeof BarChart | typeof LineChart | typeof AreaChart;
ChartComponent: typeof BarChart ;
SeriesComponent: typeof Bar | typeof Line | typeof Area;
areaProps?: Record<string, unknown>;
}) {
@ -134,7 +134,7 @@ function CartesianChart({
dataKey={xKey}
tick={axisStyle}
tickFormatter={formatLabel}
axisLine={{ stroke: "#262626" }}
axisLine={{ stroke: "var(--color-border)" }}
tickLine={false}
/>
<YAxis
@ -245,9 +245,9 @@ function RadarChartPanel({
return (
<ResponsiveContainer width="100%" height={height}>
<RadarChart data={data} cx="50%" cy="50%" outerRadius={compact ? 60 : 100}>
<PolarGrid stroke="#262626" />
<PolarAngleAxis dataKey={nameKey} tick={{ fontSize: 11, fill: "#888" }} />
<PolarRadiusAxis tick={{ fontSize: 10, fill: "#888" }} />
<PolarGrid stroke="var(--color-border)" />
<PolarAngleAxis dataKey={nameKey} tick={{ fontSize: 11, fill: "var(--color-text-muted)" }} />
<PolarRadiusAxis tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} />
{valueKeys.map((key, i) => (
<Radar
key={key}
@ -285,7 +285,7 @@ function ScatterChartPanel({
<ResponsiveContainer width="100%" height={height}>
<ScatterChart margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid {...gridStyle} />
<XAxis dataKey={xKey} tick={axisStyle} name={xKey} axisLine={{ stroke: "#262626" }} tickLine={false} />
<XAxis dataKey={xKey} tick={axisStyle} name={xKey} axisLine={{ stroke: "var(--color-border)" }} tickLine={false} />
<YAxis tick={axisStyle} tickFormatter={formatValue} axisLine={false} tickLine={false} width={48} />
<Tooltip {...ttStyle} />
{yKeys.map((key, i) => (
@ -336,7 +336,7 @@ function FunnelChartPanel({
>
<LabelList
position="right"
fill="#888"
fill="var(--color-text-muted)"
stroke="none"
fontSize={11}
dataKey={nameKey}

View File

@ -147,7 +147,7 @@ function MultiSelectFilter({
onClick={() => toggleOption(opt)}
className="px-2 py-0.5 rounded-full text-[10px] transition-colors cursor-pointer"
style={{
background: selected ? "rgba(232, 93, 58, 0.15)" : "var(--color-surface)",
background: selected ? "var(--color-accent-light)" : "var(--color-surface)",
border: `1px solid ${selected ? "var(--color-accent)" : "var(--color-border)"}`,
color: selected ? "var(--color-accent)" : "var(--color-text-muted)",
}}
@ -333,7 +333,7 @@ export function FilterBar({ filters, value, onChange }: FilterBarProps) {
className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] transition-colors cursor-pointer"
style={{
color: "var(--color-accent)",
background: "rgba(232, 93, 58, 0.1)",
background: "var(--color-accent-light)",
}}
>
<XIcon />

View File

@ -194,7 +194,7 @@ export function ReportCard({ config }: ReportCardProps) {
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] transition-colors"
style={{
color: "var(--color-accent)",
background: "rgba(232, 93, 58, 0.1)",
background: "var(--color-accent-light)",
}}
>
<ExternalLinkIcon />

View File

@ -2,14 +2,28 @@
import dynamic from "next/dynamic";
import type { UIMessage } from "ai";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ChainOfThought, type ChainPart } from "./chain-of-thought";
import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks";
import type { ReportConfig } from "./charts/types";
// Lazy-load ReportCard (uses Recharts which is heavy)
const ReportCard = dynamic(
() => import("./charts/report-card").then((m) => ({ default: m.ReportCard })),
{ ssr: false, loading: () => <div className="h-48 rounded-xl animate-pulse" style={{ background: "var(--color-surface)" }} /> },
() =>
import("./charts/report-card").then((m) => ({
default: m.ReportCard,
})),
{
ssr: false,
loading: () => (
<div
className="h-48 rounded-2xl animate-pulse"
style={{ background: "var(--color-surface-hover)" }}
/>
),
},
);
/* ─── Part grouping ─── */
@ -21,8 +35,12 @@ type MessageSegment =
/** Map AI SDK tool state string to a simplified status */
function toolStatus(state: string): "running" | "done" | "error" {
if (state === "output-available") {return "done";}
if (state === "error") {return "error";}
if (state === "output-available") {
return "done";
}
if (state === "error") {
return "error";
}
return "running";
}
@ -45,9 +63,10 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
if (part.type === "text") {
flush();
const text = (part as { type: "text"; text: string }).text;
// Check for report-json fenced blocks in text
if (hasReportBlocks(text)) {
segments.push(...splitReportBlocks(text) as MessageSegment[]);
segments.push(
...(splitReportBlocks(text) as MessageSegment[]),
);
} else {
segments.push({ type: "text", text });
}
@ -57,11 +76,28 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
text: string;
state?: string;
};
chain.push({
kind: "reasoning",
text: rp.text,
isStreaming: rp.state === "streaming",
});
// Detect status reasoning blocks emitted by lifecycle/compaction events.
// These have short, specific labels — render as status indicators instead.
const statusLabels = [
"Preparing response...",
"Optimizing session context...",
];
const isStatus = statusLabels.some((l) =>
rp.text.startsWith(l),
);
if (isStatus) {
chain.push({
kind: "status",
label: rp.text.split("\n")[0],
isActive: rp.state === "streaming",
});
} else {
chain.push({
kind: "reasoning",
text: rp.text,
isStreaming: rp.state === "streaming",
});
}
} else if (part.type === "dynamic-tool") {
const tp = part as {
type: "dynamic-tool";
@ -111,102 +147,143 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
function asRecord(
val: unknown,
): Record<string, unknown> | undefined {
if (val && typeof val === "object" && !Array.isArray(val))
{return val as Record<string, unknown>;}
if (val && typeof val === "object" && !Array.isArray(val)) {
return val as Record<string, unknown>;
}
return undefined;
}
// splitReportBlocks and hasReportBlocks imported from @/lib/report-blocks
/* ─── Markdown component overrides for chat ─── */
/* ─── Chat message ─── */
const mdComponents: Components = {
// Open external links in new tab
a: ({ href, children, ...props }) => {
const isExternal =
href && (href.startsWith("http") || href.startsWith("//"));
return (
<a
href={href}
{...(isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
{...props}
>
{children}
</a>
);
},
// Render images with loading=lazy
img: ({ src, alt, ...props }) => (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt ?? ""} loading="lazy" {...props} />
),
};
/* ─── Chat message (Dench-inspired free-flowing text) ─── */
export function ChatMessage({ message }: { message: UIMessage }) {
const isUser = message.role === "user";
const segments = groupParts(message.parts);
return (
<div
className={`flex gap-3 py-4 ${isUser ? "justify-end" : "justify-start"}`}
>
{!isUser && (
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--color-accent)] flex items-center justify-center text-white text-sm font-bold">
O
</div>
)}
if (isUser) {
// User: right-aligned subtle pill (like Dench)
const textContent = segments
.filter(
(s): s is { type: "text"; text: string } =>
s.type === "text",
)
.map((s) => s.text)
.join("\n");
<div
className={`max-w-[75%] rounded-2xl px-4 py-3 ${
isUser
? "bg-[var(--color-accent)] text-white"
: "bg-[var(--color-surface)] text-[var(--color-text)]"
}`}
>
{segments.map((segment, index) => {
if (segment.type === "text") {
// Detect agent error messages (prefixed with [error])
const errorMatch = segment.text.match(
/^\[error\]\s*([\s\S]*)$/,
);
if (errorMatch) {
return (
<div
key={index}
className="flex items-start gap-2 rounded-lg px-3 py-2 text-[13px] leading-relaxed"
style={{
background:
"color-mix(in srgb, var(--color-error, #ef4444) 12%, transparent)",
color: "var(--color-error, #ef4444)",
border: "1px solid color-mix(in srgb, var(--color-error, #ef4444) 25%, transparent)",
}}
>
<span
className="flex-shrink-0 mt-0.5"
aria-hidden="true"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle
cx="12"
cy="12"
r="10"
/>
<line
x1="12"
y1="8"
x2="12"
y2="12"
/>
<line
x1="12"
y1="16"
x2="12.01"
y2="16"
/>
</svg>
</span>
<span className="whitespace-pre-wrap">
{errorMatch[1].trim()}
</span>
</div>
return (
<div className="flex justify-end py-2">
<div
className="font-bookerly max-w-[80%] rounded-2xl rounded-br-sm px-4 py-2.5 text-[17px] leading-9"
style={{
background: "var(--color-user-bubble)",
color: "var(--color-user-bubble-text)",
}}
>
<p className="whitespace-pre-wrap">{textContent}</p>
</div>
</div>
);
}
// Assistant: free-flowing text, left-aligned, NO bubble
return (
<div className="py-3 space-y-2">
{segments.map((segment, index) => {
if (segment.type === "text") {
// Detect agent error messages
const errorMatch = segment.text.match(
/^\[error\]\s*([\s\S]*)$/,
);
}
if (errorMatch) {
return (
<div
key={index}
className="font-bookerly flex items-start gap-2 rounded-xl px-3 py-2 text-[13px] leading-relaxed"
style={{
background: `color-mix(in srgb, var(--color-error) 6%, var(--color-surface))`,
color: "var(--color-error)",
border: `1px solid color-mix(in srgb, var(--color-error) 18%, transparent)`,
}}
>
<span
className="flex-shrink-0 mt-0.5"
aria-hidden="true"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle
cx="12"
cy="12"
r="10"
/>
<line
x1="12"
y1="8"
x2="12"
y2="12"
/>
<line
x1="12"
y1="16"
x2="12.01"
y2="16"
/>
</svg>
</span>
<span className="whitespace-pre-wrap">
{errorMatch[1].trim()}
</span>
</div>
);
}
return (
<div
key={index}
className="whitespace-pre-wrap text-[15px] leading-relaxed"
>
{segment.text}
</div>
<div
key={index}
className="chat-prose font-bookerly text-[17px]"
style={{ color: "var(--color-text)" }}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={mdComponents}
>
{segment.text}
</ReactMarkdown>
</div>
);
}
}
if (segment.type === "report-artifact") {
return (
<ReportCard
@ -216,16 +293,12 @@ export function ChatMessage({ message }: { message: UIMessage }) {
);
}
return (
<ChainOfThought key={index} parts={segment.parts} />
<ChainOfThought
key={index}
parts={segment.parts}
/>
);
})}
</div>
{isUser && (
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--color-border)] flex items-center justify-center text-[var(--color-text-muted)] text-sm font-bold">
U
</div>
)}
</div>
);
}

View File

@ -57,9 +57,9 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
ref,
) {
const [input, setInput] = useState("");
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
null,
);
const [currentSessionId, setCurrentSessionId] = useState<
string | null
>(null);
const [loadingSession, setLoadingSession] = useState(false);
const [startingNewSession, setStartingNewSession] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
@ -72,27 +72,19 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
const isFirstFileMessageRef = useRef(true);
// File-scoped session list (compact mode only)
const [fileSessions, setFileSessions] = useState<FileScopedSession[]>(
[],
);
const [fileSessions, setFileSessions] = useState<
FileScopedSession[]
>([]);
const filePath = fileContext?.path ?? null;
// ── Ref-based session ID for transport ──
// The transport body function reads from this ref so it always has
// the latest session ID, even when called in the same event-loop
// tick as a state update (before the re-render).
const sessionIdRef = useRef<string | null>(null);
// Keep ref in sync with React state.
useEffect(() => {
sessionIdRef.current = currentSessionId;
}, [currentSessionId]);
// ── Transport (per-instance) ──
// Each ChatPanel mounts its own transport. For file-scoped chats the
// body function injects the sessionId so the API spawns an isolated
// agent process (subagent) per chat session.
const transport = useMemo(
() =>
new DefaultChatTransport({
@ -108,11 +100,14 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
const { messages, sendMessage, status, stop, error, setMessages } =
useChat({ transport });
const isStreaming = status === "streaming" || status === "submitted";
const isStreaming =
status === "streaming" || status === "submitted";
// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
});
}, [messages]);
// ── Session persistence helpers ──
@ -120,7 +115,9 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
const createSession = useCallback(
async (title: string): Promise<string> => {
const body: Record<string, string> = { title };
if (filePath) {body.filePath = filePath;}
if (filePath) {
body.filePath = filePath;
}
const res = await fetch("/api/web-sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -164,8 +161,9 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
}),
},
);
for (const m of msgs)
{savedMessageIdsRef.current.add(m.id);}
for (const m of msgs) {
savedMessageIdsRef.current.add(m.id);
}
onSessionsChange?.();
} catch (err) {
console.error("Failed to save messages:", err);
@ -195,15 +193,14 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
);
// ── File-scoped session initialization ──
// When the active file changes: reset chat state, fetch existing
// sessions for this file, and auto-load the most recent one.
const fetchFileSessionsRef = useRef<
(() => Promise<FileScopedSession[]>) | null
>(null);
fetchFileSessionsRef.current = async () => {
if (!filePath) {return [];}
if (!filePath) {
return [];
}
try {
const res = await fetch(
`/api/web-sessions?filePath=${encodeURIComponent(filePath)}`,
@ -216,10 +213,11 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
};
useEffect(() => {
if (!filePath) {return;}
if (!filePath) {
return;
}
let cancelled = false;
// Reset state for the new file
sessionIdRef.current = null;
setCurrentSessionId(null);
onActiveSessionChange?.(null);
@ -227,10 +225,12 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
savedMessageIdsRef.current.clear();
isFirstFileMessageRef.current = true;
// Fetch sessions and auto-load the most recent
(async () => {
const sessions = await fetchFileSessionsRef.current?.() ?? [];
if (cancelled) {return;}
const sessions =
(await fetchFileSessionsRef.current?.()) ?? [];
if (cancelled) {
return;
}
setFileSessions(sessions);
if (sessions.length > 0) {
@ -240,12 +240,13 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
onActiveSessionChange?.(latest.id);
isFirstFileMessageRef.current = false;
// Load messages for the most recent session
try {
const msgRes = await fetch(
`/api/web-sessions/${latest.id}`,
);
if (cancelled) {return;}
if (cancelled) {
return;
}
const msgData = await msgRes.json();
const sessionMessages: Array<{
id: string;
@ -254,22 +255,26 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
parts?: Array<Record<string, unknown>>;
}> = msgData.messages || [];
const uiMessages = sessionMessages.map((msg) => {
savedMessageIdsRef.current.add(msg.id);
return {
id: msg.id,
role: msg.role,
parts: (msg.parts ?? [
{
type: "text" as const,
text: msg.content,
},
]) as UIMessage["parts"],
};
});
if (!cancelled) {setMessages(uiMessages);}
const uiMessages = sessionMessages.map(
(msg) => {
savedMessageIdsRef.current.add(msg.id);
return {
id: msg.id,
role: msg.role,
parts: (msg.parts ?? [
{
type: "text" as const,
text: msg.content,
},
]) as UIMessage["parts"],
};
},
);
if (!cancelled) {
setMessages(uiMessages);
}
} catch {
// ignore start with empty messages
// ignore
}
}
})();
@ -281,7 +286,6 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
}, [filePath]);
// ── Persist unsaved messages + live-reload after streaming ──
const prevStatusRef = useRef(status);
useEffect(() => {
const wasStreaming =
@ -303,21 +307,23 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
saveMessages(currentSessionId, toSave);
}
// Refresh file session list (title/count may have changed)
if (filePath) {
fetchFileSessionsRef.current?.().then((sessions) => {
setFileSessions(sessions);
});
fetchFileSessionsRef.current?.().then(
(sessions) => {
setFileSessions(sessions);
},
);
}
// Re-fetch file content for live reload after agent edits
if (filePath && onFileChanged) {
fetch(
`/api/workspace/file?path=${encodeURIComponent(filePath)}`,
)
.then((r) => r.json())
.then((data) => {
if (data.content) {onFileChanged(data.content);}
if (data.content) {
onFileChanged(data.content);
}
})
.catch(() => {});
}
@ -337,7 +343,9 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isStreaming) {return;}
if (!input.trim() || isStreaming) {
return;
}
const userText = input.trim();
setInput("");
@ -347,7 +355,6 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
return;
}
// Create session if none exists yet
let sessionId = currentSessionId;
if (!sessionId) {
const title =
@ -360,15 +367,15 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
onActiveSessionChange?.(sessionId);
onSessionsChange?.();
// Refresh file session tabs
if (filePath) {
fetchFileSessionsRef.current?.().then((sessions) => {
setFileSessions(sessions);
});
fetchFileSessionsRef.current?.().then(
(sessions) => {
setFileSessions(sessions);
},
);
}
}
// Prepend file path context for the first message in a file-scoped session
let messageText = userText;
if (fileContext && isFirstFileMessageRef.current) {
messageText = `[Context: workspace file '${fileContext.path}']\n\n${userText}`;
@ -380,7 +387,9 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
const handleSessionSelect = useCallback(
async (sessionId: string) => {
if (sessionId === currentSessionId) {return;}
if (sessionId === currentSessionId) {
return;
}
stop();
setLoadingSession(true);
@ -388,14 +397,15 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
sessionIdRef.current = sessionId;
onActiveSessionChange?.(sessionId);
savedMessageIdsRef.current.clear();
isFirstFileMessageRef.current = false; // loaded session has context
isFirstFileMessageRef.current = false;
try {
const response = await fetch(
`/api/web-sessions/${sessionId}`,
);
if (!response.ok)
{throw new Error("Failed to load session");}
if (!response.ok) {
throw new Error("Failed to load session");
}
const data = await response.json();
const sessionMessages: Array<{
@ -426,7 +436,12 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
setLoadingSession(false);
}
},
[currentSessionId, setMessages, onActiveSessionChange, stop],
[
currentSessionId,
setMessages,
onActiveSessionChange,
stop,
],
);
const handleNewSession = useCallback(async () => {
@ -439,22 +454,20 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
isFirstFileMessageRef.current = true;
newSessionPendingRef.current = false;
// Only send /new to backend for non-file sessions (main chat)
if (!filePath) {
setStartingNewSession(true);
try {
await fetch("/api/new-session", { method: "POST" });
await fetch("/api/new-session", {
method: "POST",
});
} catch (err) {
console.error("Failed to send /new:", err);
} finally {
setStartingNewSession(false);
}
}
// NOTE: we intentionally do NOT clear fileSessions so the
// session tab list remains intact.
}, [setMessages, onActiveSessionChange, filePath, stop]);
// Expose imperative handle for parent-driven session management
useImperativeHandle(
ref,
() => ({
@ -514,7 +527,12 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
</>
) : (
<>
<h2 className="text-sm font-semibold">
<h2
className="text-sm font-semibold"
style={{
color: "var(--color-text)",
}}
>
{currentSessionId
? "Chat Session"
: "New Chat"}
@ -535,20 +553,11 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
<button
type="button"
onClick={() => handleNewSession()}
className="p-1 rounded transition-colors"
style={{ color: "var(--color-text-muted)" }}
className="p-1.5 rounded-lg"
style={{
color: "var(--color-text-muted)",
}}
title="New chat"
onMouseEnter={(e) => {
(
e.currentTarget as HTMLElement
).style.background =
"var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(
e.currentTarget as HTMLElement
).style.background = "transparent";
}}
>
<svg
width="14"
@ -569,10 +578,12 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
<button
type="button"
onClick={() => stop()}
className={`${compact ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs"} rounded-md transition-colors`}
className={`${compact ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs"} rounded-full font-medium`}
style={{
background: "var(--color-border)",
background:
"var(--color-surface-hover)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
>
Stop
@ -585,24 +596,31 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
{compact && fileContext && fileSessions.length > 0 && (
<div
className="px-2 py-1.5 border-b flex gap-1 overflow-x-auto flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
style={{
borderColor: "var(--color-border)",
}}
>
{fileSessions.slice(0, 10).map((s) => (
<button
key={s.id}
type="button"
onClick={() => handleSessionSelect(s.id)}
className="px-2 py-0.5 text-[10px] rounded-md whitespace-nowrap transition-colors flex-shrink-0"
onClick={() =>
handleSessionSelect(s.id)
}
className="px-2.5 py-1 text-[10px] rounded-full whitespace-nowrap flex-shrink-0 font-medium"
style={{
background:
s.id === currentSessionId
? "var(--color-accent)"
: "var(--color-surface)",
: "var(--color-surface-hover)",
color:
s.id === currentSessionId
? "white"
: "var(--color-text-muted)",
border: `1px solid ${s.id === currentSessionId ? "var(--color-accent)" : "var(--color-border)"}`,
border:
s.id === currentSessionId
? "none"
: "1px solid var(--color-border)",
}}
>
{s.title.length > 25
@ -641,7 +659,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="text-center max-w-md px-4">
{compact ? (
<p
className="text-sm"
@ -653,14 +671,16 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
</p>
) : (
<>
<p className="text-6xl mb-4">
🦞
</p>
<h3 className="text-lg font-semibold mb-1">
Ironclaw Chat
<h3
className="font-instrument text-3xl tracking-tight mb-2"
style={{
color: "var(--color-text)",
}}
>
What can I help with?
</h3>
<p
className="text-sm"
className="text-sm leading-relaxed"
style={{
color: "var(--color-text-muted)",
}}
@ -693,11 +713,9 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
<div
className="px-3 py-2 border-t flex-shrink-0 flex items-center gap-2"
style={{
background:
"color-mix(in srgb, var(--color-error, #ef4444) 10%, var(--color-surface))",
borderColor:
"color-mix(in srgb, var(--color-error, #ef4444) 25%, transparent)",
color: "var(--color-error, #ef4444)",
background: `color-mix(in srgb, var(--color-error) 6%, var(--color-surface))`,
borderColor: `color-mix(in srgb, var(--color-error) 18%, transparent)`,
color: "var(--color-error)",
}}
>
<svg
@ -712,71 +730,132 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
className="flex-shrink-0"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
<line
x1="12"
y1="8"
x2="12"
y2="12"
/>
<line
x1="12"
y1="16"
x2="12.01"
y2="16"
/>
</svg>
<p className="text-xs">
{error.message}
</p>
<p className="text-xs">{error.message}</p>
</div>
)}
{/* Input */}
{/* Input — Dench-style rounded area with toolbar */}
<div
className={`${compact ? "px-3 py-2" : "px-6 py-4"} border-t flex-shrink-0`}
style={{
borderColor: "var(--color-border)",
background: "var(--color-surface)",
}}
className={`${compact ? "px-3 py-2" : "px-6 py-4"} flex-shrink-0`}
style={{ background: "var(--color-bg)" }}
>
<form
onSubmit={handleSubmit}
className={`${compact ? "" : "max-w-3xl mx-auto"} flex gap-2`}
<div
className={`${compact ? "" : "max-w-3xl mx-auto"}`}
>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={
compact && fileContext
? `Ask about ${fileContext.filename}...`
: "Message Ironclaw..."
}
disabled={
isStreaming ||
loadingSession ||
startingNewSession
}
className={`flex-1 ${compact ? "px-3 py-2 text-xs rounded-lg" : "px-4 py-3 text-sm rounded-xl"} border focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)] focus:border-transparent disabled:opacity-50`}
<div
className="rounded-2xl overflow-hidden"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
/>
<button
type="submit"
disabled={
!input.trim() ||
isStreaming ||
loadingSession ||
startingNewSession
}
className={`${compact ? "px-3 py-2 text-xs rounded-lg" : "px-5 py-3 text-sm rounded-xl"} font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed`}
style={{
background: "var(--color-accent)",
color: "white",
background:
"var(--color-chat-input-bg)",
border: "1px solid var(--color-border)",
}}
>
{isStreaming ? (
<div
className={`${compact ? "w-3 h-3" : "w-5 h-5"} border-2 border-white/30 border-t-white rounded-full animate-spin`}
<form onSubmit={handleSubmit}>
<input
type="text"
value={input}
onChange={(e) =>
setInput(e.target.value)
}
placeholder={
compact && fileContext
? `Ask about ${fileContext.filename}...`
: "Ask anything..."
}
disabled={
isStreaming ||
loadingSession ||
startingNewSession
}
className={`w-full ${compact ? "px-3 py-2.5 text-xs" : "px-4 py-3.5 text-sm"} bg-transparent outline-none placeholder:text-[var(--color-text-muted)] disabled:opacity-50`}
style={{
color: "var(--color-text)",
}}
/>
) : (
"Send"
)}
</button>
</form>
</form>
{/* Toolbar row */}
<div
className={`flex items-center justify-between ${compact ? "px-2 pb-1.5" : "px-3 pb-2.5"}`}
>
<div className="flex items-center gap-0.5">
{/* Placeholder toolbar icons */}
<button
type="button"
className="p-1.5 rounded-lg"
style={{
color: "var(--color-text-muted)",
}}
title="Attach"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
</button>
</div>
{/* Send button */}
<button
type="submit"
onClick={handleSubmit}
disabled={
!input.trim() ||
isStreaming ||
loadingSession ||
startingNewSession
}
className={`${compact ? "w-6 h-6" : "w-7 h-7"} rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed`}
style={{
background:
input.trim()
? "var(--color-accent)"
: "var(--color-border-strong)",
color: "white",
}}
>
{isStreaming ? (
<div
className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin"
/>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 19V5" />
<path d="m5 12 7-7 7 7" />
</svg>
)}
</button>
</div>
</div>
</div>
</div>
</div>
);

View File

@ -0,0 +1,806 @@
"use client";
import React, { useState, useCallback, useMemo, useRef, useEffect } from "react";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
flexRender,
type ColumnDef,
type SortingState,
type ColumnFiltersState,
type VisibilityState,
type Row,
type OnChangeFn,
type PaginationState,
} from "@tanstack/react-table";
import {
DndContext,
closestCenter,
type DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
horizontalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { rankItem } from "@tanstack/match-sorter-utils";
/* ─── Types ─── */
export type RowAction<TData> = {
label: string;
onClick?: (row: TData) => void;
icon?: React.ReactNode;
variant?: "default" | "destructive";
};
export type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
loading?: boolean;
// search
searchPlaceholder?: string;
enableGlobalFilter?: boolean;
// sorting
enableSorting?: boolean;
// row selection
enableRowSelection?: boolean;
rowSelection?: Record<string, boolean>;
onRowSelectionChange?: OnChangeFn<Record<string, boolean>>;
bulkActions?: React.ReactNode;
// column features
enableColumnReordering?: boolean;
onColumnReorder?: (newOrder: string[]) => void;
initialColumnVisibility?: VisibilityState;
// pagination
pageSize?: number;
// actions
onRefresh?: () => void;
onAdd?: () => void;
addButtonLabel?: string;
onRowClick?: (row: TData, index: number) => void;
rowActions?: (row: TData) => RowAction<TData>[];
// toolbar
toolbarExtra?: React.ReactNode;
title?: string;
titleIcon?: React.ReactNode;
// sticky
stickyFirstColumn?: boolean;
};
/* ─── Fuzzy filter ─── */
function fuzzyFilter(
row: Row<unknown>,
columnId: string,
filterValue: string,
) {
const result = rankItem(row.getValue(columnId), filterValue);
return result.passed;
}
/* ─── Sortable header cell (DnD) ─── */
function SortableHeader({
id,
children,
style,
className,
}: {
id: string;
children: React.ReactNode;
style?: React.CSSProperties;
className?: string;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const dragStyle: React.CSSProperties = {
...style,
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: "grab",
};
return (
<th
ref={setNodeRef}
style={dragStyle}
className={className}
{...attributes}
{...listeners}
>
{children}
</th>
);
}
/* ─── Sort icon ─── */
function SortIcon({ direction }: { direction: "asc" | "desc" | false }) {
if (!direction) {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.25 }}>
<path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" />
</svg>
);
}
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{direction === "asc" ? <path d="m5 12 7-7 7 7" /> : <path d="m19 12-7 7-7-7" />}
</svg>
);
}
/* ─── Main component ─── */
export function DataTable<TData, TValue>({
columns,
data,
loading = false,
searchPlaceholder = "Search...",
enableGlobalFilter = true,
enableSorting = true,
enableRowSelection = false,
rowSelection: externalRowSelection,
onRowSelectionChange,
bulkActions,
enableColumnReordering = false,
onColumnReorder,
initialColumnVisibility,
pageSize: defaultPageSize = 100,
onRefresh,
onAdd,
addButtonLabel = "+ Add",
onRowClick,
rowActions,
toolbarExtra,
title,
titleIcon,
stickyFirstColumn: stickyFirstProp = true,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(initialColumnVisibility ?? {});
const [internalRowSelection, setInternalRowSelection] = useState<Record<string, boolean>>({});
const [showColumnsMenu, setShowColumnsMenu] = useState(false);
const [stickyFirstColumn, setStickyFirstColumn] = useState(stickyFirstProp);
const [isScrolled, setIsScrolled] = useState(false);
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: defaultPageSize });
const columnsMenuRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const rowSelectionState = externalRowSelection !== undefined ? externalRowSelection : internalRowSelection;
// Extract column ID from ColumnDef
const getColumnId = useCallback((c: ColumnDef<TData, TValue>): string => {
if ("id" in c && typeof c.id === "string") {return c.id;}
if ("accessorKey" in c && typeof c.accessorKey === "string") {return c.accessorKey;}
return "";
}, []);
// Column order for DnD — include "select" at start and "actions" at end
// so TanStack doesn't push them to the end of the table
const buildColumnOrder = useCallback(
(dataCols: ColumnDef<TData, TValue>[]) => {
const dataOrder = dataCols.map(getColumnId);
const order: string[] = [];
if (enableRowSelection) {order.push("select");}
order.push(...dataOrder);
if (rowActions) {order.push("actions");}
return order;
},
[getColumnId, enableRowSelection, rowActions],
);
const [columnOrder, setColumnOrder] = useState<string[]>(() =>
buildColumnOrder(columns),
);
// Update column order when columns change
useEffect(() => {
setColumnOrder(buildColumnOrder(columns));
}, [columns, buildColumnOrder]);
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setColumnOrder((old) => {
const oldIndex = old.indexOf(active.id as string);
const newIndex = old.indexOf(over.id as string);
const newOrder = arrayMove(old, oldIndex, newIndex);
onColumnReorder?.(newOrder);
return newOrder;
});
}
},
[onColumnReorder],
);
// Scroll tracking for sticky column shadow
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
setIsScrolled(e.currentTarget.scrollLeft > 0);
}, []);
// Close columns menu on click outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (columnsMenuRef.current && !columnsMenuRef.current.contains(e.target as Node)) {
setShowColumnsMenu(false);
}
}
if (showColumnsMenu) {
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}
}, [showColumnsMenu]);
// Build selection column
const selectionColumn: ColumnDef<TData> | null = enableRowSelection
? {
id: "select",
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllPageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)] cursor-pointer"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)] cursor-pointer"
/>
),
size: 40,
enableSorting: false,
enableHiding: false,
}
: null;
// Build actions column
const actionsColumn: ColumnDef<TData> | null = rowActions
? {
id: "actions",
header: () => null,
cell: ({ row }) => (
<RowActionsMenu
row={row.original}
actions={rowActions(row.original)}
/>
),
size: 48,
enableSorting: false,
enableHiding: false,
}
: null;
const allColumns = useMemo(() => {
const cols: ColumnDef<TData, TValue>[] = [];
if (selectionColumn) {cols.push(selectionColumn as ColumnDef<TData, TValue>);}
cols.push(...columns);
if (actionsColumn) {cols.push(actionsColumn as ColumnDef<TData, TValue>);}
return cols;
}, [columns, selectionColumn, actionsColumn]);
const table = useReactTable({
data,
columns: allColumns,
state: {
sorting,
globalFilter,
columnFilters,
columnVisibility,
rowSelection: rowSelectionState,
columnOrder: enableColumnReordering ? columnOrder : undefined,
pagination,
},
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: (updater) => {
if (onRowSelectionChange) {
onRowSelectionChange(updater);
} else {
setInternalRowSelection(updater);
}
},
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
enableRowSelection,
enableSorting,
globalFilterFn: fuzzyFilter,
columnResizeMode: "onChange",
});
const selectedCount = Object.keys(rowSelectionState).filter((k) => rowSelectionState[k]).length;
const visibleColumns = table.getVisibleFlatColumns().filter((c) => c.id !== "select" && c.id !== "actions");
// ─── Render ───
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div
className="flex items-center gap-2 px-4 py-2.5 flex-shrink-0 flex-wrap"
style={{ borderBottom: "1px solid var(--color-border)" }}
>
{title && (
<div className="flex items-center gap-2 mr-2">
{titleIcon}
<span className="text-sm font-semibold" style={{ color: "var(--color-text)" }}>
{title}
</span>
</div>
)}
{/* Search */}
{enableGlobalFilter && (
<div className="relative flex-1 min-w-[180px] max-w-[320px]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: "var(--color-text-muted)" }}>
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg>
<input
type="text"
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder={searchPlaceholder}
className="w-full pl-9 pr-3 py-1.5 text-xs rounded-full outline-none"
style={{
background: "var(--color-surface-hover)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
/>
{globalFilter && (
<button
type="button"
onClick={() => setGlobalFilter("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
</button>
)}
</div>
)}
{/* Bulk actions */}
{selectedCount > 0 && bulkActions && (
<div className="flex items-center gap-2">
<span className="text-xs font-medium" style={{ color: "var(--color-text-muted)" }}>
{selectedCount} selected
</span>
{bulkActions}
</div>
)}
<div className="flex-1" />
{toolbarExtra}
{/* Refresh */}
{onRefresh && (
<button
type="button"
onClick={onRefresh}
className="p-1.5 rounded-lg"
style={{ color: "var(--color-text-muted)" }}
title="Refresh"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" /><path d="M3 21v-5h5" />
</svg>
</button>
)}
{/* Columns menu */}
<div className="relative" ref={columnsMenuRef}>
<button
type="button"
onClick={() => setShowColumnsMenu((v) => !v)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium"
style={{
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
background: showColumnsMenu ? "var(--color-surface-hover)" : "transparent",
}}
>
<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" /><path d="M9 3v18" /><path d="M15 3v18" />
</svg>
Columns
</button>
{showColumnsMenu && (
<div
className="absolute right-0 top-full mt-1 z-50 min-w-[200px] rounded-xl overflow-hidden py-1"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
boxShadow: "var(--shadow-lg)",
}}
>
{/* Sticky first col toggle */}
<label
className="flex items-center gap-2 px-3 py-2 text-xs cursor-pointer"
style={{ color: "var(--color-text-muted)", borderBottom: "1px solid var(--color-border)" }}
>
<input
type="checkbox"
checked={stickyFirstColumn}
onChange={() => setStickyFirstColumn((v) => !v)}
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)]"
/>
Freeze first column
</label>
{visibleColumns.length === 0 ? (
<div className="px-3 py-2 text-xs" style={{ color: "var(--color-text-muted)" }}>
No toggleable columns
</div>
) : (
table.getAllLeafColumns()
.filter((c) => c.id !== "select" && c.id !== "actions" && c.getCanHide())
.map((column) => (
<label
key={column.id}
className="flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer"
style={{ color: "var(--color-text)" }}
>
<input
type="checkbox"
checked={column.getIsVisible()}
onChange={column.getToggleVisibilityHandler()}
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)]"
/>
{typeof column.columnDef.header === "string"
? column.columnDef.header
: column.id}
</label>
))
)}
</div>
)}
</div>
{/* Add button */}
{onAdd && (
<button
type="button"
onClick={onAdd}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium"
style={{
background: "var(--color-accent)",
color: "white",
}}
>
{addButtonLabel}
</button>
)}
</div>
{/* Table */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-auto"
onScroll={handleScroll}
>
{loading ? (
<LoadingSkeleton columnCount={allColumns.length} />
) : data.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" /><path d="M9 3v18" />
</svg>
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>No data</p>
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<table className="w-full text-sm" style={{ borderCollapse: "separate", borderSpacing: 0 }}>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header, colIdx) => {
const isFirstData = colIdx === (enableRowSelection ? 1 : 0);
const isSticky = stickyFirstColumn && isFirstData;
const isSelectCol = header.id === "select";
const isActionsCol = header.id === "actions";
const canSort = header.column.getCanSort();
const headerStyle: React.CSSProperties = {
borderColor: "var(--color-border)",
background: "var(--color-surface)",
position: "sticky",
top: 0,
zIndex: isSticky || isSelectCol ? 31 : 30,
...(isSticky ? { left: enableRowSelection ? 40 : 0, boxShadow: isScrolled ? "4px 0 8px -2px rgba(0,0,0,0.08)" : "none" } : {}),
...(isSelectCol ? { left: 0, position: "sticky", zIndex: 31, width: 40 } : {}),
width: header.getSize(),
};
const content = header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext());
if (enableColumnReordering && !isSelectCol && !isActionsCol) {
return (
<SortableHeader
key={header.id}
id={header.id}
style={headerStyle}
className="text-left px-3 py-2.5 font-medium text-xs uppercase tracking-wider whitespace-nowrap border-b select-none"
>
<span
className={`flex items-center gap-1 ${canSort ? "cursor-pointer" : ""}`}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
>
{content}
{canSort && <SortIcon direction={header.column.getIsSorted()} />}
</span>
</SortableHeader>
);
}
return (
<th
key={header.id}
style={headerStyle}
className="text-left px-3 py-2.5 font-medium text-xs uppercase tracking-wider whitespace-nowrap border-b select-none"
>
<span
className={`flex items-center gap-1 ${canSort ? "cursor-pointer" : ""}`}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
style={{ color: "var(--color-text-muted)" }}
>
{content}
{canSort && <SortIcon direction={header.column.getIsSorted()} />}
</span>
</th>
);
})}
</SortableContext>
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row, rowIdx) => {
const isSelected = row.getIsSelected();
return (
<tr
key={row.id}
className={`transition-colors duration-75 ${onRowClick ? "cursor-pointer" : ""}`}
style={{
background: isSelected
? "var(--color-accent-light)"
: rowIdx % 2 === 0
? "transparent"
: "var(--color-surface)",
}}
onClick={() => onRowClick?.(row.original, rowIdx)}
onMouseEnter={(e) => {
if (!isSelected)
{(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";}
}}
onMouseLeave={(e) => {
if (!isSelected)
{(e.currentTarget as HTMLElement).style.background =
rowIdx % 2 === 0 ? "transparent" : "var(--color-surface)";}
}}
>
{row.getVisibleCells().map((cell, colIdx) => {
const isFirstData = colIdx === (enableRowSelection ? 1 : 0);
const isSticky = stickyFirstColumn && isFirstData;
const isSelectCol = cell.column.id === "select";
const cellStyle: React.CSSProperties = {
borderColor: "var(--color-border)",
...(isSticky
? {
position: "sticky" as const,
left: enableRowSelection ? 40 : 0,
zIndex: 20,
background: isSelected
? "var(--color-accent-light)"
: "var(--color-bg)",
boxShadow: isScrolled ? "4px 0 8px -2px rgba(0,0,0,0.08)" : "none",
}
: {}),
...(isSelectCol
? {
position: "sticky" as const,
left: 0,
zIndex: 20,
background: isSelected
? "var(--color-accent-light)"
: "var(--color-bg)",
width: 40,
}
: {}),
};
return (
<td
key={cell.id}
className="px-3 py-2 border-b whitespace-nowrap"
style={cellStyle}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</DndContext>
)}
</div>
{/* Pagination footer */}
{!loading && data.length > 0 && (
<div
className="flex items-center justify-between px-4 py-2 text-xs flex-shrink-0"
style={{
borderTop: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<span>
Showing {table.getRowModel().rows.length} of {data.length} results
{selectedCount > 0 && ` (${selectedCount} selected)`}
</span>
<div className="flex items-center gap-2">
<span>Rows per page</span>
<select
value={pagination.pageSize}
onChange={(e) => setPagination((p) => ({ ...p, pageSize: Number(e.target.value), pageIndex: 0 }))}
className="px-1.5 py-0.5 rounded-md text-xs outline-none"
style={{
background: "var(--color-surface-hover)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
>
{[20, 50, 100, 250, 500].map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span>
Page {pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<div className="flex gap-0.5">
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: 0 }))} disabled={!table.getCanPreviousPage()} label="&laquo;" />
<PaginationButton onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} label="&lsaquo;" />
<PaginationButton onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} label="&rsaquo;" />
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: table.getPageCount() - 1 }))} disabled={!table.getCanNextPage()} label="&raquo;" />
</div>
</div>
</div>
)}
</div>
);
}
/* ─── Sub-components ─── */
function PaginationButton({ onClick, disabled, label }: { onClick: () => void; disabled: boolean; label: string }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="w-6 h-6 rounded flex items-center justify-center text-xs disabled:opacity-30"
style={{ color: "var(--color-text-muted)", border: "1px solid var(--color-border)" }}
// biome-ignore lint: using html entity label
dangerouslySetInnerHTML={{ __html: label }}
/>
);
}
function RowActionsMenu<TData>({ row, actions }: { row: TData; actions: RowAction<TData>[] }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) {
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}
}, [open]);
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
className="p-1 rounded-md"
style={{ color: "var(--color-text-muted)" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="12" cy="19" r="1.5" /></svg>
</button>
{open && (
<div
className="absolute right-0 top-full mt-1 z-50 min-w-[140px] rounded-xl overflow-hidden py-1"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
boxShadow: "var(--shadow-lg)",
}}
>
{actions.map((action, i) => (
<button
key={i}
type="button"
onClick={(e) => {
e.stopPropagation();
action.onClick?.(row);
setOpen(false);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left"
style={{
color: action.variant === "destructive" ? "var(--color-error)" : "var(--color-text)",
}}
>
{action.icon}
{action.label}
</button>
))}
</div>
)}
</div>
);
}
function LoadingSkeleton({ columnCount }: { columnCount: number }) {
return (
<div className="p-4 space-y-2">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="flex gap-3">
{Array.from({ length: Math.min(columnCount, 6) }).map((_, j) => (
<div
key={j}
className="h-8 rounded-lg animate-pulse flex-1"
style={{ background: "var(--color-surface-hover)", animationDelay: `${j * 50}ms` }}
/>
))}
</div>
))}
</div>
);
}

View File

@ -403,7 +403,7 @@ export function DatabaseViewer({ dbPath, filename }: DatabaseViewerProps) {
onClick={() => setQueryMode(!queryMode)}
className="w-full flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors duration-100 cursor-pointer"
style={{
background: queryMode ? "rgba(232, 93, 58, 0.15)" : "var(--color-surface-hover)",
background: queryMode ? "var(--color-accent-light)" : "var(--color-surface-hover)",
color: queryMode ? "var(--color-accent)" : "var(--color-text-muted)",
border: `1px solid ${queryMode ? "var(--color-accent)" : "var(--color-border)"}`,
}}

View File

@ -1,119 +1,156 @@
"use client";
export function EmptyState({ workspaceExists }: { workspaceExists: boolean }) {
return (
<div className="flex flex-col items-center justify-center h-full gap-6 px-8">
{/* Icon */}
<div
className="w-20 h-20 rounded-2xl flex items-center justify-center"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
}}
>
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--color-text-muted)", opacity: 0.5 }}
>
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
</div>
export function EmptyState({
workspaceExists,
}: {
workspaceExists: boolean;
}) {
return (
<div className="flex flex-col items-center justify-center h-full gap-6 px-8">
{/* Icon */}
<div
className="w-20 h-20 rounded-2xl flex items-center justify-center"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
boxShadow: "var(--shadow-sm)",
}}
>
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{
color: "var(--color-text-muted)",
opacity: 0.5,
}}
>
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect
width="7"
height="7"
x="14"
y="3"
rx="1"
/>
<rect
width="7"
height="7"
x="14"
y="14"
rx="1"
/>
<rect
width="7"
height="7"
x="3"
y="14"
rx="1"
/>
</svg>
</div>
{/* Text */}
<div className="text-center max-w-md">
<h2
className="text-lg font-semibold mb-2"
style={{ color: "var(--color-text)" }}
>
{workspaceExists
? "Workspace is empty"
: "No workspace found"}
</h2>
<p
className="text-sm leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{workspaceExists ? (
<>
The Dench workspace exists but has no knowledge tree yet.
Ask the CRM agent to create objects and documents to populate it.
</>
) : (
<>
The Dench workspace directory was not found. To initialize it,
start a conversation with the CRM agent and it will create the
workspace structure automatically.
</>
)}
</p>
</div>
{/* Text */}
<div className="text-center max-w-md">
<h2
className="font-instrument text-2xl tracking-tight mb-2"
style={{ color: "var(--color-text)" }}
>
{workspaceExists
? "Workspace is empty"
: "No workspace found"}
</h2>
<p
className="text-sm leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{workspaceExists ? (
<>
The Dench workspace exists but has no
knowledge tree yet. Ask the CRM agent to
create objects and documents to populate
it.
</>
) : (
<>
The Dench workspace directory was not
found. To initialize it, start a
conversation with the CRM agent and it
will create the workspace structure
automatically.
</>
)}
</p>
</div>
{/* Hint */}
<div
className="flex items-center gap-2 px-4 py-3 rounded-lg text-sm"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--color-accent)", flexShrink: 0 }}
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
<span>
Expected location:{" "}
<code
className="px-1.5 py-0.5 rounded text-xs"
style={{ background: "var(--color-bg)" }}
>
~/.openclaw/workspace/dench/
</code>
</span>
</div>
{/* Hint */}
<div
className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
boxShadow: "var(--shadow-sm)",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{
color: "var(--color-accent)",
flexShrink: 0,
}}
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
<span>
Expected location:{" "}
<code
className="px-1.5 py-0.5 rounded-md text-xs"
style={{
background: "var(--color-surface-hover)",
border: "1px solid var(--color-border)",
}}
>
~/.openclaw/workspace/dench/
</code>
</span>
</div>
{/* Back link */}
<a
href="/"
className="flex items-center gap-2 text-sm mt-2 transition-colors"
style={{ color: "var(--color-accent)" }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
Back to Home
</a>
</div>
);
{/* Back link */}
<a
href="/"
className="flex items-center gap-2 text-sm mt-2"
style={{ color: "var(--color-accent)" }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
Back to Home
</a>
</div>
);
}

View File

@ -49,6 +49,8 @@ type EntryDetailModalProps = {
onNavigateEntry?: (objectName: string, entryId: string) => void;
/** Navigate to an object table view. */
onNavigateObject?: (objectName: string) => void;
/** Called after an edit or delete to refresh parent data. */
onRefresh?: () => void;
};
// --- Helpers ---
@ -289,10 +291,15 @@ export function EntryDetailModal({
onClose,
onNavigateEntry,
onNavigateObject,
onRefresh,
}: EntryDetailModalProps) {
const [data, setData] = useState<EntryDetailData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingField, setEditingField] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const backdropRef = useRef<HTMLDivElement>(null);
// Fetch entry data
@ -348,6 +355,46 @@ export function EntryDetailModal({
[onClose],
);
// ── Edit handler ──
const handleSaveField = useCallback(async (fieldName: string, value: string) => {
setSaving(true);
try {
const res = await fetch(
`/api/workspace/objects/${encodeURIComponent(objectName)}/entries/${encodeURIComponent(entryId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: { [fieldName]: value } }),
},
);
if (res.ok) {
// Update local data optimistically
setData((prev) => {
if (!prev) {return prev;}
return { ...prev, entry: { ...prev.entry, [fieldName]: value } };
});
setEditingField(null);
onRefresh?.();
}
} catch { /* ignore */ }
finally { setSaving(false); }
}, [objectName, entryId, onRefresh]);
// ── Delete handler ──
const handleDelete = useCallback(async () => {
if (!confirm("Are you sure you want to delete this entry?")) {return;}
setDeleting(true);
try {
await fetch(
`/api/workspace/objects/${encodeURIComponent(objectName)}/entries/${encodeURIComponent(entryId)}`,
{ method: "DELETE" },
);
onRefresh?.();
onClose();
} catch { /* ignore */ }
finally { setDeleting(false); }
}, [objectName, entryId, onRefresh, onClose]);
const displayField = data?.effectiveDisplayField;
const title = displayField && data?.entry[displayField]
? String(data.entry[displayField])
@ -380,9 +427,9 @@ export function EntryDetailModal({
onClick={() => onNavigateObject?.(objectName)}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium capitalize transition-colors hover:opacity-80 flex-shrink-0"
style={{
background: "rgba(232, 93, 58, 0.1)",
background: "var(--color-accent-light)",
color: "var(--color-accent)",
border: "1px solid rgba(232, 93, 58, 0.2)",
border: "1px solid var(--color-border)",
}}
title={`Go to ${objectName}`}
>
@ -398,17 +445,33 @@ export function EntryDetailModal({
{loading ? "Loading..." : title}
</h2>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-md transition-colors flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Close"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Delete button */}
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="p-1.5 rounded-lg flex-shrink-0"
style={{ color: "var(--color-error)" }}
title="Delete entry"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</button>
{/* Close button */}
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Close"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
@ -447,13 +510,72 @@ export function EntryDetailModal({
className="text-sm min-h-[1.75rem] flex items-center"
style={{ color: "var(--color-text)" }}
>
<FieldValue
value={value}
field={field}
members={members}
relationLabels={data.relationLabels}
onNavigateEntry={onNavigateEntry}
/>
{editingField === field.name ? (
<form
onSubmit={(e) => { e.preventDefault(); handleSaveField(field.name, editValue); }}
className="flex items-center gap-2 w-full"
>
{field.type === "enum" && field.enum_values ? (
<select
value={editValue}
onChange={(e) => { setEditValue(e.target.value); handleSaveField(field.name, e.target.value); }}
autoFocus
className="flex-1 px-2 py-1 text-sm rounded-lg outline-none"
style={{ background: "var(--color-surface-hover)", color: "var(--color-text)", border: "2px solid var(--color-accent)" }}
>
<option value="">--</option>
{field.enum_values.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
) : field.type === "boolean" ? (
<select
value={editValue}
onChange={(e) => { setEditValue(e.target.value); handleSaveField(field.name, e.target.value); }}
autoFocus
className="flex-1 px-2 py-1 text-sm rounded-lg outline-none"
style={{ background: "var(--color-surface-hover)", color: "var(--color-text)", border: "2px solid var(--color-accent)" }}
>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
) : (
<>
<input
type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
autoFocus
className="flex-1 px-2 py-1 text-sm rounded-lg outline-none"
style={{ background: "var(--color-surface-hover)", color: "var(--color-text)", border: "2px solid var(--color-accent)" }}
/>
<button type="submit" disabled={saving} className="px-2 py-1 text-xs rounded-lg font-medium" style={{ background: "var(--color-accent)", color: "white" }}>
{saving ? "..." : "Save"}
</button>
</>
)}
<button type="button" onClick={() => setEditingField(null)} className="px-2 py-1 text-xs rounded-lg" style={{ color: "var(--color-text-muted)", border: "1px solid var(--color-border)" }}>
Cancel
</button>
</form>
) : (
<div
className={`flex-1 ${!["relation", "user"].includes(field.type) ? "cursor-pointer hover:opacity-80" : ""}`}
onClick={() => {
if (!["relation", "user"].includes(field.type)) {
setEditingField(field.name);
setEditValue(String(value ?? ""));
}
}}
title={!["relation", "user"].includes(field.type) ? "Click to edit" : undefined}
>
<FieldValue
value={value}
field={field}
members={members}
relationLabels={data.relationLabels}
onNavigateEntry={onNavigateEntry}
/>
</div>
)}
</div>
</div>
);

View File

@ -449,7 +449,7 @@ function DraggableNode({
style={{
paddingLeft: `${depth * 16 + 8}px`,
background: showDropHighlight
? "rgba(232, 93, 58, 0.12)"
? "var(--color-accent-light)"
: isSelected
? "var(--color-surface-hover)"
: isActive
@ -502,7 +502,7 @@ function DraggableNode({
{/* Type badge for objects */}
{node.type === "object" && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
style={{ background: "rgba(232, 93, 58, 0.15)", color: "var(--color-accent)" }}>
style={{ background: "var(--color-accent-light)", color: "var(--color-accent)" }}>
{node.defaultView === "kanban" ? "board" : "table"}
</span>
)}
@ -773,12 +773,12 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
break;
}
case "newFile": {
const parent = target.kind === "folder" ? target.path : target.kind === "file" ? parentPath(target.path) : "knowledge";
const parent = target.kind === "folder" ? target.path : target.kind === "file" ? parentPath(target.path) : "";
setNewItemPrompt({ kind: "file", parentPath: parent });
break;
}
case "newFolder": {
const parent = target.kind === "folder" ? target.path : target.kind === "file" ? parentPath(target.path) : "knowledge";
const parent = target.kind === "folder" ? target.path : target.kind === "file" ? parentPath(target.path) : "";
setNewItemPrompt({ kind: "folder", parentPath: parent });
break;
}
@ -938,7 +938,7 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
? curNode.type === "folder" || curNode.type === "object"
? curNode.path
: parentPath(curNode.path)
: "knowledge";
: "";
if (e.shiftKey) {
setNewItemPrompt({ kind: "folder", parentPath: parent });
} else {

View File

@ -204,7 +204,7 @@ function TreeNodeItem({
<span
className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
style={{
background: "rgba(232, 93, 58, 0.15)",
background: "var(--color-accent-light)",
color: "var(--color-accent)",
}}
>

View File

@ -0,0 +1,387 @@
"use client";
import { useState, useCallback } from "react";
// --- Types ---
export type MediaType = "image" | "video" | "audio" | "pdf";
type MediaViewerProps = {
/** URL to serve the raw file (e.g. /api/workspace/raw-file?path=...) */
url: string;
/** Original filename for display */
filename: string;
/** Detected media type */
mediaType: MediaType;
/** Original workspace path for download/copy */
filePath?: string;
};
// --- Extension → MediaType mapping ---
const IMAGE_EXTS = new Set([
"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif", "heic", "heif",
"ico", "tiff", "tif",
]);
const VIDEO_EXTS = new Set(["mp4", "webm", "mov", "avi", "mkv"]);
const AUDIO_EXTS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac"]);
const PDF_EXTS = new Set(["pdf"]);
/** Returns the media type for a filename, or null if it's not a known media file. */
export function detectMediaType(filename: string): MediaType | null {
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
if (IMAGE_EXTS.has(ext)) {return "image";}
if (VIDEO_EXTS.has(ext)) {return "video";}
if (AUDIO_EXTS.has(ext)) {return "audio";}
if (PDF_EXTS.has(ext)) {return "pdf";}
return null;
}
// --- Icons ---
function DownloadIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
);
}
function ExternalLinkIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
);
}
function ZoomInIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" x2="16.65" y1="21" y2="16.65" />
<line x1="11" x2="11" y1="8" y2="14" />
<line x1="8" x2="14" y1="11" y2="11" />
</svg>
);
}
function ZoomOutIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" x2="16.65" y1="21" y2="16.65" />
<line x1="8" x2="14" y1="11" y2="11" />
</svg>
);
}
function mediaTypeLabel(mediaType: MediaType): string {
switch (mediaType) {
case "image": return "Image";
case "video": return "Video";
case "audio": return "Audio";
case "pdf": return "PDF";
}
}
function mediaTypeColor(mediaType: MediaType): string {
switch (mediaType) {
case "image": return "#60a5fa";
case "video": return "#c084fc";
case "audio": return "#f59e0b";
case "pdf": return "#ef4444";
}
}
// --- Main Component ---
export function MediaViewer({ url, filename, mediaType, filePath }: MediaViewerProps) {
return (
<div className="flex flex-col h-full">
{/* Header bar */}
<div
className="flex items-center gap-3 px-5 py-3 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<MediaTypeIcon mediaType={mediaType} />
<span className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
{filename}
</span>
<span
className="text-[10px] px-2 py-0.5 rounded-full flex-shrink-0"
style={{
background: `${mediaTypeColor(mediaType)}18`,
color: mediaTypeColor(mediaType),
border: `1px solid ${mediaTypeColor(mediaType)}30`,
}}
>
{mediaTypeLabel(mediaType)}
</span>
<div className="flex items-center gap-1 ml-auto">
{/* Open in new tab */}
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-md transition-colors duration-100"
style={{ color: "var(--color-text-muted)" }}
title="Open in new tab"
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<ExternalLinkIcon />
</a>
{/* Download */}
<a
href={url}
download={filename}
className="p-1.5 rounded-md transition-colors duration-100"
style={{ color: "var(--color-text-muted)" }}
title="Download"
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<DownloadIcon />
</a>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto flex items-center justify-center p-6" style={{ background: "var(--color-surface)" }}>
{mediaType === "image" && <ImageViewer url={url} filename={filename} />}
{mediaType === "video" && <VideoViewer url={url} />}
{mediaType === "audio" && <AudioViewer url={url} filename={filename} />}
{mediaType === "pdf" && <PdfViewer url={url} />}
</div>
{/* Footer with path */}
{filePath && (
<div
className="px-5 py-2 border-t flex-shrink-0 flex items-center"
style={{ borderColor: "var(--color-border)" }}
>
<span
className="text-[11px] truncate"
style={{ color: "var(--color-text-muted)", fontFamily: "'SF Mono', 'Fira Code', monospace" }}
>
{filePath}
</span>
</div>
)}
</div>
);
}
// --- Image Viewer (with zoom) ---
function ImageViewer({ url, filename }: { url: string; filename: string }) {
const [zoom, setZoom] = useState(1);
const [error, setError] = useState(false);
const handleZoomIn = useCallback(() => setZoom((z) => Math.min(z * 1.5, 5)), []);
const handleZoomOut = useCallback(() => setZoom((z) => Math.max(z / 1.5, 0.25)), []);
const handleReset = useCallback(() => setZoom(1), []);
if (error) {
return (
<div className="flex flex-col items-center gap-3 py-12">
<span className="text-4xl" style={{ opacity: 0.3 }}>🖼</span>
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
Failed to load image
</p>
</div>
);
}
return (
<div className="flex flex-col items-center gap-4 w-full">
{/* Zoom controls */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={handleZoomOut}
className="p-1.5 rounded-md transition-colors duration-100 cursor-pointer"
style={{ color: "var(--color-text-muted)" }}
title="Zoom out"
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<ZoomOutIcon />
</button>
<button
type="button"
onClick={handleReset}
className="px-2 py-1 rounded-md text-[11px] tabular-nums transition-colors duration-100 cursor-pointer"
style={{ color: "var(--color-text-muted)" }}
title="Reset zoom"
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{Math.round(zoom * 100)}%
</button>
<button
type="button"
onClick={handleZoomIn}
className="p-1.5 rounded-md transition-colors duration-100 cursor-pointer"
style={{ color: "var(--color-text-muted)" }}
title="Zoom in"
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<ZoomInIcon />
</button>
</div>
{/* Image container with checkerboard background for transparency */}
<div
className="overflow-auto max-w-full max-h-[calc(100vh-260px)] rounded-xl border"
style={{
borderColor: "var(--color-border)",
backgroundImage: "linear-gradient(45deg, var(--color-surface-hover) 25%, transparent 25%), linear-gradient(-45deg, var(--color-surface-hover) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--color-surface-hover) 75%), linear-gradient(-45deg, transparent 75%, var(--color-surface-hover) 75%)",
backgroundSize: "20px 20px",
backgroundPosition: "0 0, 0 10px, 10px -10px, -10px 0px",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={filename}
onError={() => setError(true)}
style={{
transform: `scale(${zoom})`,
transformOrigin: "center center",
transition: "transform 200ms ease",
maxWidth: zoom <= 1 ? "100%" : "none",
display: "block",
}}
draggable={false}
/>
</div>
</div>
);
}
// --- Video Viewer ---
function VideoViewer({ url }: { url: string }) {
return (
<div className="w-full max-w-4xl">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
src={url}
controls
className="w-full rounded-xl border"
style={{
borderColor: "var(--color-border)",
maxHeight: "calc(100vh - 220px)",
background: "#000",
}}
/>
</div>
);
}
// --- Audio Viewer ---
function AudioViewer({ url, filename }: { url: string; filename: string }) {
return (
<div className="flex flex-col items-center gap-6 py-8">
{/* Visual representation */}
<div
className="w-32 h-32 rounded-2xl flex items-center justify-center"
style={{
background: "linear-gradient(135deg, #f59e0b20, #f59e0b10)",
border: "1px solid #f59e0b30",
}}
>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</div>
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{filename}
</p>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio src={url} controls className="w-full max-w-md" />
</div>
);
}
// --- PDF Viewer ---
function PdfViewer({ url }: { url: string }) {
return (
<div className="w-full h-full flex flex-col">
<iframe
src={url}
className="w-full flex-1 rounded-xl border"
style={{
borderColor: "var(--color-border)",
minHeight: "calc(100vh - 220px)",
background: "white",
}}
title="PDF viewer"
/>
</div>
);
}
// --- Media type icon ---
function MediaTypeIcon({ mediaType }: { mediaType: MediaType }) {
const color = mediaTypeColor(mediaType);
switch (mediaType) {
case "image":
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} 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>
);
case "video":
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
<rect x="2" y="6" width="14" height="12" rx="2" />
</svg>
);
case "audio":
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
case "pdf":
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} 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" />
<path d="M10 9H8" />
<path d="M16 13H8" />
<path d="M16 17H8" />
</svg>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,122 +1,212 @@
"use client";
import { useEffect, useState } from "react";
import { FileManagerTree, type TreeNode } from "./file-manager-tree";
type WorkspaceSidebarProps = {
tree: TreeNode[];
activePath: string | null;
onSelect: (node: TreeNode) => void;
onRefresh: () => void;
orgName?: string;
loading?: boolean;
tree: TreeNode[];
activePath: string | null;
onSelect: (node: TreeNode) => void;
onRefresh: () => void;
orgName?: string;
loading?: boolean;
};
function WorkspaceLogo() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
);
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
);
}
function HomeIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}
/* ─── Theme toggle ─── */
function ThemeToggle() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
setIsDark(document.documentElement.classList.contains("dark"));
}, []);
const toggle = () => {
const next = !isDark;
setIsDark(next);
if (next) {
document.documentElement.classList.add("dark");
localStorage.setItem("theme", "dark");
} else {
document.documentElement.classList.remove("dark");
localStorage.setItem("theme", "light");
}
};
return (
<button
type="button"
onClick={toggle}
className="p-1.5 rounded-lg"
style={{ color: "var(--color-text-muted)" }}
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{isDark ? (
/* Sun icon */
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
) : (
/* Moon icon */
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
)}
</button>
);
}
export function WorkspaceSidebar({
tree,
activePath,
onSelect,
onRefresh,
orgName,
loading,
tree,
activePath,
onSelect,
onRefresh,
orgName,
loading,
}: WorkspaceSidebarProps) {
return (
<aside
className="flex flex-col h-screen border-r flex-shrink-0"
style={{
width: "260px",
background: "var(--color-surface)",
borderColor: "var(--color-border)",
}}
>
{/* Header */}
<div
className="flex items-center gap-2.5 px-4 py-3 border-b"
style={{ borderColor: "var(--color-border)" }}
>
<span style={{ color: "var(--color-accent)" }}>
<WorkspaceLogo />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
{orgName || "Workspace"}
</div>
<div className="text-xs" style={{ color: "var(--color-text-muted)" }}>
Dench CRM
</div>
</div>
</div>
return (
<aside
className="flex flex-col h-screen border-r flex-shrink-0"
style={{
width: "260px",
background: "var(--color-surface)",
borderColor: "var(--color-border)",
}}
>
{/* Header */}
<div
className="flex items-center gap-2.5 px-4 py-3 border-b"
style={{ borderColor: "var(--color-border)" }}
>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
}}
>
<WorkspaceLogo />
</span>
<div className="flex-1 min-w-0">
<div
className="text-sm font-medium truncate"
style={{ color: "var(--color-text)" }}
>
{orgName || "Workspace"}
</div>
<div
className="text-[11px]"
style={{
color: "var(--color-text-muted)",
}}
>
Dench CRM
</div>
</div>
</div>
{/* Section label */}
<div
className="px-4 pt-4 pb-1 text-[11px] font-medium uppercase tracking-wider"
style={{ color: "var(--color-text-muted)" }}
>
Knowledge
</div>
{/* Tree */}
<div className="flex-1 overflow-y-auto px-1">
{loading ? (
<div className="flex items-center justify-center py-12">
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{
borderColor: "var(--color-border)",
borderTopColor:
"var(--color-accent)",
}}
/>
</div>
) : (
<FileManagerTree
tree={tree}
activePath={activePath}
onSelect={onSelect}
onRefresh={onRefresh}
/>
)}
</div>
{/* Tree (includes real files + virtual Skills, Memories, Chats folders) */}
<div className="flex-1 overflow-y-auto px-1">
{loading ? (
<div className="flex items-center justify-center py-12">
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{
borderColor: "var(--color-border)",
borderTopColor: "var(--color-accent)",
}}
/>
</div>
) : (
<FileManagerTree
tree={tree}
activePath={activePath}
onSelect={onSelect}
onRefresh={onRefresh}
/>
)}
</div>
{/* Footer */}
<div
className="px-3 py-2.5 border-t"
style={{ borderColor: "var(--color-border)" }}
>
<a
href="/"
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors"
style={{ color: "var(--color-text-muted)" }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background =
"var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<HomeIcon />
Home
</a>
</div>
</aside>
);
{/* Footer */}
<div
className="px-3 py-2.5 border-t flex items-center justify-between"
style={{ borderColor: "var(--color-border)" }}
>
<a
href="/"
className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm"
style={{ color: "var(--color-text-muted)" }}
>
<HomeIcon />
Home
</a>
<ThemeToggle />
</div>
</aside>
);
}

View File

@ -1,16 +1,155 @@
@import "tailwindcss";
/* ============================================================
Theme System Light (default) & Dark (.dark)
============================================================ */
:root {
--color-bg: #0a0a0a;
--color-surface: #141414;
--color-surface-hover: #1a1a1a;
--color-border: #262626;
--color-text: #ededed;
--color-text-muted: #888;
--color-accent: #e85d3a;
--color-accent-hover: #f06a47;
/* Background / Surface */
--color-bg: #f5f5f0;
--color-surface: #ffffff;
--color-surface-hover: #f0efeb;
--color-surface-raised: #ffffff;
/* Borders */
--color-border: rgba(0, 0, 0, 0.08);
--color-border-strong: rgba(0, 0, 0, 0.14);
/* Text */
--color-text: #1c1c1a;
--color-text-secondary: #44443e;
--color-text-muted: #8a8a82;
/* Accent (blue) */
--color-accent: #2563eb;
--color-accent-hover: #1d4ed8;
--color-accent-light: rgba(37, 99, 235, 0.08);
/* Chat */
--color-user-bubble: #e9e5dd;
--color-user-bubble-text: #1c1c1a;
--color-chat-input-bg: #eeeee8;
/* Semantic */
--color-success: #16a34a;
--color-warning: #d97706;
--color-error: #dc2626;
--color-info: #2563eb;
/* Glassmorphism */
--color-glass: rgba(255, 255, 255, 0.72);
--color-glass-border: rgba(255, 255, 255, 0.85);
/* Object type chips */
--color-chip-object: rgba(37, 99, 235, 0.08);
--color-chip-object-text: #2563eb;
--color-chip-document: rgba(96, 165, 250, 0.08);
--color-chip-document-text: #3b82f6;
--color-chip-database: rgba(147, 51, 234, 0.08);
--color-chip-database-text: #9333ea;
--color-chip-report: rgba(22, 163, 74, 0.08);
--color-chip-report-text: #16a34a;
/* Shadow */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.10);
}
.dark {
/* Background / Surface */
--color-bg: #0c0c0b;
--color-surface: #161615;
--color-surface-hover: #1e1e1c;
--color-surface-raised: #1a1a18;
/* Borders */
--color-border: rgba(255, 255, 255, 0.08);
--color-border-strong: rgba(255, 255, 255, 0.14);
/* Text */
--color-text: #ececea;
--color-text-secondary: #b8b8b0;
--color-text-muted: #78776f;
/* Accent (blue, brighter for dark) */
--color-accent: #3b82f6;
--color-accent-hover: #60a5fa;
--color-accent-light: rgba(59, 130, 246, 0.12);
/* Chat */
--color-user-bubble: #1e1e1c;
--color-user-bubble-text: #ececea;
--color-chat-input-bg: #1e1e1c;
/* Semantic */
--color-success: #22c55e;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-info: #3b82f6;
/* Glassmorphism */
--color-glass: rgba(22, 22, 21, 0.72);
--color-glass-border: rgba(255, 255, 255, 0.06);
/* Object type chips */
--color-chip-object: rgba(59, 130, 246, 0.12);
--color-chip-object-text: #60a5fa;
--color-chip-document: rgba(96, 165, 250, 0.12);
--color-chip-document-text: #93c5fd;
--color-chip-database: rgba(147, 51, 234, 0.12);
--color-chip-database-text: #c084fc;
--color-chip-report: rgba(34, 197, 94, 0.12);
--color-chip-report-text: #4ade80;
/* Shadow */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.20);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.30);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.40);
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.50);
}
/* ============================================================
Fonts Bookerly (local)
============================================================ */
@font-face {
font-family: "Bookerly";
src: url("/fonts/Bookerly-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Bookerly";
src: url("/fonts/Bookerly-RegularItalic.ttf") format("truetype");
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Bookerly";
src: url("/fonts/Bookerly-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Bookerly";
src: url("/fonts/Bookerly-BoldItalic.ttf") format("truetype");
font-weight: 700;
font-style: italic;
font-display: swap;
}
/* ============================================================
Base
============================================================ */
body {
background: var(--color-bg);
color: var(--color-text);
@ -21,11 +160,45 @@ body {
"Segoe UI",
Roboto,
sans-serif;
transition: background-color 0.2s ease, color 0.2s ease;
}
/* Scrollbar styling */
/* Font utilities */
.font-instrument {
font-family: "Instrument Serif", serif;
}
.font-bookerly {
font-family: "Bookerly", Georgia, "Times New Roman", serif;
}
/* Smooth theme transitions */
*,
*::before,
*::after {
transition-property: background-color, border-color;
transition-duration: 0.15s;
transition-timing-function: ease;
}
/* Override to prevent layout thrashing with transitions */
input,
textarea,
button,
a,
[role="button"] {
transition-property: background-color, border-color, color, box-shadow, opacity;
transition-duration: 0.15s;
transition-timing-function: ease;
}
/* ============================================================
Scrollbar
============================================================ */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@ -33,7 +206,7 @@ body {
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
background: var(--color-border-strong);
border-radius: 3px;
}
@ -41,9 +214,9 @@ body {
background: var(--color-text-muted);
}
/* ========================================
/* ============================================================
Workspace Prose (markdown document view)
======================================== */
============================================================ */
.workspace-prose {
color: var(--color-text);
@ -65,13 +238,17 @@ body {
}
.workspace-prose h1 {
font-size: 1.75rem;
font-family: "Instrument Serif", serif;
font-size: 2rem;
font-weight: 400;
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.5rem;
}
.workspace-prose h2 {
font-size: 1.375rem;
font-family: "Instrument Serif", serif;
font-size: 1.5rem;
font-weight: 400;
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.4rem;
}
@ -89,13 +266,13 @@ body {
}
.workspace-prose a {
color: #60a5fa;
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.workspace-prose a:hover {
color: #93bbfd;
color: var(--color-accent-hover);
}
.workspace-prose strong {
@ -136,23 +313,23 @@ body {
padding: 0.5em 1em;
margin: 1em 0;
background: var(--color-surface);
border-radius: 0 0.5rem 0.5rem 0;
border-radius: 0 0.75rem 0.75rem 0;
color: var(--color-text-muted);
}
.workspace-prose code {
font-family: "SF Mono", "Fira Code", "JetBrains Mono", monospace;
font-size: 0.85em;
background: var(--color-surface);
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
border-radius: 0.25rem;
border-radius: 0.375rem;
padding: 0.15em 0.35em;
}
.workspace-prose pre {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
border-radius: 0.75rem;
padding: 1em;
overflow-x: auto;
margin: 1em 0;
@ -201,7 +378,7 @@ body {
.workspace-prose img {
max-width: 100%;
border-radius: 0.5rem;
border-radius: 0.75rem;
margin: 1em 0;
}
@ -210,11 +387,12 @@ body {
appearance: none;
width: 1em;
height: 1em;
border: 1.5px solid var(--color-border);
border-radius: 0.2em;
border: 1.5px solid var(--color-border-strong);
border-radius: 0.25em;
vertical-align: middle;
margin-right: 0.4em;
position: relative;
cursor: pointer;
}
.workspace-prose input[type="checkbox"]:checked {
@ -234,18 +412,16 @@ body {
transform: rotate(45deg);
}
/* ========================================
/* ============================================================
Tiptap Markdown Editor
======================================== */
============================================================ */
/* Editor container layout */
.markdown-editor-container {
display: flex;
flex-direction: column;
min-height: 0;
}
/* Tiptap contenteditable area -- inherits workspace-prose via parent */
.editor-content-area {
flex: 1;
padding: 1rem 1.5rem 2rem;
@ -261,7 +437,6 @@ body {
outline: none;
}
/* Placeholder */
.editor-content-area .tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
@ -271,7 +446,7 @@ body {
height: 0;
}
/* Tiptap task list (editable checkboxes) */
/* Tiptap task list */
.editor-content-area .tiptap ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
@ -293,8 +468,8 @@ body {
appearance: none;
width: 1em;
height: 1em;
border: 1.5px solid var(--color-border);
border-radius: 0.2em;
border: 1.5px solid var(--color-border-strong);
border-radius: 0.25em;
cursor: pointer;
position: relative;
}
@ -324,7 +499,7 @@ body {
.editor-content-area .tiptap .editor-image {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
border-radius: 0.75rem;
margin: 1em 0;
cursor: default;
}
@ -332,7 +507,7 @@ body {
.editor-content-area .tiptap .editor-image.ProseMirror-selectednode {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: 0.5rem;
border-radius: 0.75rem;
}
/* Table editing */
@ -350,12 +525,12 @@ body {
}
.editor-content-area .tiptap th {
background: var(--color-surface);
background: var(--color-surface-hover);
font-weight: 600;
}
.editor-content-area .tiptap .selectedCell {
background: rgba(232, 93, 58, 0.08);
background: var(--color-accent-light);
}
/* --- Toolbar --- */
@ -366,7 +541,7 @@ body {
gap: 2px;
padding: 0.375rem 1.5rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
background: var(--color-surface);
flex-shrink: 0;
flex-wrap: wrap;
}
@ -390,14 +565,13 @@ body {
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.25rem;
border-radius: 0.375rem;
border: none;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.75rem;
font-weight: 500;
transition: all 0.1s;
}
.editor-toolbar-btn:hover {
@ -406,12 +580,13 @@ body {
}
.editor-toolbar-btn-active {
background: rgba(232, 93, 58, 0.12);
background: var(--color-accent-light);
color: var(--color-accent);
}
.editor-toolbar-btn-active:hover {
background: rgba(232, 93, 58, 0.18);
background: var(--color-accent-light);
color: var(--color-accent-hover);
}
/* --- Bubble menu --- */
@ -423,8 +598,8 @@ body {
padding: 0.25rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
border-radius: 0.75rem;
box-shadow: var(--shadow-lg);
}
.bubble-menu-btn {
@ -433,13 +608,12 @@ body {
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.25rem;
border-radius: 0.375rem;
border: none;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.8rem;
transition: all 0.1s;
}
.bubble-menu-btn:hover {
@ -449,7 +623,7 @@ body {
.bubble-menu-btn-active {
color: var(--color-accent);
background: rgba(232, 93, 58, 0.12);
background: var(--color-accent-light);
}
/* --- Sticky top bar (save + read toggle) --- */
@ -460,7 +634,7 @@ body {
justify-content: space-between;
padding: 0.5rem 1.5rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
background: var(--color-surface);
flex-shrink: 0;
position: sticky;
top: 0;
@ -484,15 +658,15 @@ body {
}
.editor-save-unsaved {
color: #f59e0b;
color: var(--color-warning);
}
.editor-save-saved {
color: #22c55e;
color: var(--color-success);
}
.editor-save-error {
color: #f87171;
color: var(--color-error);
}
.editor-save-hint {
@ -500,8 +674,8 @@ body {
color: var(--color-text-muted);
opacity: 0.6;
padding: 0.15rem 0.4rem;
background: var(--color-surface);
border-radius: 0.25rem;
background: var(--color-surface-hover);
border-radius: 0.375rem;
border: 1px solid var(--color-border);
}
@ -509,12 +683,11 @@ body {
padding: 0.35rem 1rem;
font-size: 0.8rem;
font-weight: 500;
border-radius: 0.375rem;
border-radius: 0.5rem;
border: none;
background: var(--color-accent);
color: white;
cursor: pointer;
transition: all 0.15s;
}
.editor-save-button:hover:not(:disabled) {
@ -535,23 +708,22 @@ body {
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 0.375rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.15s;
}
.editor-mode-toggle:hover {
background: var(--color-surface-hover);
color: var(--color-text);
border-color: var(--color-text-muted);
border-color: var(--color-border-strong);
}
/* ========================================
/* ============================================================
Slash Command Popup
======================================== */
============================================================ */
.slash-cmd-popup {
max-height: 320px;
@ -560,8 +732,8 @@ body {
max-width: 320px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
border-radius: 0.75rem;
box-shadow: var(--shadow-xl);
padding: 0.25rem;
}
@ -580,10 +752,9 @@ body {
padding: 0.5rem 0.625rem;
border: none;
background: transparent;
border-radius: 0.375rem;
border-radius: 0.5rem;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.slash-cmd-item:hover,
@ -597,7 +768,7 @@ body {
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
border-radius: 0.375rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
@ -643,20 +814,230 @@ body {
font-weight: 500;
text-transform: capitalize;
border-radius: 999px;
background: rgba(232, 93, 58, 0.12);
background: var(--color-accent-light);
color: var(--color-accent);
vertical-align: middle;
line-height: 1.4;
}
/* ========================================
/* ============================================================
Chat Prose (markdown in chat messages)
============================================================ */
.chat-prose {
color: var(--color-text);
line-height: 1.8;
}
.chat-prose>*:first-child {
margin-top: 0;
}
.chat-prose>*:last-child {
margin-bottom: 0;
}
.chat-prose h1,
.chat-prose h2,
.chat-prose h3,
.chat-prose h4,
.chat-prose h5,
.chat-prose h6 {
color: var(--color-text);
font-weight: 600;
margin-top: 1.4em;
margin-bottom: 0.5em;
line-height: 1.3;
}
.chat-prose h1 {
font-family: "Instrument Serif", serif;
font-size: 1.6em;
font-weight: 400;
}
.chat-prose h2 {
font-family: "Instrument Serif", serif;
font-size: 1.35em;
font-weight: 400;
}
.chat-prose h3 {
font-size: 1.15em;
}
.chat-prose h4 {
font-size: 1em;
}
.chat-prose p {
margin-bottom: 0.75em;
}
.chat-prose a {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-word;
}
.chat-prose a:hover {
color: var(--color-accent-hover);
}
.chat-prose strong {
color: var(--color-text);
font-weight: 700;
}
.chat-prose em {
font-style: italic;
}
.chat-prose ul,
.chat-prose ol {
margin-bottom: 0.75em;
padding-left: 1.5em;
}
.chat-prose ul {
list-style-type: disc;
}
.chat-prose ol {
list-style-type: decimal;
}
.chat-prose li {
margin-bottom: 0.2em;
}
.chat-prose li>p {
margin-bottom: 0.25em;
}
.chat-prose li>ul,
.chat-prose li>ol {
margin-top: 0.2em;
margin-bottom: 0;
}
.chat-prose blockquote {
border-left: 3px solid var(--color-accent);
padding: 0.4em 0.8em;
margin: 0.75em 0;
background: var(--color-surface);
border-radius: 0 0.75rem 0.75rem 0;
color: var(--color-text-secondary);
}
.chat-prose blockquote p:last-child {
margin-bottom: 0;
}
.chat-prose code {
font-family: "SF Mono", "Fira Code", "JetBrains Mono", monospace;
font-size: 0.85em;
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
border-radius: 0.375rem;
padding: 0.15em 0.35em;
}
.chat-prose pre {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 0.875em 1em;
overflow-x: auto;
margin: 0.75em 0;
}
.chat-prose pre code {
background: transparent;
border: none;
padding: 0;
font-size: 0.82em;
line-height: 1.6;
}
.chat-prose hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 1.5em 0;
}
.chat-prose table {
width: 100%;
border-collapse: collapse;
margin: 0.75em 0;
font-size: 0.9em;
}
.chat-prose th {
text-align: left;
font-weight: 600;
padding: 0.5em 0.65em;
border-bottom: 2px solid var(--color-border);
color: var(--color-text-muted);
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.chat-prose td {
padding: 0.4em 0.65em;
border-bottom: 1px solid var(--color-border);
}
.chat-prose tr:hover td {
background: var(--color-surface-hover);
}
.chat-prose img {
max-width: 100%;
border-radius: 0.75rem;
margin: 0.75em 0;
}
/* Task list checkboxes (GFM) */
.chat-prose input[type="checkbox"] {
appearance: none;
width: 1em;
height: 1em;
border: 1.5px solid var(--color-border-strong);
border-radius: 0.25em;
vertical-align: middle;
margin-right: 0.4em;
position: relative;
cursor: pointer;
}
.chat-prose input[type="checkbox"]:checked {
background: var(--color-accent);
border-color: var(--color-accent);
}
.chat-prose input[type="checkbox"]:checked::after {
content: "";
position: absolute;
left: 3px;
top: 1px;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
/* ============================================================
Report Block (in-editor)
======================================== */
============================================================ */
.report-block-wrapper {
position: relative;
margin: 1em 0;
border-radius: 0.75rem;
border-radius: 1rem;
border: 1px solid var(--color-border);
overflow: hidden;
}
@ -682,12 +1063,11 @@ body {
padding: 0.2rem 0.5rem;
font-size: 0.7rem;
font-weight: 500;
border-radius: 0.25rem;
border-radius: 0.375rem;
border: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.1s;
}
.report-block-btn:hover {
@ -696,9 +1076,9 @@ body {
}
.report-block-btn-danger:hover {
background: rgba(248, 113, 113, 0.1);
color: #f87171;
border-color: rgba(248, 113, 113, 0.3);
background: rgba(220, 38, 38, 0.08);
color: var(--color-error);
border-color: rgba(220, 38, 38, 0.25);
}
.report-block-source {
@ -737,7 +1117,7 @@ body {
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: rgba(248, 113, 113, 0.05);
color: #f87171;
background: rgba(220, 38, 38, 0.04);
color: var(--color-error);
font-size: 0.8rem;
}

View File

@ -2,8 +2,9 @@ import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Ironclaw",
description: "AI CRM with an agent that connects to your apps and does the work for you",
title: "Dench",
description:
"AI CRM with an agent that connects to your apps and does the work for you",
};
export default function RootLayout({
@ -12,7 +13,25 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark">
<html lang="en" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
{/* Inline script to prevent FOUC — reads localStorage or system preference */}
<script
dangerouslySetInnerHTML={{
__html: `try{if(localStorage.theme==="dark"||(!("theme" in localStorage)&&window.matchMedia("(prefers-color-scheme: dark)").matches)){document.documentElement.classList.add("dark")}else{document.documentElement.classList.remove("dark")}}catch(e){}`,
}}
/>
</head>
<body className="antialiased">{children}</body>
</html>
);

View File

@ -10,12 +10,15 @@ export default function Home() {
>
{/* Logo / brand mark */}
<div
className="mb-6 w-16 h-16 rounded-2xl flex items-center justify-center"
style={{ background: "rgba(232, 93, 58, 0.12)" }}
className="mb-8 w-16 h-16 rounded-2xl flex items-center justify-center"
style={{
background: "var(--color-accent-light)",
boxShadow: "var(--shadow-md)",
}}
>
<svg
width="32"
height="32"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@ -33,35 +36,29 @@ export default function Home() {
{/* Heading */}
<h1
className="text-4xl font-bold tracking-tight mb-3 text-center"
className="font-instrument text-5xl md:text-6xl tracking-tight mb-3 text-center"
style={{ color: "var(--color-text)" }}
>
Ironclaw
Dench
</h1>
{/* Tagline */}
<p
className="text-lg mb-8 text-center max-w-md"
className="text-base mb-10 text-center max-w-md leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
Your AI workspace &mdash; chat, knowledge, skills, and memory in one place.
Your AI workspace &mdash; chat, knowledge, skills, and memory in one
place.
</p>
{/* CTA */}
<Link
href="/workspace"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg text-sm font-medium transition-colors"
className="inline-flex items-center gap-2.5 px-7 py-3.5 rounded-full text-sm font-medium transition-all hover:scale-[1.02] active:scale-[0.98]"
style={{
background: "var(--color-accent)",
color: "#fff",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background =
"var(--color-accent-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background =
"var(--color-accent)";
boxShadow: "var(--shadow-md)",
}}
>
Open Workspace
@ -80,9 +77,9 @@ export default function Home() {
</svg>
</Link>
{/* Subtle footer link */}
{/* Subtle footer */}
<p
className="mt-12 text-xs"
className="mt-16 text-xs"
style={{ color: "var(--color-text-muted)", opacity: 0.5 }}
>
Powered by OpenClaw

View File

@ -9,6 +9,7 @@ import { ObjectTable } from "../components/workspace/object-table";
import { ObjectKanban } from "../components/workspace/object-kanban";
import { DocumentView } from "../components/workspace/document-view";
import { FileViewer } from "../components/workspace/file-viewer";
import { MediaViewer, detectMediaType, type MediaType } from "../components/workspace/media-viewer";
import { DatabaseViewer } from "../components/workspace/database-viewer";
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
import { EmptyState } from "../components/workspace/empty-state";
@ -78,6 +79,7 @@ type ContentState =
| { kind: "object"; data: ObjectData }
| { kind: "document"; data: FileData; title: string }
| { kind: "file"; data: FileData; filename: string }
| { kind: "media"; url: string; mediaType: MediaType; filename: string; filePath: string }
| { kind: "database"; dbPath: string; filename: string }
| { kind: "report"; reportPath: string; filename: string }
| { kind: "directory"; node: TreeNode };
@ -301,6 +303,14 @@ function WorkspacePageInner() {
} else if (node.type === "report") {
setContent({ kind: "report", reportPath: node.path, filename: node.name });
} else if (node.type === "file") {
// Check if this is a media file (image/video/audio/pdf)
const mediaType = detectMediaType(node.name);
if (mediaType) {
const rawUrl = `/api/workspace/raw-file?path=${encodeURIComponent(node.path)}`;
setContent({ kind: "media", url: rawUrl, mediaType, filename: node.name, filePath: node.path });
return;
}
const res = await fetch(fileApiUrl(node.path));
if (!res.ok) {
setContent({ kind: "none" });
@ -556,7 +566,7 @@ function WorkspacePageInner() {
setContent({ kind: "none" });
router.replace("/workspace", { scroll: false });
}}
className="p-1.5 rounded-md transition-colors flex-shrink-0"
className="p-1.5 rounded-lg flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Back to chat"
>
@ -568,10 +578,10 @@ function WorkspacePageInner() {
<button
type="button"
onClick={() => setShowChatSidebar((v) => !v)}
className="p-1.5 rounded-md transition-colors flex-shrink-0"
className="p-1.5 rounded-lg flex-shrink-0"
style={{
color: showChatSidebar ? "var(--color-accent)" : "var(--color-text-muted)",
background: showChatSidebar ? "rgba(232, 93, 58, 0.1)" : "transparent",
background: showChatSidebar ? "var(--color-accent-light)" : "transparent",
}}
title={showChatSidebar ? "Hide chat" : "Chat about this file"}
>
@ -650,6 +660,7 @@ function WorkspacePageInner() {
handleCloseEntry();
handleNavigateToObject(objName);
}}
onRefresh={refreshCurrentObject}
/>
)}
</div>
@ -732,6 +743,16 @@ function ContentRenderer({
/>
);
case "media":
return (
<MediaViewer
url={content.url}
filename={content.filename}
mediaType={content.mediaType}
filePath={content.filePath}
/>
);
case "database":
return (
<DatabaseViewer
@ -817,7 +838,7 @@ function ObjectView({
{/* Object header */}
<div className="mb-6">
<h1
className="text-2xl font-bold capitalize"
className="font-instrument text-3xl tracking-tight capitalize"
style={{ color: "var(--color-text)" }}
>
{data.object.name}
@ -856,9 +877,9 @@ function ObjectView({
<span
className="text-xs px-2 py-1 rounded-full"
style={{
background: "rgba(96, 165, 250, 0.08)",
color: "#60a5fa",
border: "1px solid rgba(96, 165, 250, 0.2)",
background: "var(--color-chip-document)",
color: "var(--color-chip-document-text)",
border: "1px solid var(--color-border)",
}}
>
{data.fields.filter((f) => f.type === "relation").length} relation{data.fields.filter((f) => f.type === "relation").length !== 1 ? "s" : ""}
@ -868,9 +889,9 @@ function ObjectView({
<span
className="text-xs px-2 py-1 rounded-full"
style={{
background: "rgba(192, 132, 252, 0.08)",
color: "#c084fc",
border: "1px solid rgba(192, 132, 252, 0.2)",
background: "var(--color-chip-database)",
color: "var(--color-chip-database-text)",
border: "1px solid var(--color-border)",
}}
>
{data.reverseRelations!.filter((rr) => Object.keys(rr.entries).length > 0).length} linked from
@ -940,6 +961,7 @@ function ObjectView({
reverseRelations={data.reverseRelations}
onNavigateToObject={onNavigateToObject}
onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined}
onRefresh={onRefreshObject}
/>
)}
</div>
@ -960,7 +982,7 @@ function DirectoryListing({
return (
<div className="p-6 max-w-4xl mx-auto">
<h1
className="text-2xl font-bold mb-1 capitalize"
className="font-instrument text-3xl tracking-tight mb-1 capitalize"
style={{ color: "var(--color-text)" }}
>
{node.name}
@ -980,14 +1002,15 @@ function DirectoryListing({
type="button"
key={child.path}
onClick={() => onNodeSelect(child)}
className="flex items-center gap-3 p-4 rounded-xl text-left transition-all duration-100 cursor-pointer"
className="flex items-center gap-3 p-4 rounded-2xl text-left transition-all duration-100 cursor-pointer"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
boxShadow: "var(--shadow-sm)",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.borderColor =
"var(--color-text-muted)";
"var(--color-border-strong)";
(e.currentTarget as HTMLElement).style.transform = "translateY(-1px)";
}}
onMouseLeave={(e) => {
@ -997,27 +1020,27 @@ function DirectoryListing({
}}
>
<span
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
style={{
background:
child.type === "object"
? "rgba(232, 93, 58, 0.1)"
? "var(--color-chip-object)"
: child.type === "document"
? "rgba(96, 165, 250, 0.1)"
? "var(--color-chip-document)"
: child.type === "database"
? "rgba(192, 132, 252, 0.1)"
? "var(--color-chip-database)"
: child.type === "report"
? "rgba(34, 197, 94, 0.1)"
? "var(--color-chip-report)"
: "var(--color-surface-hover)",
color:
child.type === "object"
? "var(--color-accent)"
? "var(--color-chip-object-text)"
: child.type === "document"
? "#60a5fa"
? "var(--color-chip-document-text)"
: child.type === "database"
? "#c084fc"
? "var(--color-chip-database-text)"
: child.type === "report"
? "#22c55e"
? "var(--color-chip-report-text)"
: "var(--color-text-muted)",
}}
>
@ -1030,7 +1053,10 @@ function DirectoryListing({
>
{child.name.replace(/\.md$/, "")}
</div>
<div className="text-xs capitalize" style={{ color: "var(--color-text-muted)" }}>
<div
className="text-xs capitalize"
style={{ color: "var(--color-text-muted)" }}
>
{child.type}
{child.children ? ` (${child.children.length})` : ""}
</div>
@ -1067,7 +1093,7 @@ function WelcomeView({
return (
<div className="p-8 max-w-4xl mx-auto">
<h1
className="text-2xl font-bold mb-2"
className="font-instrument text-3xl tracking-tight mb-2"
style={{ color: "var(--color-text)" }}
>
Workspace
@ -1090,10 +1116,11 @@ function WelcomeView({
type="button"
key={obj.path}
onClick={() => onNodeSelect(obj)}
className="flex items-center gap-3 p-4 rounded-xl text-left transition-all duration-100 cursor-pointer"
className="flex items-center gap-3 p-4 rounded-2xl text-left transition-all duration-100 cursor-pointer"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
boxShadow: "var(--shadow-sm)",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.borderColor =
@ -1105,10 +1132,10 @@ function WelcomeView({
}}
>
<span
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
style={{
background: "rgba(232, 93, 58, 0.1)",
color: "var(--color-accent)",
background: "var(--color-chip-object)",
color: "var(--color-chip-object-text)",
}}
>
<NodeTypeIcon type="object" />
@ -1144,13 +1171,14 @@ function WelcomeView({
type="button"
key={doc.path}
onClick={() => onNodeSelect(doc)}
className="flex items-center gap-3 p-4 rounded-xl text-left transition-all duration-100 cursor-pointer"
className="flex items-center gap-3 p-4 rounded-2xl text-left transition-all duration-100 cursor-pointer"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
boxShadow: "var(--shadow-sm)",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.borderColor = "#60a5fa";
(e.currentTarget as HTMLElement).style.borderColor = "var(--color-chip-document-text)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.borderColor =
@ -1158,10 +1186,10 @@ function WelcomeView({
}}
>
<span
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
style={{
background: "rgba(96, 165, 250, 0.1)",
color: "#60a5fa",
background: "var(--color-chip-document)",
color: "var(--color-chip-document-text)",
}}
>
<NodeTypeIcon type="document" />

View File

@ -37,7 +37,18 @@ export type AgentCallback = {
isError: boolean,
result?: ToolResult,
) => void;
/** Called when the agent run is picked up and starts executing. */
onLifecycleStart?: () => void;
onLifecycleEnd: () => void;
/** Called when session auto-compaction begins. */
onCompactionStart?: () => void;
/** Called when session auto-compaction finishes. */
onCompactionEnd?: (willRetry: boolean) => void;
/** Called when a running tool emits a progress update. */
onToolUpdate?: (
toolCallId: string,
toolName: string,
) => void;
onError: (error: Error) => void;
onClose: (code: number | null) => void;
/** Called when the agent encounters an API or runtime error (402, rate limit, etc.) */
@ -116,10 +127,11 @@ export async function runAgent(
"--message",
message,
"--stream-json",
// Run embedded (--local) so we get ALL events (tool, thinking,
// lifecycle) unfiltered. The gateway path drops tool events
// unless verbose is explicitly "on".
"--local",
// Route through the gateway daemon (not --local) so all concurrent
// agent runs share the gateway's lane-based concurrency system.
// The gateway serialises writes per session-key and avoids the
// cross-process file-lock contention that --local causes when
// multiple chat threads run in parallel child processes.
];
// Isolated session for file-scoped subagent chats.
@ -180,6 +192,15 @@ export async function runAgent(
if (delta) {
callback.onTextDelta(delta);
}
// Forward media URLs (images, files generated by the agent)
const mediaUrls = event.data?.mediaUrls;
if (Array.isArray(mediaUrls)) {
for (const url of mediaUrls) {
if (typeof url === "string" && url.trim()) {
callback.onTextDelta(`\n![media](${url.trim()})\n`);
}
}
}
}
// Handle thinking/reasoning deltas
@ -215,6 +236,8 @@ export async function runAgent(
? (event.data.args as Record<string, unknown>)
: undefined;
callback.onToolStart(toolCallId, toolName, args);
} else if (phase === "update") {
callback.onToolUpdate?.(toolCallId, toolName);
} else if (phase === "result") {
const isError = event.data?.isError === true;
const result = extractToolResult(event.data?.result);
@ -222,6 +245,15 @@ export async function runAgent(
}
}
// Handle lifecycle start
if (
event.event === "agent" &&
event.stream === "lifecycle" &&
event.data?.phase === "start"
) {
callback.onLifecycleStart?.();
}
// Handle lifecycle end
if (
event.event === "agent" &&
@ -231,6 +263,20 @@ export async function runAgent(
callback.onLifecycleEnd();
}
// Handle session compaction events
if (event.event === "agent" && event.stream === "compaction") {
const phase =
typeof event.data?.phase === "string"
? event.data.phase
: undefined;
if (phase === "start") {
callback.onCompactionStart?.();
} else if (phase === "end") {
const willRetry = event.data?.willRetry === true;
callback.onCompactionEnd?.(willRetry);
}
}
// ── Surface agent-level errors (API 402, rate limits, etc.) ──
// Lifecycle error phase
@ -266,7 +312,7 @@ export async function runAgent(
if (!agentErrorReported) {
agentErrorReported = true;
callback.onAgentError?.(
parseErrorBody(event.data.errorMessage as string),
parseErrorBody(event.data.errorMessage),
);
}
}

View File

@ -14,6 +14,8 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-table": "^8.21.3",
"@tiptap/core": "^3.19.0",
"@tiptap/extension-image": "^3.19.0",
"@tiptap/extension-link": "^3.19.0",

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{
"name": "ironclaw",
"version": "2026.2.10",
"version": "2026.2.10-1",
"description": "AI-powered CRM platform with multi-channel agent gateway, DuckDB workspace, and knowledge management",
"keywords": [],
"license": "MIT",

35
pnpm-lock.yaml generated
View File

@ -309,6 +309,12 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.1.0)
'@tanstack/match-sorter-utils':
specifier: ^8.19.4
version: 8.19.4
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.1.0)(react@19.1.0)
'@tiptap/core':
specifier: ^3.19.0
version: 3.19.0(@tiptap/pm@3.19.0)
@ -2655,7 +2661,6 @@ packages:
/@lancedb/lancedb@0.26.2(apache-arrow@18.1.0):
resolution: {integrity: sha512-umk4WMCTwJntLquwvUbpqE+TXREolcQVL9MHcxr8EhRjsha88+ATJ4QuS/hpyiE1CG3R/XcgrMgJAGkziPC/gA==}
engines: {node: '>= 18'}
cpu: [x64, arm64]
os: [darwin, linux, win32]
peerDependencies:
apache-arrow: '>=15.0.0 <=18.1.0'
@ -5600,6 +5605,30 @@ packages:
tailwindcss: 4.1.8
dev: true
/@tanstack/match-sorter-utils@8.19.4:
resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
engines: {node: '>=12'}
dependencies:
remove-accents: 0.5.0
dev: false
/@tanstack/react-table@8.21.3(react-dom@19.1.0)(react@19.1.0):
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
dependencies:
'@tanstack/table-core': 8.21.3
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
dev: false
/@tanstack/table-core@8.21.3:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
dev: false
/@tinyhttp/content-disposition@2.2.3:
resolution: {integrity: sha512-0nSvOgFHvq0a15+pZAdbAyHUk0+AGLX6oyo45b7fPdgWdPfHA19IfgUKRECYT0aw86ZP6ZDDLxGQ7FEA1fAVOg==}
engines: {node: '>=12.17.0'}
@ -11166,6 +11195,10 @@ packages:
unified: 11.0.5
dev: false
/remove-accents@0.5.0:
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
dev: false
/request-promise-core@1.1.4(request@2.88.2):
resolution: {integrity: sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==}
engines: {node: '>=0.10.0'}

View File

@ -246,6 +246,9 @@ async function agentViaGatewayStreamJson(opts: AgentCliOpts, _runtime: RuntimeEn
timeoutMs: gatewayTimeoutMs,
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
// Request tool-events capability so the gateway streams tool start/result
// events alongside assistant text, thinking, and lifecycle events.
caps: ["tool-events"],
onEvent: (evt) => {
// Emit each gateway event as an NDJSON line (chat deltas, agent tool/lifecycle events).
emitNdjsonLine({ event: evt.event, ...(evt.payload as Record<string, unknown>) });

View File

@ -44,6 +44,8 @@ export type CallGatewayOptions = {
configPath?: string;
/** Optional callback for gateway events received while the request is in flight. */
onEvent?: (evt: { event: string; payload?: unknown; seq?: number }) => void;
/** Client capabilities to advertise during the WebSocket handshake (e.g. "tool-events"). */
caps?: string[];
};
export type GatewayConnectionDetails = {
@ -272,6 +274,7 @@ export async function callGateway<T = Record<string, unknown>>(
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,
role: "operator",
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
caps: opts.caps,
deviceIdentity: loadOrCreateDeviceIdentity(),
minProtocol: opts.minProtocol ?? PROTOCOL_VERSION,
maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION,