diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index 80a35543144..a1a142d9f43 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -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(); diff --git a/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts b/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts index 2fd0ce7f7fa..83c6c75eb80 100644 --- a/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts +++ b/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts @@ -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( - `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( + `SELECT * FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`, + ); + if (objects.length === 0) { + return Response.json( + { error: `Object '${name}' not found` }, + { status: 404 }, + ); + } + const obj = objects[0]; - // Fetch fields - const fields = duckdbQuery( - `SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`, - ); + // Fetch fields + const fields = duckdbQuery( + `SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`, + ); - // Fetch entry field values - const entryRows = duckdbQuery<{ - entry_id: string; - created_at: string; - updated_at: string; - field_name: string; - value: string | null; - }>( - `SELECT e.id as entry_id, e.created_at, e.updated_at, + // 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 = { 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 = { 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> = {}; - const relatedObjectNames: Record = {}; + // Resolve relation labels for this entry + const relationLabels: Record> = + {}; + const relatedObjectNames: Record = {}; - 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( - `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( + `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( - `SELECT * FROM fields WHERE object_id = '${sqlEscape(relObj.id)}' ORDER BY sort_order`, - ); - const displayFieldName = resolveDisplayField(relObj, relFields); + const relFields = duckdbQuery( + `SELECT * FROM fields WHERE object_id = '${sqlEscape(relObj.id)}' ORDER BY sort_order`, + ); + const displayFieldName = resolveDisplayField( + relObj, + relFields, + ); - const idList = ids.map((i) => `'${sqlEscape(i)}'`).join(","); - const displayRows = duckdbQuery<{ entry_id: string; value: string }>( - `SELECT e.id as entry_id, ef.value + const idList = ids + .map((i) => `'${sqlEscape(i)}'`) + .join(","); + const displayRows = duckdbQuery<{ + entry_id: string; + value: string; + }>( + `SELECT e.id as entry_id, ef.value FROM entries e JOIN entry_fields ef ON ef.entry_id = e.id JOIN fields f ON f.id = ef.field_id WHERE e.id IN (${idList}) AND f.object_id = '${sqlEscape(relObj.id)}' AND f.name = '${sqlEscape(displayFieldName)}'`, - ); + ); - const labelMap: Record = {}; - for (const row of displayRows) { - labelMap[row.entry_id] = row.value || row.entry_id; - } - for (const i of ids) { - if (!labelMap[i]) {labelMap[i] = i;} - } - relationLabels[rf.name] = labelMap; - } + const labelMap: Record = {}; + for (const row of displayRows) { + labelMap[row.entry_id] = row.value || row.entry_id; + } + for (const i of ids) { + if (!labelMap[i]) { + labelMap[i] = i; + } + } + relationLabels[rf.name] = labelMap; + } - // Enrich fields with related object names - const enrichedFields = parsedFields.map((f) => ({ - ...f, - related_object_name: - f.type === "relation" ? relatedObjectNames[f.name] : undefined, - })); + // 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 = + 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( - `SELECT * FROM objects WHERE id = '${sqlEscape(rrf.object_id)}' LIMIT 1`, - ); - if (sourceObj.length === 0) {continue;} + const sourceObj = duckdbQuery( + `SELECT * FROM objects WHERE id = '${sqlEscape(rrf.object_id)}' LIMIT 1`, + ); + if (sourceObj.length === 0) { + continue; + } - const sourceFields = duckdbQuery( - `SELECT * FROM fields WHERE object_id = '${sqlEscape(rrf.object_id)}' ORDER BY sort_order`, - ); - const displayFieldName = resolveDisplayField(sourceObj[0], sourceFields); + const sourceFields = duckdbQuery( + `SELECT * FROM fields WHERE object_id = '${sqlEscape(rrf.object_id)}' ORDER BY sort_order`, + ); + const displayFieldName = resolveDisplayField( + sourceObj[0], + sourceFields, + ); - // Get display labels for matching source entries - const idList = matchingSourceIds.map((i) => `'${sqlEscape(i)}'`).join(","); - const displayRows = duckdbQuery<{ entry_id: string; value: string }>( - `SELECT ef.entry_id, ef.value + const idList = matchingSourceIds + .map((i) => `'${sqlEscape(i)}'`) + .join(","); + const displayRows = duckdbQuery<{ + entry_id: string; + value: string; + }>( + `SELECT ef.entry_id, ef.value FROM entry_fields ef JOIN fields f ON f.id = ef.field_id WHERE ef.entry_id IN (${idList}) AND f.name = '${sqlEscape(displayFieldName)}' AND f.object_id = '${sqlEscape(rrf.object_id)}'`, - ); + ); - const displayMap: Record = {}; - for (const row of displayRows) { - displayMap[row.entry_id] = row.value || row.entry_id; - } + const displayMap: Record = {}; + for (const row of displayRows) { + displayMap[row.entry_id] = row.value || row.entry_id; + } - const links = matchingSourceIds.map((sid) => ({ - id: sid, - label: displayMap[sid] || sid, - })); + 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; } diff --git a/apps/web/app/api/workspace/objects/[name]/entries/bulk-delete/route.ts b/apps/web/app/api/workspace/objects/[name]/entries/bulk-delete/route.ts new file mode 100644 index 00000000000..2af3b11b223 --- /dev/null +++ b/apps/web/app/api/workspace/objects/[name]/entries/bulk-delete/route.ts @@ -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, + }); +} diff --git a/apps/web/app/api/workspace/objects/[name]/entries/route.ts b/apps/web/app/api/workspace/objects/[name]/entries/route.ts new file mode 100644 index 00000000000..49bff713e2b --- /dev/null +++ b/apps/web/app/api/workspace/objects/[name]/entries/route.ts @@ -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 } + */ +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 } = {}; + 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 }); +} diff --git a/apps/web/app/api/workspace/objects/[name]/fields/[fieldId]/route.ts b/apps/web/app/api/workspace/objects/[name]/fields/[fieldId]/route.ts new file mode 100644 index 00000000000..7ca84c1fa93 --- /dev/null +++ b/apps/web/app/api/workspace/objects/[name]/fields/[fieldId]/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/workspace/objects/[name]/fields/reorder/route.ts b/apps/web/app/api/workspace/objects/[name]/fields/reorder/route.ts new file mode 100644 index 00000000000..158e1f2ff22 --- /dev/null +++ b/apps/web/app/api/workspace/objects/[name]/fields/reorder/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/workspace/raw-file/route.ts b/apps/web/app/api/workspace/raw-file/route.ts new file mode 100644 index 00000000000..69b859e5c3a --- /dev/null +++ b/apps/web/app/api/workspace/raw-file/route.ts @@ -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 = { + // 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 }); + } +} diff --git a/apps/web/app/api/workspace/search-index/route.ts b/apps/web/app/api/workspace/search-index/route.ts index 979c9977764..342cd44108e 100644 --- a/apps/web/app/api/workspace/search-index/route.ts +++ b/apps/web/app/api/workspace/search-index/route.ts @@ -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 diff --git a/apps/web/app/api/workspace/tree/route.ts b/apps/web/app/api/workspace/tree/route.ts index ea465eab0a7..43c6971d342 100644 --- a/apps/web/app/api/workspace/tree/route.ts +++ b/apps/web/app/api/workspace/tree/route.ts @@ -57,7 +57,7 @@ function loadDbObjects(): Map { 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(); diff --git a/apps/web/app/components/chain-of-thought.tsx b/apps/web/app/components/chain-of-thought.tsx index 9670aa43392..c7fc3c13552 100644 --- a/apps/web/app/components/chain-of-thought.tsx +++ b/apps/web/app/components/chain-of-thought.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; /* ─── Public types ─── */ @@ -14,8 +14,395 @@ export type ChainPart = args?: Record; output?: Record; errorText?: string; + } + | { + kind: "status"; + label: string; + isActive: boolean; }; +/* ─── Media / file type helpers ─── */ + +const IMAGE_EXTS = new Set([ + "jpg", + "jpeg", + "png", + "gif", + "webp", + "svg", + "bmp", + "avif", + "heic", + "heif", + "tiff", + "tif", + "ico", +]); +const VIDEO_EXTS = new Set([ + "mp4", + "webm", + "mov", + "avi", + "mkv", +]); +const PDF_EXTS = new Set(["pdf"]); +const AUDIO_EXTS = new Set(["mp3", "wav", "ogg", "m4a"]); + +type MediaKind = "image" | "video" | "pdf" | "audio" | null; + +function getFileExt(path: string): string { + return (path.split(".").pop() ?? "").toLowerCase(); +} + +function detectMedia(path: string): MediaKind { + const ext = getFileExt(path); + if (IMAGE_EXTS.has(ext)) {return "image";} + if (VIDEO_EXTS.has(ext)) {return "video";} + if (PDF_EXTS.has(ext)) {return "pdf";} + if (AUDIO_EXTS.has(ext)) {return "audio";} + return null; +} + +function rawFileUrl(path: string): string { + return `/api/workspace/raw-file?path=${encodeURIComponent(path)}`; +} + +/** Resolve a media URL — use raw URL directly if it's already HTTP */ +function resolveMediaUrl(path: string): string { + if (path.startsWith("http://") || path.startsWith("https://")) { + return path; + } + return rawFileUrl(path); +} + +/** Regex to find file paths with media extensions in free text */ +const MEDIA_FILE_RE = + /(?:^|[\s"'(=])(((?:\/|\.\/)?[\w.\-/\\]+)\.(?:jpe?g|png|gif|webp|svg|bmp|avif|heic|heif|tiff?|ico|mp4|webm|mov|avi|mkv|mp3|wav|ogg|m4a|pdf))\b/i; + +const PATH_KEYS = [ + "path", + "file", + "file_path", + "filePath", + "filename", + "url", + "src", + "name", + "target", +]; + +/** + * Extract the file path from tool args and/or output. + * Searches standard keys, then all string values, then output text. + */ +function getFilePath( + args?: Record, + output?: Record, +): string | null { + // 1. Check standard keys in args + if (args) { + for (const key of PATH_KEYS) { + const v = args[key]; + if (typeof v === "string" && v.length > 0) {return v;} + } + } + + // 2. Check standard keys in output + if (output) { + for (const key of PATH_KEYS) { + const v = output[key]; + if (typeof v === "string" && v.length > 0 && looksLikePath(v)) + {return v;} + } + } + + // 3. Scan all string values in args for file-like paths + if (args) { + const found = findPathInValues(args); + if (found) {return found;} + } + + // 4. Extract from output text + if (output?.text && typeof output.text === "string") { + const m = output.text.match(MEDIA_FILE_RE); + if (m) {return m[1];} + } + + // 5. Scan output values too + if (output) { + const found = findPathInValues(output); + if (found) {return found;} + } + + return null; +} + +/** Check if a string looks like a file path (has an extension, no spaces) */ +function looksLikePath(s: string): boolean { + return ( + s.length > 2 && + s.length < 500 && + /\.\w{1,5}$/.test(s) && + !s.includes(" ") + ); +} + +/** Search all string values in an object for a path-like string */ +function findPathInValues(obj: Record): string | null { + for (const val of Object.values(obj)) { + if (typeof val === "string" && looksLikePath(val)) { + return val; + } + } + return null; +} + +/* ─── Domain / URL extraction helpers ─── */ + +const URL_RE = /https?:\/\/[^\s"'<>,;)}\]]+/gi; + +function extractDomains(text: string): string[] { + const urls = text.match(URL_RE) ?? []; + const domains = new Set(); + for (const url of urls) { + try { + const hostname = new URL(url).hostname; + if (hostname && !hostname.includes("localhost")) { + domains.add(hostname); + } + } catch { + /* skip */ + } + } + return [...domains].slice(0, 8); +} + +function faviconUrl(domain: string): string { + return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=32`; +} + +/* ─── Classify tool steps ─── */ + +type StepKind = + | "search" + | "fetch" + | "read" + | "exec" + | "write" + | "image" + | "generic"; + +function classifyTool(name: string): StepKind { + const n = name.toLowerCase().replace(/[_-]/g, ""); + if ( + [ + "websearch", + "search", + "googlesearch", + "bingsearch", + "browsersearch", + "tavily", + ].some((k) => n.includes(k)) + ) + {return "search";} + if ( + ["fetchurl", "fetch", "browse", "browseurl", "webfetch"].some( + (k) => n.includes(k), + ) + ) + {return "fetch";} + if ( + ["read", "file", "readfile", "getfile"].some( + (k) => n.includes(k), + ) + ) + {return "read";} + if ( + [ + "bash", + "shell", + "execute", + "exec", + "terminal", + "command", + "run", + ].some((k) => n.includes(k)) + ) + {return "exec";} + if ( + [ + "write", + "create", + "edit", + "str_replace", + "save", + "patch", + ].some((k) => n.includes(k)) + ) + {return "write";} + if ( + [ + "image", + "screenshot", + "photo", + "picture", + "dalle", + "generateimage", + ].some((k) => n.includes(k)) + ) + {return "image";} + return "generic"; +} + +function buildStepLabel( + kind: StepKind, + toolName: string, + args?: Record, + output?: Record, +): string { + const strVal = (key: string) => { + const v = args?.[key]; + return typeof v === "string" && v.length > 0 ? v : null; + }; + + switch (kind) { + case "search": { + const q = + strVal("query") ?? + strVal("search_query") ?? + strVal("search") ?? + strVal("q"); + return q + ? `Searching for ${q.length > 60 ? q.slice(0, 60) + "..." : q}` + : "Searching..."; + } + case "fetch": { + const u = + strVal("url") ?? strVal("path") ?? strVal("src"); + if (u) { + try { + return `Fetching ${new URL(u).hostname}`; + } catch { + return `Fetching ${u.length > 50 ? u.slice(0, 50) + "..." : u}`; + } + } + return "Fetching page"; + } + case "read": { + const p = getFilePath(args, output); + if (p) { + const short = p.split("/").pop() ?? p; + return short.startsWith("http") + ? `Fetching ${short.slice(0, 50)}` + : `Reading ${short}`; + } + return "Reading file"; + } + case "exec": { + const cmd = strVal("command") ?? strVal("cmd"); + if (cmd) { + const short = + cmd.length > 60 ? cmd.slice(0, 60) + "..." : cmd; + return `Running: ${short}`; + } + return "Running command"; + } + case "write": { + const p = strVal("path") ?? strVal("file") ?? strVal("file_path"); + if (p) { + const short = p.split("/").pop() ?? p; + return `Editing ${short}`; + } + return "Editing file"; + } + case "image": + return strVal("description") + ? `Generating image: ${strVal("description")!.slice(0, 50)}` + : "Generating image"; + default: + return toolName + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()) + .trim(); + } +} + +/** Extract domains from tool output for search steps */ +function getSearchDomains( + output?: Record, +): string[] { + if (!output) {return [];} + const text = typeof output.text === "string" ? output.text : ""; + const results = output.results; + let combined = text; + if (Array.isArray(results)) { + for (const r of results) { + if (typeof r === "object" && r !== null) { + const obj = r as Record; + if (typeof obj.url === "string") + {combined += ` ${obj.url}`;} + if (typeof obj.link === "string") + {combined += ` ${obj.link}`;} + } + } + } + return extractDomains(combined); +} + +/* ─── Group consecutive media reads ─── */ + +type ToolPart = Extract; + +type VisualItem = + | { type: "tool"; tool: ToolPart } + | { + type: "media-group"; + mediaKind: "image" | "video" | "pdf" | "audio"; + items: Array<{ path: string; tool: ToolPart }>; + }; + +function groupToolSteps(tools: ToolPart[]): VisualItem[] { + const result: VisualItem[] = []; + let i = 0; + while (i < tools.length) { + const tool = tools[i]; + const kind = classifyTool(tool.toolName); + // Check both args AND output for the file path + const filePath = getFilePath(tool.args, tool.output); + const media = filePath ? detectMedia(filePath) : null; + + // If this is a media read, look for consecutive media reads of the same kind + if (kind === "read" && media && filePath) { + const group: Array<{ path: string; tool: ToolPart }> = [ + { path: filePath, tool }, + ]; + let j = i + 1; + while (j < tools.length) { + const next = tools[j]; + const nextKind = classifyTool(next.toolName); + const nextPath = getFilePath(next.args, next.output); + const nextMedia = nextPath ? detectMedia(nextPath) : null; + if (nextKind === "read" && nextMedia === media && nextPath) { + group.push({ path: nextPath, tool: next }); + j++; + } else { + break; + } + } + result.push({ + type: "media-group", + mediaKind: media, + items: group, + }); + i = j; + } else { + result.push({ type: "tool", tool }); + i++; + } + } + return result; +} + /* ─── Main component ─── */ export function ChainOfThought({ parts }: { parts: ChainPart[] }) { @@ -25,10 +412,39 @@ export function ChainOfThought({ parts }: { parts: ChainPart[] }) { const isActive = parts.some( (p) => (p.kind === "reasoning" && p.isStreaming) || - (p.kind === "tool" && p.status === "running"), + (p.kind === "tool" && p.status === "running") || + (p.kind === "status" && p.isActive), ); - // Auto-collapse once all steps finish (active → inactive transition) + /* ─── Live elapsed-time tracking ─── */ + const startRef = useRef(null); + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + if (isActive && startRef.current === null) { + startRef.current = Date.now(); + } + }, [isActive]); + + useEffect(() => { + if (!isActive) {return;} + const tick = () => { + if (startRef.current !== null) { + setElapsed(Math.floor((Date.now() - startRef.current) / 1000)); + } + }; + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [isActive]); + + const formatDuration = useCallback((s: number) => { + if (s < 60) {return `${s}s`;} + const m = Math.floor(s / 60); + const rem = s % 60; + return rem > 0 ? `${m}m ${rem}s` : `${m}m`; + }, []); + useEffect(() => { if (prevActiveRef.current && !isActive && parts.length > 0) { setIsOpen(false); @@ -36,7 +452,10 @@ export function ChainOfThought({ parts }: { parts: ChainPart[] }) { prevActiveRef.current = isActive; }, [isActive, parts.length]); - // Aggregate reasoning text from all reasoning parts + const statusParts = parts.filter( + (p): p is Extract => + p.kind === "status", + ); const reasoningText = parts .filter( (p): p is Extract => @@ -48,78 +467,126 @@ export function ChainOfThought({ parts }: { parts: ChainPart[] }) { (p) => p.kind === "reasoning" && p.isStreaming, ); - // Tool steps const tools = parts.filter( - (p): p is Extract => p.kind === "tool", + (p): p is ToolPart => p.kind === "tool", ); - const completedTools = tools.filter((t) => t.status === "done").length; - const activeTool = tools.find((t) => t.status === "running"); + const visualItems = groupToolSteps(tools); - // Header label summarizes current/completed activity - let headerLabel: string; - if (isActive) { - if (activeTool) { - // Show what the active tool is doing - const summary = getToolSummary( - activeTool.toolName, - activeTool.args, - ); - headerLabel = summary || formatToolName(activeTool.toolName); - } else { - headerLabel = "Thinking"; - } - } else if (tools.length > 0) { - headerLabel = `Reasoned with ${completedTools} tool${completedTools !== 1 ? "s" : ""}`; - } else { - headerLabel = "Reasoned"; - } + // Derive a more descriptive header from status parts + const activeStatus = statusParts.find((s) => s.isActive); + const headerLabel = isActive + ? activeStatus + ? elapsed > 0 + ? `${activeStatus.label} ${formatDuration(elapsed)}` + : activeStatus.label + : elapsed > 0 + ? `Thinking... ${formatDuration(elapsed)}` + : "Thinking..." + : elapsed > 0 + ? `Thought for ${formatDuration(elapsed)}` + : "Thought"; return ( -
- {/* Trigger */} +
+ {/* Header trigger */} - {/* Collapsible content (smooth CSS grid animation) */} + {/* Collapsible content */}
-
- {/* Reasoning text block */} - {reasoningText && ( - + {/* Timeline connector line */} +
+ {statusParts.map((sp, idx) => ( + - )} - - {/* Tool step timeline */} - {tools.length > 0 && ( -
- {tools.map((tool) => ( - - ))} + ))} + {reasoningText && ( +
+
+ + + + +
+
+ +
)} + {visualItems.map((item, idx) => { + if (item.type === "media-group") { + return ( + + ); + } + return ( + + ); + })}
@@ -127,10 +594,9 @@ export function ChainOfThought({ parts }: { parts: ChainPart[] }) { ); } -/* ─── Sub-components ─── */ +/* ─── Reasoning block ─── */ -/** Expandable reasoning text display */ -function ReasoningText({ +function ReasoningBlock({ text, isStreaming, }: { @@ -138,27 +604,34 @@ function ReasoningText({ isStreaming: boolean; }) { const [expanded, setExpanded] = useState(false); - const isLong = text.length > 300; + const isLong = text.length > 400; return ( -
+
{text} {isStreaming && ( - + )}
{isLong && !expanded && ( @@ -167,7 +640,324 @@ function ReasoningText({ ); } -/** Rich tool step with args display and collapsible output */ +/* ─── Status step (lifecycle / compaction indicators) ─── */ + +function StatusStep({ + label, + isActive, +}: { + label: string; + isActive: boolean; +}) { + return ( +
+
+ {isActive ? ( + + ) : ( + + + + )} +
+ + {label} + +
+ ); +} + +/* ─── Media group (images, videos, PDFs, audio) ─── */ + +function MediaGroup({ + mediaKind, + items, +}: { + mediaKind: "image" | "video" | "pdf" | "audio"; + items: Array<{ path: string; tool: ToolPart }>; +}) { + const [expanded, setExpanded] = useState(false); + const anyRunning = items.some( + (i) => i.tool.status === "running", + ); + + // Show completed items progressively — don't wait for allDone + const completedItems = items.filter( + (i) => i.tool.status === "done", + ); + const doneCount = completedItems.length; + + const label = anyRunning + ? `Reading ${items.length} ${mediaKind}${items.length > 1 ? "s" : ""}...` + : mediaKind === "image" + ? items.length === 1 + ? `Read 1 image` + : `Read ${items.length} images` + : mediaKind === "video" + ? items.length === 1 + ? `Read 1 video` + : `Read ${items.length} videos` + : mediaKind === "pdf" + ? items.length === 1 + ? `Read 1 PDF` + : `Read ${items.length} PDFs` + : items.length === 1 + ? `Read 1 audio file` + : `Read ${items.length} audio files`; + + // Show up to 6 thumbnails by default, expandable + const PREVIEW_COUNT = 6; + const displayItems = expanded + ? completedItems + : completedItems.slice(0, PREVIEW_COUNT); + const hasMore = + completedItems.length > PREVIEW_COUNT && !expanded; + + return ( +
+
+ {anyRunning ? ( + + ) : ( + + )} +
+
+
+ {label} +
+ + {/* Image thumbnail grid — show progressively as items complete */} + {doneCount > 0 && mediaKind === "image" && ( +
+ {displayItems.map((item) => ( + + ))} + {anyRunning && ( +
+ +
+ )} + {hasMore && ( + + )} +
+ )} + + {/* Video inline */} + {doneCount > 0 && mediaKind === "video" && ( +
+ {displayItems.map((item) => ( +
+ )} + + {/* PDF links */} + {doneCount > 0 && mediaKind === "pdf" && ( +
+ {displayItems.map((item) => { + const filename = + item.path.split("/").pop() ?? + item.path; + return ( + + + + {filename} + + + ); + })} +
+ )} + + {/* Audio inline */} + {doneCount > 0 && mediaKind === "audio" && ( +
+ {displayItems.map((item) => ( +
+ )} +
+
+ ); +} + +/** Image thumbnail with error fallback */ +function MediaThumb({ + path, + single, +}: { + path: string; + single: boolean; +}) { + const [error, setError] = useState(false); + const filename = path.split("/").pop() ?? path; + const url = resolveMediaUrl(path); + const w = single ? 200 : 80; + const h = single ? 150 : 80; + + if (error) { + return ( +
+ {filename} +
+ ); + } + + return ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + {filename} setError(true)} + /> + + ); +} + +/* ─── Tool step (non-media) ─── */ + function ToolStep({ toolName, status, @@ -182,251 +972,407 @@ function ToolStep({ errorText?: string; }) { const [showOutput, setShowOutput] = useState(false); - const displayType = getToolDisplayType(toolName); - const primaryArg = getPrimaryArg(toolName, args); + const kind = classifyTool(toolName); + const label = buildStepLabel(kind, toolName, args, output); + const domains = + (kind === "search" || kind === "fetch") && status === "done" + ? getSearchDomains(output) + : []; const outputText = typeof output?.text === "string" ? output.text : undefined; - const exitCode = - output?.exitCode !== undefined ? Number(output.exitCode) : undefined; + + // For single-file reads that are media, render inline preview + const filePath = getFilePath(args, output); + const media = filePath ? detectMedia(filePath) : null; + const isSingleMedia = kind === "read" && media && status === "done"; return ( -
- {/* Tool name + status */} -
- {status === "running" && ( - - )} - {status === "done" && ( - - )} - {status === "error" && ( - - )} - - - {formatToolName(toolName)} - - - {/* Exit code badge for bash/exec tools */} - {exitCode !== undefined && exitCode !== 0 && ( - - exit {exitCode} - +
+
+ {status === "running" ? ( + + ) : status === "error" ? ( + + ) : ( + )}
- {/* Primary argument: command, path, query, code, etc. */} - {primaryArg && ( -
- {displayType === "bash" ? ( - - ) : displayType === "code" ? ( - - ) : ( -
- {primaryArg} +
+
+ {label} +
+ + {/* Single media inline preview (when not grouped) */} + {isSingleMedia && filePath && media === "image" && ( +
+ +
+ )} + + {isSingleMedia && filePath && media === "video" && ( +
- )} - {/* Error message */} - {status === "error" && errorText && ( -
- {errorText} -
- )} - - {/* Tool output */} - {outputText && status === "done" && ( -
- - {showOutput && ( - + {errorText} +
+ )} + + {/* Output toggle — skip for media files and search */} + {outputText && + status === "done" && + kind !== "search" && + !isSingleMedia && ( +
+ + {showOutput && ( +
+									{outputText.length > 2000
+										? outputText.slice(0, 2000) +
+											"\n..."
+										: outputText}
+								
+ )} +
)} -
- )} +
); } -/** Monospace code block with optional line limit */ -function CodeBlock({ - content, - maxLines = 10, -}: { - content: string; - maxLines?: number; -}) { - const [expanded, setExpanded] = useState(false); - const lines = content.split("\n"); - const isLong = lines.length > maxLines; - const displayContent = - !expanded && isLong - ? lines.slice(0, maxLines).join("\n") + "\n..." - : content; +/* ─── Domain badge with favicon ─── */ +function DomainBadge({ domain }: { domain: string }) { + const short = domain.replace(/^www\./, ""); return ( -
-
-				{displayContent}
-			
- {isLong && !expanded && ( - - )} -
+ + {/* eslint-disable-next-line @next/next/no-img-element */} + + {short} + ); } -/* ─── Tool classification helpers ─── */ +/* ─── Step icons ─── */ -type ToolDisplayType = "bash" | "code" | "file" | "search" | "generic"; +function StepIcon({ kind }: { kind: StepKind }) { + const color = "var(--color-text-muted)"; + const size = 16; -function getToolDisplayType(toolName: string): ToolDisplayType { - const name = toolName.toLowerCase().replace(/[_-]/g, ""); - if ( - ["bash", "shell", "execute", "exec", "terminal", "command"].some((k) => - name.includes(k), - ) - ) - return "bash"; - if ( - ["runcode", "python", "javascript", "typescript", "notebook"].some( - (k) => name.includes(k), - ) - ) - return "code"; - if ( - ["file", "read", "write", "create", "edit", "str_replace"].some((k) => - name.includes(k), - ) - ) - return "file"; - if ( - ["search", "web", "grep", "find", "glob"].some((k) => - name.includes(k), - ) - ) - return "search"; - return "generic"; -} - -function getPrimaryArg( - toolName: string, - args?: Record, -): string | undefined { - if (!args) return undefined; - const type = getToolDisplayType(toolName); - switch (type) { - case "bash": - return strArg(args, "command") ?? strArg(args, "cmd"); - case "code": - return strArg(args, "code") ?? strArg(args, "script"); - case "file": - return ( - strArg(args, "path") ?? - strArg(args, "file") ?? - strArg(args, "file_path") - ); + switch (kind) { case "search": return ( - strArg(args, "query") ?? - strArg(args, "search") ?? - strArg(args, "pattern") ?? - strArg(args, "q") + + + + + ); + case "fetch": + return ( + + + + + + ); + case "read": + return ( + + + + + ); + case "exec": + return ( + + + + + ); + case "write": + return ( + + + + + ); + case "image": + return ( + + + + + ); - default: { - // Return first short string arg - for (const val of Object.values(args)) { - if (typeof val === "string" && val.length > 0 && val.length < 200) return val; - } - return undefined; - } - } -} - -/** Safely extract a string value from an args object */ -function strArg( - args: Record, - key: string, -): string | undefined { - const val = args[key]; - return typeof val === "string" && val.length > 0 ? val : undefined; -} - -/** Build a short summary for the active tool (shown in collapsed header) */ -function getToolSummary( - toolName: string, - args?: Record, -): string | undefined { - if (!args) return undefined; - const type = getToolDisplayType(toolName); - const primary = getPrimaryArg(toolName, args); - if (!primary) return undefined; - - switch (type) { - case "bash": { - // Show first 40 chars of command - const short = - primary.length > 40 ? primary.slice(0, 40) + "..." : primary; - return `Running: ${short}`; - } - case "file": { - return `Reading ${primary.split("/").pop()}`; - } - case "search": { - return `Searching: ${primary}`; - } default: - return undefined; + return ( + + + + ); } } -/* ─── Helpers ─── */ - -/** Convert tool_name_like_this → Tool Name Like This */ -function formatToolName(name: string): string { - return name - .replace(/_/g, " ") - .replace(/\b\w/g, (c) => c.toUpperCase()) - .trim(); +function ErrorCircleIcon() { + return ( + + + + + + ); } -/* ─── Inline SVG icons (avoids adding lucide-react dep) ─── */ +function PdfIcon() { + return ( + + + + + + + + ); +} -function SparkleIcon({ className }: { className?: string }) { +/* ─── Header icons ─── */ + +function ThinkingIcon({ className }: { className?: string }) { return ( - + + + ); } @@ -446,35 +1392,3 @@ function ChevronIcon({ className }: { className?: string }) { ); } - -function CheckIcon({ className }: { className?: string }) { - return ( - - - - ); -} - -function XIcon({ className }: { className?: string }) { - return ( - - - - ); -} diff --git a/apps/web/app/components/charts/chart-panel.tsx b/apps/web/app/components/charts/chart-panel.tsx index 87acb447133..3c38ad49768 100644 --- a/apps/web/app/components/charts/chart-panel.tsx +++ b/apps/web/app/components/charts/chart-panel.tsx @@ -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[]; compact?: boolean; - ChartComponent: typeof BarChart | typeof LineChart | typeof AreaChart; + ChartComponent: typeof BarChart ; SeriesComponent: typeof Bar | typeof Line | typeof Area; areaProps?: Record; }) { @@ -134,7 +134,7 @@ function CartesianChart({ dataKey={xKey} tick={axisStyle} tickFormatter={formatLabel} - axisLine={{ stroke: "#262626" }} + axisLine={{ stroke: "var(--color-border)" }} tickLine={false} /> - - - + + + {valueKeys.map((key, i) => ( - + {yKeys.map((key, i) => ( @@ -336,7 +336,7 @@ function FunnelChartPanel({ > 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)", }} > diff --git a/apps/web/app/components/charts/report-card.tsx b/apps/web/app/components/charts/report-card.tsx index da92a209ba6..18ba1f3d9d9 100644 --- a/apps/web/app/components/charts/report-card.tsx +++ b/apps/web/app/components/charts/report-card.tsx @@ -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)", }} > diff --git a/apps/web/app/components/chat-message.tsx b/apps/web/app/components/chat-message.tsx index b90771869ef..b7b63acd779 100644 --- a/apps/web/app/components/chat-message.tsx +++ b/apps/web/app/components/chat-message.tsx @@ -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: () =>
}, + () => + import("./charts/report-card").then((m) => ({ + default: m.ReportCard, + })), + { + ssr: false, + loading: () => ( +
+ ), + }, ); /* ─── 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 | undefined { - if (val && typeof val === "object" && !Array.isArray(val)) - {return val as Record;} + if (val && typeof val === "object" && !Array.isArray(val)) { + return val as Record; + } 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 ( + + {children} + + ); + }, + // Render images with loading=lazy + img: ({ src, alt, ...props }) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt + ), +}; + +/* ─── Chat message (Dench-inspired free-flowing text) ─── */ export function ChatMessage({ message }: { message: UIMessage }) { const isUser = message.role === "user"; const segments = groupParts(message.parts); - return ( -
- {!isUser && ( -
- O -
- )} + 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"); -
- {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 ( -
- - - {errorMatch[1].trim()} - -
+ return ( +
+
+

{textContent}

+
+
+ ); + } + + // Assistant: free-flowing text, left-aligned, NO bubble + return ( +
+ {segments.map((segment, index) => { + if (segment.type === "text") { + // Detect agent error messages + const errorMatch = segment.text.match( + /^\[error\]\s*([\s\S]*)$/, ); - } + if (errorMatch) { + return ( +
+ + + {errorMatch[1].trim()} + +
+ ); + } return ( -
- {segment.text} -
+
+ + {segment.text} + +
); - } + } if (segment.type === "report-artifact") { return ( + ); })} -
- - {isUser && ( -
- U -
- )}
); } diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx index 25083545c59..6d8e7ed3e24 100644 --- a/apps/web/app/components/chat-panel.tsx +++ b/apps/web/app/components/chat-panel.tsx @@ -57,9 +57,9 @@ export const ChatPanel = forwardRef( ref, ) { const [input, setInput] = useState(""); - const [currentSessionId, setCurrentSessionId] = useState( - null, - ); + const [currentSessionId, setCurrentSessionId] = useState< + string | null + >(null); const [loadingSession, setLoadingSession] = useState(false); const [startingNewSession, setStartingNewSession] = useState(false); const messagesEndRef = useRef(null); @@ -72,27 +72,19 @@ export const ChatPanel = forwardRef( const isFirstFileMessageRef = useRef(true); // File-scoped session list (compact mode only) - const [fileSessions, setFileSessions] = useState( - [], - ); + 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(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( 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( const createSession = useCallback( async (title: string): Promise => { const body: Record = { 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( }), }, ); - 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( ); // ── 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) | 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( }; 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( 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( 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( parts?: Array>; }> = 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( }, [filePath]); // ── Persist unsaved messages + live-reload after streaming ── - const prevStatusRef = useRef(status); useEffect(() => { const wasStreaming = @@ -303,21 +307,23 @@ export const ChatPanel = forwardRef( 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( 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( return; } - // Create session if none exists yet let sessionId = currentSessionId; if (!sessionId) { const title = @@ -360,15 +367,15 @@ export const ChatPanel = forwardRef( 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( 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( 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( setLoadingSession(false); } }, - [currentSessionId, setMessages, onActiveSessionChange, stop], + [ + currentSessionId, + setMessages, + onActiveSessionChange, + stop, + ], ); const handleNewSession = useCallback(async () => { @@ -439,22 +454,20 @@ export const ChatPanel = forwardRef( 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( ) : ( <> -

+

{currentSessionId ? "Chat Session" : "New Chat"} @@ -535,20 +553,11 @@ export const ChatPanel = forwardRef(

) : messages.length === 0 ? (
-
+
{compact ? (

(

) : ( <> -

- 🦞 -

-

- Ironclaw Chat +

+ What can I help with?

(

( className="flex-shrink-0" > - - + + -

- {error.message} -

+

{error.message}

)} - {/* Input */} + {/* Input — Dench-style rounded area with toolbar */}
-
- 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`} +
- - + + {/* Toolbar row */} +
+
+ {/* Placeholder toolbar icons */} + +
+ {/* Send button */} + +
+
+
); diff --git a/apps/web/app/components/workspace/data-table.tsx b/apps/web/app/components/workspace/data-table.tsx new file mode 100644 index 00000000000..af206377229 --- /dev/null +++ b/apps/web/app/components/workspace/data-table.tsx @@ -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 = { + label: string; + onClick?: (row: TData) => void; + icon?: React.ReactNode; + variant?: "default" | "destructive"; +}; + +export type DataTableProps = { + columns: ColumnDef[]; + data: TData[]; + loading?: boolean; + // search + searchPlaceholder?: string; + enableGlobalFilter?: boolean; + // sorting + enableSorting?: boolean; + // row selection + enableRowSelection?: boolean; + rowSelection?: Record; + onRowSelectionChange?: OnChangeFn>; + 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[]; + // toolbar + toolbarExtra?: React.ReactNode; + title?: string; + titleIcon?: React.ReactNode; + // sticky + stickyFirstColumn?: boolean; +}; + +/* ─── Fuzzy filter ─── */ + +function fuzzyFilter( + row: Row, + 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 ( + + {children} + + ); +} + +/* ─── Sort icon ─── */ + +function SortIcon({ direction }: { direction: "asc" | "desc" | false }) { + if (!direction) { + return ( + + + + ); + } + return ( + + {direction === "asc" ? : } + + ); +} + +/* ─── Main component ─── */ + +export function DataTable({ + 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) { + const [sorting, setSorting] = useState([]); + const [globalFilter, setGlobalFilter] = useState(""); + const [columnFilters, setColumnFilters] = useState([]); + const [columnVisibility, setColumnVisibility] = useState(initialColumnVisibility ?? {}); + const [internalRowSelection, setInternalRowSelection] = useState>({}); + const [showColumnsMenu, setShowColumnsMenu] = useState(false); + const [stickyFirstColumn, setStickyFirstColumn] = useState(stickyFirstProp); + const [isScrolled, setIsScrolled] = useState(false); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: defaultPageSize }); + const columnsMenuRef = useRef(null); + const scrollContainerRef = useRef(null); + + const rowSelectionState = externalRowSelection !== undefined ? externalRowSelection : internalRowSelection; + + // Extract column ID from ColumnDef + const getColumnId = useCallback((c: ColumnDef): 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[]) => { + 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(() => + 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) => { + 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 | null = enableRowSelection + ? { + id: "select", + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + 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 | null = rowActions + ? { + id: "actions", + header: () => null, + cell: ({ row }) => ( + + ), + size: 48, + enableSorting: false, + enableHiding: false, + } + : null; + + const allColumns = useMemo(() => { + const cols: ColumnDef[] = []; + if (selectionColumn) {cols.push(selectionColumn as ColumnDef);} + cols.push(...columns); + if (actionsColumn) {cols.push(actionsColumn as ColumnDef);} + 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 ( +
+ {/* Toolbar */} +
+ {title && ( +
+ {titleIcon} + + {title} + +
+ )} + + {/* Search */} + {enableGlobalFilter && ( +
+ + + + 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 && ( + + )} +
+ )} + + {/* Bulk actions */} + {selectedCount > 0 && bulkActions && ( +
+ + {selectedCount} selected + + {bulkActions} +
+ )} + +
+ + {toolbarExtra} + + {/* Refresh */} + {onRefresh && ( + + )} + + {/* Columns menu */} +
+ + {showColumnsMenu && ( +
+ {/* Sticky first col toggle */} + + {visibleColumns.length === 0 ? ( +
+ No toggleable columns +
+ ) : ( + table.getAllLeafColumns() + .filter((c) => c.id !== "select" && c.id !== "actions" && c.getCanHide()) + .map((column) => ( + + )) + )} +
+ )} +
+ + {/* Add button */} + {onAdd && ( + + )} +
+ + {/* Table */} +
+ {loading ? ( + + ) : data.length === 0 ? ( +
+ + + +

No data

+
+ ) : ( + + + + {table.getHeaderGroups().map((headerGroup) => ( + + + {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 ( + + + {content} + {canSort && } + + + ); + } + + return ( + + ); + })} + + + ))} + + + {table.getRowModel().rows.map((row, rowIdx) => { + const isSelected = row.getIsSelected(); + return ( + 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 ( + + ); + })} + + ); + })} + +
+ + {content} + {canSort && } + +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ )} +
+ + {/* Pagination footer */} + {!loading && data.length > 0 && ( +
+ + Showing {table.getRowModel().rows.length} of {data.length} results + {selectedCount > 0 && ` (${selectedCount} selected)`} + +
+ Rows per page + + + Page {pagination.pageIndex + 1} of {table.getPageCount()} + +
+ setPagination((p) => ({ ...p, pageIndex: 0 }))} disabled={!table.getCanPreviousPage()} label="«" /> + table.previousPage()} disabled={!table.getCanPreviousPage()} label="‹" /> + table.nextPage()} disabled={!table.getCanNextPage()} label="›" /> + setPagination((p) => ({ ...p, pageIndex: table.getPageCount() - 1 }))} disabled={!table.getCanNextPage()} label="»" /> +
+
+
+ )} +
+ ); +} + +/* ─── Sub-components ─── */ + +function PaginationButton({ onClick, disabled, label }: { onClick: () => void; disabled: boolean; label: string }) { + return ( + + {open && ( +
+ {actions.map((action, i) => ( + + ))} +
+ )} +
+ ); +} + +function LoadingSkeleton({ columnCount }: { columnCount: number }) { + return ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ {Array.from({ length: Math.min(columnCount, 6) }).map((_, j) => ( +
+ ))} +
+ ))} +
+ ); +} diff --git a/apps/web/app/components/workspace/database-viewer.tsx b/apps/web/app/components/workspace/database-viewer.tsx index 207d649f965..a4f3a092422 100644 --- a/apps/web/app/components/workspace/database-viewer.tsx +++ b/apps/web/app/components/workspace/database-viewer.tsx @@ -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)"}`, }} diff --git a/apps/web/app/components/workspace/empty-state.tsx b/apps/web/app/components/workspace/empty-state.tsx index 419866c2a94..db7a0203340 100644 --- a/apps/web/app/components/workspace/empty-state.tsx +++ b/apps/web/app/components/workspace/empty-state.tsx @@ -1,119 +1,156 @@ "use client"; -export function EmptyState({ workspaceExists }: { workspaceExists: boolean }) { - return ( -
- {/* Icon */} -
- - - - - - -
+export function EmptyState({ + workspaceExists, +}: { + workspaceExists: boolean; +}) { + return ( +
+ {/* Icon */} +
+ + + + + + +
- {/* Text */} -
-

- {workspaceExists - ? "Workspace is empty" - : "No workspace found"} -

-

- {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. - - )} -

-
+ {/* Text */} +
+

+ {workspaceExists + ? "Workspace is empty" + : "No workspace found"} +

+

+ {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. + + )} +

+
- {/* Hint */} -
- - - - - - - Expected location:{" "} - - ~/.openclaw/workspace/dench/ - - -
+ {/* Hint */} +
+ + + + + + + Expected location:{" "} + + ~/.openclaw/workspace/dench/ + + +
- {/* Back link */} - - - - - - Back to Home - -
- ); + {/* Back link */} + + + + + + Back to Home + +
+ ); } diff --git a/apps/web/app/components/workspace/entry-detail-modal.tsx b/apps/web/app/components/workspace/entry-detail-modal.tsx index f92fecc091d..1908a6fa1bc 100644 --- a/apps/web/app/components/workspace/entry-detail-modal.tsx +++ b/apps/web/app/components/workspace/entry-detail-modal.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [editingField, setEditingField] = useState(null); + const [editValue, setEditValue] = useState(""); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); const backdropRef = useRef(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}
- +
+ {/* Delete button */} + + {/* Close button */} + +
{/* Content */} @@ -447,13 +510,72 @@ export function EntryDetailModal({ className="text-sm min-h-[1.75rem] flex items-center" style={{ color: "var(--color-text)" }} > - + {editingField === field.name ? ( +
{ e.preventDefault(); handleSaveField(field.name, editValue); }} + className="flex items-center gap-2 w-full" + > + {field.type === "enum" && field.enum_values ? ( + + ) : field.type === "boolean" ? ( + + ) : ( + <> + 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)" }} + /> + + + )} + +
+ ) : ( +
{ + if (!["relation", "user"].includes(field.type)) { + setEditingField(field.name); + setEditValue(String(value ?? "")); + } + }} + title={!["relation", "user"].includes(field.type) ? "Click to edit" : undefined} + > + +
+ )}
); diff --git a/apps/web/app/components/workspace/file-manager-tree.tsx b/apps/web/app/components/workspace/file-manager-tree.tsx index feb6c1c3a6a..d347d39976b 100644 --- a/apps/web/app/components/workspace/file-manager-tree.tsx +++ b/apps/web/app/components/workspace/file-manager-tree.tsx @@ -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" && ( + style={{ background: "var(--color-accent-light)", color: "var(--color-accent)" }}> {node.defaultView === "kanban" ? "board" : "table"} )} @@ -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 { diff --git a/apps/web/app/components/workspace/knowledge-tree.tsx b/apps/web/app/components/workspace/knowledge-tree.tsx index 1c52dc142e5..a61d48d23d8 100644 --- a/apps/web/app/components/workspace/knowledge-tree.tsx +++ b/apps/web/app/components/workspace/knowledge-tree.tsx @@ -204,7 +204,7 @@ function TreeNodeItem({ diff --git a/apps/web/app/components/workspace/media-viewer.tsx b/apps/web/app/components/workspace/media-viewer.tsx new file mode 100644 index 00000000000..22b53c7ff91 --- /dev/null +++ b/apps/web/app/components/workspace/media-viewer.tsx @@ -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 ( + + + + + + ); +} + +function ExternalLinkIcon() { + return ( + + + + + + ); +} + +function ZoomInIcon() { + return ( + + + + + + + ); +} + +function ZoomOutIcon() { + return ( + + + + + + ); +} + +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 ( +
+ {/* Header bar */} + + + {/* Content */} +
+ {mediaType === "image" && } + {mediaType === "video" && } + {mediaType === "audio" && } + {mediaType === "pdf" && } +
+ + {/* Footer with path */} + {filePath && ( +
+ + {filePath} + +
+ )} +
+ ); +} + +// --- 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 ( +
+ 🖼 +

+ Failed to load image +

+
+ ); + } + + return ( +
+ {/* Zoom controls */} +
+ + + +
+ + {/* Image container with checkerboard background for transparency */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {filename} setError(true)} + style={{ + transform: `scale(${zoom})`, + transformOrigin: "center center", + transition: "transform 200ms ease", + maxWidth: zoom <= 1 ? "100%" : "none", + display: "block", + }} + draggable={false} + /> +
+
+ ); +} + +// --- Video Viewer --- + +function VideoViewer({ url }: { url: string }) { + return ( +
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */} +
+ ); +} + +// --- Audio Viewer --- + +function AudioViewer({ url, filename }: { url: string; filename: string }) { + return ( +
+ {/* Visual representation */} +
+ + + + + +
+ +

+ {filename} +

+ + {/* eslint-disable-next-line jsx-a11y/media-has-caption */} +
+ ); +} + +// --- PDF Viewer --- + +function PdfViewer({ url }: { url: string }) { + return ( +
+