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:
parent
18fab85ae7
commit
8341c6048c
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
95
apps/web/app/api/workspace/objects/[name]/entries/route.ts
Normal file
95
apps/web/app/api/workspace/objects/[name]/entries/route.ts
Normal 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 });
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
122
apps/web/app/api/workspace/raw-file/route.ts
Normal file
122
apps/web/app/api/workspace/raw-file/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
806
apps/web/app/components/workspace/data-table.tsx
Normal file
806
apps/web/app/components/workspace/data-table.tsx
Normal 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="«" />
|
||||
<PaginationButton onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} label="‹" />
|
||||
<PaginationButton onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} label="›" />
|
||||
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: table.getPageCount() - 1 }))} disabled={!table.getCanNextPage()} label="»" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -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)"}`,
|
||||
}}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)",
|
||||
}}
|
||||
>
|
||||
|
||||
387
apps/web/app/components/workspace/media-viewer.tsx
Normal file
387
apps/web/app/components/workspace/media-viewer.tsx
Normal 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
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 — chat, knowledge, skills, and memory in one place.
|
||||
Your AI workspace — 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
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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})\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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
@ -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
35
pnpm-lock.yaml
generated
@ -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'}
|
||||
|
||||
@ -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>) });
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user