diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index a21f5a22cd7..8c94b95c3f6 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -45,7 +45,8 @@ export async function POST(req: Request) { sessionId, sessionKey, distinctId, - }: { messages: UIMessage[]; sessionId?: string; sessionKey?: string; distinctId?: string } = await req.json(); + userHtml, + }: { messages: UIMessage[]; sessionId?: string; sessionKey?: string; distinctId?: string; userHtml?: string } = await req.json(); const lastUserMessage = messages.filter((m) => m.role === "user").pop(); const userText = @@ -106,16 +107,17 @@ export async function POST(req: Request) { task: info.task, }); } - persistSubscribeUserMessage(sessionKey, { + await persistSubscribeUserMessage(sessionKey, { id: lastUserMessage.id, text: userText, }); reactivateSubscribeRun(sessionKey, agentMessage); } else if (sessionId && lastUserMessage) { - persistUserMessage(sessionId, { + await persistUserMessage(sessionId, { id: lastUserMessage.id, content: userText, parts: lastUserMessage.parts as unknown[], + html: userHtml, }); try { startRun({ diff --git a/apps/web/app/api/workspace/db.test.ts b/apps/web/app/api/workspace/db.test.ts index fe2a17cc2bf..dbfb087408d 100644 --- a/apps/web/app/api/workspace/db.test.ts +++ b/apps/web/app/api/workspace/db.test.ts @@ -89,9 +89,9 @@ describe("Workspace DB & Reports API", () => { }); it("executes query and returns rows", async () => { - const { safeResolvePath, duckdbQueryOnFile } = await import("@/lib/workspace"); + const { safeResolvePath, duckdbQueryOnFileAsync } = await import("@/lib/workspace"); vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb"); - vi.mocked(duckdbQueryOnFile).mockReturnValue([{ id: 1, name: "test" }]); + vi.mocked(duckdbQueryOnFileAsync).mockResolvedValue([{ id: 1, name: "test" }]); const { POST } = await import("./db/query/route.js"); const req = new Request("http://localhost/api/workspace/db/query", { @@ -106,9 +106,9 @@ describe("Workspace DB & Reports API", () => { }); it("returns empty rows for empty result", async () => { - const { safeResolvePath, duckdbQueryOnFile } = await import("@/lib/workspace"); + const { safeResolvePath, duckdbQueryOnFileAsync } = await import("@/lib/workspace"); vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb"); - vi.mocked(duckdbQueryOnFile).mockReturnValue([]); + vi.mocked(duckdbQueryOnFileAsync).mockResolvedValue([]); const { POST } = await import("./db/query/route.js"); const req = new Request("http://localhost/api/workspace/db/query", { @@ -190,8 +190,8 @@ describe("Workspace DB & Reports API", () => { it("executes report query successfully", async () => { const { checkSqlSafety } = await import("@/lib/report-filters"); vi.mocked(checkSqlSafety).mockReturnValue(null); - const { duckdbQuery } = await import("@/lib/workspace"); - vi.mocked(duckdbQuery).mockReturnValue([{ count: 42 }]); + const { duckdbQueryAsync } = await import("@/lib/workspace"); + vi.mocked(duckdbQueryAsync).mockResolvedValue([{ count: 42 }]); const { POST } = await import("./reports/execute/route.js"); const req = new Request("http://localhost/api/workspace/reports/execute", { @@ -210,8 +210,8 @@ describe("Workspace DB & Reports API", () => { vi.mocked(checkSqlSafety).mockReturnValue(null); vi.mocked(buildFilterClauses).mockReturnValue(['"Status" = \'Active\'']); vi.mocked(injectFilters).mockReturnValue("SELECT * FROM filtered"); - const { duckdbQuery } = await import("@/lib/workspace"); - vi.mocked(duckdbQuery).mockReturnValue([{ count: 10 }]); + const { duckdbQueryAsync } = await import("@/lib/workspace"); + vi.mocked(duckdbQueryAsync).mockResolvedValue([{ count: 10 }]); const { POST } = await import("./reports/execute/route.js"); const req = new Request("http://localhost/api/workspace/reports/execute", { @@ -244,8 +244,8 @@ describe("Workspace DB & Reports API", () => { }); it("executes query and returns rows", async () => { - const { duckdbQuery } = await import("@/lib/workspace"); - vi.mocked(duckdbQuery).mockReturnValue([{ id: 1 }]); + const { duckdbQueryAsync } = await import("@/lib/workspace"); + vi.mocked(duckdbQueryAsync).mockResolvedValue([{ id: 1 }]); const { POST } = await import("./query/route.js"); const req = new Request("http://localhost/api/workspace/query", { diff --git a/apps/web/app/api/workspace/db/query/route.ts b/apps/web/app/api/workspace/db/query/route.ts index 69c7011af79..0f19cc1ab57 100644 --- a/apps/web/app/api/workspace/db/query/route.ts +++ b/apps/web/app/api/workspace/db/query/route.ts @@ -1,4 +1,4 @@ -import { safeResolvePath, duckdbQueryOnFile } from "@/lib/workspace"; +import { safeResolvePath, duckdbQueryOnFileAsync } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -51,6 +51,6 @@ export async function POST(request: Request) { ); } - const rows = duckdbQueryOnFile(absPath, sql); + const rows = await duckdbQueryOnFileAsync(absPath, sql); return Response.json({ rows, sql }); } diff --git a/apps/web/app/api/workspace/objects.test.ts b/apps/web/app/api/workspace/objects.test.ts index aee28a87012..d4f4dcc5cc7 100644 --- a/apps/web/app/api/workspace/objects.test.ts +++ b/apps/web/app/api/workspace/objects.test.ts @@ -8,13 +8,18 @@ vi.mock("node:child_process", () => ({ // Mock workspace vi.mock("@/lib/workspace", () => ({ duckdbPath: vi.fn(() => null), + duckdbPathAsync: vi.fn(async () => null), duckdbQueryOnFile: vi.fn(() => []), + duckdbQueryOnFileAsync: vi.fn(async () => []), duckdbExecOnFile: vi.fn(() => true), + duckdbExecOnFileAsync: vi.fn(async () => true), findDuckDBForObject: vi.fn(() => null), + findDuckDBForObjectAsync: vi.fn(async () => null), getObjectViews: vi.fn(() => ({ views: [], activeView: null })), parseRelationValue: vi.fn((v: string | null) => (v ? [v] : [])), resolveDuckdbBin: vi.fn(() => null), discoverDuckDBPaths: vi.fn(() => []), + discoverDuckDBPathsAsync: vi.fn(async () => []), })); describe("Workspace Objects API", () => { @@ -25,13 +30,18 @@ describe("Workspace Objects API", () => { })); vi.mock("@/lib/workspace", () => ({ duckdbPath: vi.fn(() => null), + duckdbPathAsync: vi.fn(async () => null), duckdbQueryOnFile: vi.fn(() => []), + duckdbQueryOnFileAsync: vi.fn(async () => []), duckdbExecOnFile: vi.fn(() => true), + duckdbExecOnFileAsync: vi.fn(async () => true), findDuckDBForObject: vi.fn(() => null), + findDuckDBForObjectAsync: vi.fn(async () => null), getObjectViews: vi.fn(() => ({ views: [], activeView: null })), parseRelationValue: vi.fn((v: string | null) => (v ? [v] : [])), resolveDuckdbBin: vi.fn(() => null), discoverDuckDBPaths: vi.fn(() => []), + discoverDuckDBPathsAsync: vi.fn(async () => []), })); }); @@ -67,10 +77,10 @@ describe("Workspace Objects API", () => { }); it("returns 404 when object not found", async () => { - const { findDuckDBForObject, resolveDuckdbBin, duckdbPath: mockDuckdbPath } = await import("@/lib/workspace"); + const { findDuckDBForObjectAsync, resolveDuckdbBin, duckdbPathAsync: mockDuckdbPath } = await import("@/lib/workspace"); vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb"); - vi.mocked(findDuckDBForObject).mockReturnValue(null); - vi.mocked(mockDuckdbPath).mockReturnValue(null); + vi.mocked(findDuckDBForObjectAsync).mockResolvedValue(null); + vi.mocked(mockDuckdbPath).mockResolvedValue(null); const { GET } = await import("./objects/[name]/route.js"); const res = await GET( @@ -81,14 +91,14 @@ describe("Workspace Objects API", () => { }); it("returns object schema and entries when found", async () => { - const { findDuckDBForObject, duckdbQueryOnFile, resolveDuckdbBin, discoverDuckDBPaths } = await import("@/lib/workspace"); - vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb"); + const { findDuckDBForObjectAsync, duckdbQueryOnFileAsync, resolveDuckdbBin, discoverDuckDBPathsAsync } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObjectAsync).mockResolvedValue("/ws/workspace.duckdb"); vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb"); - vi.mocked(discoverDuckDBPaths).mockReturnValue(["/ws/workspace.duckdb"]); + vi.mocked(discoverDuckDBPathsAsync).mockResolvedValue(["/ws/workspace.duckdb"]); // Mock different queries with a call counter let queryCall = 0; - vi.mocked(duckdbQueryOnFile).mockImplementation(() => { + vi.mocked(duckdbQueryOnFileAsync).mockImplementation(async () => { queryCall++; if (queryCall === 1) { // Object row @@ -120,18 +130,64 @@ describe("Workspace Objects API", () => { expect(json.fields).toBeDefined(); }); + it("loads same-db schema queries sequentially (prevents oscillating empty fields during live refresh)", async () => { + const { + findDuckDBForObjectAsync, + duckdbQueryOnFileAsync, + resolveDuckdbBin, + discoverDuckDBPathsAsync, + } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObjectAsync).mockResolvedValue("/ws/workspace.duckdb"); + vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb"); + vi.mocked(discoverDuckDBPathsAsync).mockResolvedValue(["/ws/workspace.duckdb"]); + + let inFlight = 0; + vi.mocked(duckdbQueryOnFileAsync).mockImplementation(async (_dbFile, sql) => { + inFlight += 1; + const concurrent = inFlight > 1; + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight -= 1; + + if (sql.includes("SELECT * FROM objects WHERE name")) { + return [{ id: "obj1", name: "company", description: "Company object" }] as never; + } + if (sql.includes("SELECT * FROM fields")) { + return concurrent + ? ([] as never) + : ([{ id: "f1", name: "Company Name", type: "text", sort_order: 0 }] as never); + } + if (sql.includes("SELECT * FROM statuses")) { + return concurrent + ? ([] as never) + : ([{ id: "status1", name: "Active", sort_order: 0 }] as never); + } + return [] as never; + }); + + const { GET } = await import("./objects/[name]/route.js"); + const res = await GET( + new Request("http://localhost/api/workspace/objects/company"), + { params: Promise.resolve({ name: "company" }) }, + ); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.fields).toHaveLength(1); + expect(json.statuses).toHaveLength(1); + }); + it("returns saved views and active view from object yaml metadata", async () => { const { - findDuckDBForObject, - duckdbQueryOnFile, + findDuckDBForObjectAsync, + duckdbQueryOnFileAsync, resolveDuckdbBin, - discoverDuckDBPaths, + discoverDuckDBPathsAsync, getObjectViews, } = await import("@/lib/workspace"); - vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb"); + vi.mocked(findDuckDBForObjectAsync).mockResolvedValue("/ws/workspace.duckdb"); vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb"); - vi.mocked(discoverDuckDBPaths).mockReturnValue(["/ws/workspace.duckdb"]); + vi.mocked(discoverDuckDBPathsAsync).mockResolvedValue(["/ws/workspace.duckdb"]); vi.mocked(getObjectViews).mockReturnValue({ views: [ { @@ -151,7 +207,7 @@ describe("Workspace Objects API", () => { }); let queryCall = 0; - vi.mocked(duckdbQueryOnFile).mockImplementation(() => { + vi.mocked(duckdbQueryOnFileAsync).mockImplementation(async () => { queryCall += 1; if (queryCall === 1) { return [{ id: "obj1", name: "leads", description: "Leads object", icon: "star" }]; @@ -179,8 +235,8 @@ describe("Workspace Objects API", () => { }); it("accepts underscored names", async () => { - const { findDuckDBForObject } = await import("@/lib/workspace"); - vi.mocked(findDuckDBForObject).mockReturnValue(null); + const { findDuckDBForObjectAsync } = await import("@/lib/workspace"); + vi.mocked(findDuckDBForObjectAsync).mockResolvedValue(null); const { GET } = await import("./objects/[name]/route.js"); const res = await GET( diff --git a/apps/web/app/api/workspace/objects/[name]/route.ts b/apps/web/app/api/workspace/objects/[name]/route.ts index c36b8a7f86f..3bc6bdc54f4 100644 --- a/apps/web/app/api/workspace/objects/[name]/route.ts +++ b/apps/web/app/api/workspace/objects/[name]/route.ts @@ -1,6 +1,14 @@ -import { duckdbPath, parseRelationValue, resolveDuckdbBin, findDuckDBForObject, duckdbQueryOnFile, discoverDuckDBPaths, getObjectViews } from "@/lib/workspace"; +import { + duckdbPathAsync, + parseRelationValue, + resolveDuckdbBin, + findDuckDBForObjectAsync, + duckdbQueryOnFileAsync, + discoverDuckDBPathsAsync, + getObjectViews, + duckdbExecOnFileAsync, +} from "@/lib/workspace"; import { deserializeFilters, buildWhereClause, buildOrderByClause, type FieldMeta } from "@/lib/object-filters"; -import { execSync } from "node:child_process"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -49,29 +57,25 @@ type EavRow = { // --- Schema migration (idempotent, runs once per process) --- -const migratedDbs = new Set(); +const migratedDbs = new Map>(); /** Ensure the display_field column exists on a specific DB file. */ -function ensureDisplayFieldColumn(dbFile: string) { - if (migratedDbs.has(dbFile)) {return;} - const bin = resolveDuckdbBin(); - if (!bin) {return;} - try { - execSync( - `'${bin}' '${dbFile}' 'ALTER TABLE objects ADD COLUMN IF NOT EXISTS display_field VARCHAR'`, - { encoding: "utf-8", timeout: 5_000, shell: "/bin/sh" }, - ); - } catch { - // migration might fail on DBs that don't have the objects table — skip - } - migratedDbs.add(dbFile); +async function ensureDisplayFieldColumn(dbFile: string): Promise { + const existing = migratedDbs.get(dbFile); + if (existing) {return existing;} + const promise = duckdbExecOnFileAsync( + dbFile, + "ALTER TABLE objects ADD COLUMN IF NOT EXISTS display_field VARCHAR", + ).then(() => undefined); + migratedDbs.set(dbFile, promise); + return promise; } // --- Helpers --- /** Scoped query helper: queries a specific DB file. */ -function q>(dbFile: string, sql: string): T[] { - return duckdbQueryOnFile(dbFile, sql); +async function q>(dbFile: string, sql: string): Promise { + return duckdbQueryOnFileAsync(dbFile, sql); } /** @@ -141,14 +145,14 @@ function resolveDisplayField( * Resolve relation field values to human-readable display labels. * All queries target the same DB file where the object lives. */ -function resolveRelationLabels( +async function resolveRelationLabels( dbFile: string, fields: FieldRow[], entries: Record[], -): { +): Promise<{ labels: Record>; relatedObjectNames: Record; -} { +}> { const labels: Record> = {}; const relatedObjectNames: Record = {}; @@ -157,14 +161,14 @@ function resolveRelationLabels( ); for (const rf of relationFields) { - const relatedObjs = q(dbFile, + const relatedObjs = await q(dbFile, `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 relFields = q(dbFile, + const relFields = await q(dbFile, `SELECT * FROM fields WHERE object_id = '${sqlEscape(relObj.id)}' ORDER BY sort_order`, ); const displayFieldName = resolveDisplayField(relObj, relFields); @@ -196,7 +200,7 @@ function resolveRelationLabels( const idList = Array.from(entryIds) .map((id) => `'${sqlEscape(id)}'`) .join(","); - const displayRows = q<{ entry_id: string; value: string }>(dbFile, + const displayRows = await q<{ entry_id: string; value: string }>(dbFile, `SELECT e.id as entry_id, ef.value FROM entries e JOIN entry_fields ef ON ef.entry_id = e.id @@ -232,12 +236,12 @@ type ReverseRelation = { * Find reverse relations: other objects with relation fields pointing TO this object. * Searches across ALL discovered databases to catch cross-DB relations. */ -function findReverseRelations(objectId: string): ReverseRelation[] { - const dbPaths = discoverDuckDBPaths(); +async function findReverseRelations(objectId: string): Promise { + const dbPaths = await discoverDuckDBPathsAsync(); const result: ReverseRelation[] = []; for (const db of dbPaths) { - const reverseFields = q< + const reverseFields = await q< FieldRow & { source_object_id: string; source_object_name: string } >(db, `SELECT f.*, f.object_id as source_object_id, o.name as source_object_name @@ -248,17 +252,17 @@ function findReverseRelations(objectId: string): ReverseRelation[] { ); for (const rrf of reverseFields) { - const sourceObjs = q(db, + const sourceObjs = await q(db, `SELECT * FROM objects WHERE id = '${sqlEscape(rrf.source_object_id)}' LIMIT 1`, ); if (sourceObjs.length === 0) {continue;} - const sourceFields = q(db, + const sourceFields = await q(db, `SELECT * FROM fields WHERE object_id = '${sqlEscape(rrf.source_object_id)}' ORDER BY sort_order`, ); const displayFieldName = resolveDisplayField(sourceObjs[0], sourceFields); - const refRows = q<{ source_entry_id: string; target_value: string }>(db, + const refRows = await q<{ source_entry_id: string; target_value: string }>(db, `SELECT ef.entry_id as source_entry_id, ef.value as target_value FROM entry_fields ef WHERE ef.field_id = '${sqlEscape(rrf.id)}' @@ -270,7 +274,7 @@ function findReverseRelations(objectId: string): ReverseRelation[] { const sourceEntryIds = [...new Set(refRows.map((r) => r.source_entry_id))]; const idList = sourceEntryIds.map((id) => `'${sqlEscape(id)}'`).join(","); - const displayRows = q<{ entry_id: string; value: string }>(db, + const displayRows = await q<{ entry_id: string; value: string }>(db, `SELECT ef.entry_id, ef.value FROM entry_fields ef JOIN fields f ON f.id = ef.field_id @@ -333,10 +337,10 @@ export async function GET( } // Find which DuckDB file contains this object (searches all discovered DBs) - const dbFile = findDuckDBForObject(name); + const dbFile = await findDuckDBForObjectAsync(name); if (!dbFile) { // Fall back to primary DB check for a friendlier error message - if (!duckdbPath()) { + if (!await duckdbPathAsync()) { return Response.json( { error: "DuckDB database not found" }, { status: 404 }, @@ -349,10 +353,10 @@ export async function GET( } // Ensure display_field column exists on this specific DB - ensureDisplayFieldColumn(dbFile); + await ensureDisplayFieldColumn(dbFile); // All queries below target the specific DB that owns this object - const objects = q(dbFile, + const objects = await q(dbFile, `SELECT * FROM objects WHERE name = '${name}' LIMIT 1`, ); @@ -365,11 +369,15 @@ export async function GET( const obj = objects[0]; - const fields = q(dbFile, + // Keep same-DB schema reads sequential: parallel DuckDB CLI processes against + // one file can intermittently return empty results, which makes the object + // page oscillate between full and partial schemas during live refreshes. + const fields = await q( + dbFile, `SELECT * FROM fields WHERE object_id = '${obj.id}' ORDER BY sort_order`, ); - - const statuses = q(dbFile, + const statuses = await q( + dbFile, `SELECT * FROM statuses WHERE object_id = '${obj.id}' ORDER BY sort_order`, ); @@ -429,18 +437,18 @@ export async function GET( try { // Get total count with same WHERE clause but no LIMIT/OFFSET - const countResult = q<{ cnt: number }>(dbFile, + const countResult = await q<{ cnt: number }>(dbFile, `SELECT COUNT(*) as cnt FROM v_${name}${whereClause}`, ); totalCount = countResult[0]?.cnt ?? 0; - const pivotEntries = q(dbFile, + const pivotEntries = await q(dbFile, `SELECT * FROM v_${name}${whereClause}${orderByClause}${limitClause}`, ); entries = pivotEntries; } catch { // Pivot view might not exist or filter SQL may not apply; fall back - const rawRows = q(dbFile, + const rawRows = await q(dbFile, `SELECT e.id as entry_id, e.created_at, e.updated_at, f.name as field_name, ef.value FROM entries e @@ -460,7 +468,7 @@ export async function GET( })); const { labels: relationLabels, relatedObjectNames } = - resolveRelationLabels(dbFile, fields, entries); + await resolveRelationLabels(dbFile, fields, entries); const enrichedFields = parsedFields.map((f) => ({ ...f, @@ -468,7 +476,7 @@ export async function GET( f.type === "relation" ? relatedObjectNames[f.name] : undefined, })); - const reverseRelations = findReverseRelations(obj.id); + const reverseRelations = await findReverseRelations(obj.id); const effectiveDisplayField = resolveDisplayField(obj, fields); diff --git a/apps/web/app/api/workspace/query/route.ts b/apps/web/app/api/workspace/query/route.ts index 2df1dcc2962..a50ebc43d0b 100644 --- a/apps/web/app/api/workspace/query/route.ts +++ b/apps/web/app/api/workspace/query/route.ts @@ -1,4 +1,4 @@ -import { duckdbQuery } from "@/lib/workspace"; +import { duckdbQueryAsync } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -38,6 +38,6 @@ export async function POST(req: Request) { ); } - const rows = duckdbQuery(sql); + const rows = await duckdbQueryAsync(sql); return Response.json({ rows }); } diff --git a/apps/web/app/api/workspace/reports/execute/route.ts b/apps/web/app/api/workspace/reports/execute/route.ts index 3ba6a2d9f82..47518e19122 100644 --- a/apps/web/app/api/workspace/reports/execute/route.ts +++ b/apps/web/app/api/workspace/reports/execute/route.ts @@ -1,4 +1,4 @@ -import { duckdbQuery } from "@/lib/workspace"; +import { duckdbQueryAsync } from "@/lib/workspace"; import { buildFilterClauses, injectFilters, checkSqlSafety } from "@/lib/report-filters"; import type { FilterEntry } from "@/lib/report-filters"; import { trackServer } from "@/lib/telemetry"; @@ -44,7 +44,7 @@ export async function POST(req: Request) { const finalSql = injectFilters(sql, filterClauses); try { - const rows = duckdbQuery(finalSql); + const rows = await duckdbQueryAsync(finalSql); trackServer("report_executed"); return Response.json({ rows, sql: finalSql }); } catch (err) { diff --git a/apps/web/app/api/workspace/tree-browse.test.ts b/apps/web/app/api/workspace/tree-browse.test.ts index d62e396d956..86c34803203 100644 --- a/apps/web/app/api/workspace/tree-browse.test.ts +++ b/apps/web/app/api/workspace/tree-browse.test.ts @@ -9,6 +9,15 @@ vi.mock("node:fs", () => ({ statSync: vi.fn(() => ({ isDirectory: () => false, size: 100 })), })); +vi.mock("node:fs/promises", () => ({ + readdir: vi.fn(async () => []), + readFile: vi.fn(async () => ""), + access: vi.fn(async () => { + throw new Error("ENOENT"); + }), + stat: vi.fn(async () => ({ isDirectory: () => false, isFile: () => false })), +})); + // Mock node:os vi.mock("node:os", () => ({ homedir: vi.fn(() => "/home/testuser"), @@ -52,6 +61,14 @@ describe("Workspace Tree & Browse API", () => { existsSync: vi.fn(() => false), statSync: vi.fn(() => ({ isDirectory: () => false, size: 100 })), })); + vi.mock("node:fs/promises", () => ({ + readdir: vi.fn(async () => []), + readFile: vi.fn(async () => ""), + access: vi.fn(async () => { + throw new Error("ENOENT"); + }), + stat: vi.fn(async () => ({ isDirectory: () => false, isFile: () => false })), + })); vi.mock("node:os", () => ({ homedir: vi.fn(() => "/home/testuser"), })); @@ -90,16 +107,15 @@ describe("Workspace Tree & Browse API", () => { const { resolveWorkspaceRoot, getActiveWorkspaceName } = await import("@/lib/workspace"); vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws"); vi.mocked(getActiveWorkspaceName).mockReturnValue("default"); - const { readdirSync: mockReaddir, existsSync: mockExists } = await import("node:fs"); - vi.mocked(mockExists).mockReturnValue(true); + const { readdir: mockReaddir } = await import("node:fs/promises"); vi.mocked(mockReaddir).mockImplementation((dir) => { if (String(dir) === "/ws") { - return [ + return Promise.resolve([ makeDirent("knowledge", true), makeDirent("readme.md", false), - ] as unknown as Dirent[]; + ] as unknown as Dirent[]); } - return [] as unknown as Dirent[]; + return Promise.resolve([] as unknown as Dirent[]); }); const { GET } = await import("./tree/route.js"); @@ -114,8 +130,6 @@ describe("Workspace Tree & Browse API", () => { it("includes workspaceRoot in response", async () => { const { resolveWorkspaceRoot } = await import("@/lib/workspace"); vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws"); - const { existsSync: mockExists } = await import("node:fs"); - vi.mocked(mockExists).mockReturnValue(true); const { GET } = await import("./tree/route.js"); const req = new Request("http://localhost/api/workspace/tree"); @@ -127,16 +141,15 @@ describe("Workspace Tree & Browse API", () => { it("includes root IDENTITY.md in the workspace tree", async () => { const { resolveWorkspaceRoot } = await import("@/lib/workspace"); vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws"); - const { readdirSync: mockReaddir, existsSync: mockExists } = await import("node:fs"); - vi.mocked(mockExists).mockImplementation((p) => String(p) === "/ws"); + const { readdir: mockReaddir } = await import("node:fs/promises"); vi.mocked(mockReaddir).mockImplementation((dir) => { if (String(dir) === "/ws") { - return [ + return Promise.resolve([ makeDirent("IDENTITY.md", false), makeDirent("notes.md", false), - ] as unknown as Dirent[]; + ] as unknown as Dirent[]); } - return [] as unknown as Dirent[]; + return Promise.resolve([] as unknown as Dirent[]); }); const { GET } = await import("./tree/route.js"); @@ -151,27 +164,30 @@ describe("Workspace Tree & Browse API", () => { it("omits managed crm skill from the virtual skills folder", async () => { const { resolveWorkspaceRoot } = await import("@/lib/workspace"); vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws"); - const { readdirSync: mockReaddir, existsSync: mockExists } = await import("node:fs"); - vi.mocked(mockExists).mockImplementation((p) => { + const { readdir: mockReaddir, access: mockAccess } = await import("node:fs/promises"); + vi.mocked(mockAccess).mockImplementation(async (p) => { const value = String(p); - return ( + if ( value === "/ws" || value === "/ws/skills" || value === "/ws/skills/alpha/SKILL.md" || value === "/ws/skills/crm/SKILL.md" - ); + ) { + return; + } + throw new Error("ENOENT"); }); vi.mocked(mockReaddir).mockImplementation((dir) => { if (String(dir) === "/ws") { - return [] as unknown as Dirent[]; + return Promise.resolve([] as unknown as Dirent[]); } if (String(dir) === "/ws/skills") { - return [ + return Promise.resolve([ makeDirent("alpha", true), makeDirent("crm", true), - ] as unknown as Dirent[]; + ] as unknown as Dirent[]); } - return [] as unknown as Dirent[]; + return Promise.resolve([] as unknown as Dirent[]); }); const { GET } = await import("./tree/route.js"); @@ -185,6 +201,41 @@ describe("Workspace Tree & Browse API", () => { expect(skillPaths).toContain("~skills/alpha/SKILL.md"); expect(skillPaths).not.toContain("~skills/crm/SKILL.md"); }); + + it("yields before tree discovery completes (prevents UI freeze during active agent runs)", async () => { + const { resolveWorkspaceRoot, duckdbQueryAll, duckdbQueryAllAsync } = await import("@/lib/workspace"); + vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws"); + vi.mocked(duckdbQueryAll).mockImplementation(() => { + const start = Date.now(); + while (Date.now() - start < 75) { + // busy wait: if the route ever regresses to the sync helper, + // this test should fail on the elapsed-time assertion below. + } + return []; + }); + + let releaseDuckdb: (rows: Array<{ name: string }>) => void; + const duckdbGate = new Promise>((resolve) => { + releaseDuckdb = resolve; + }); + vi.mocked(duckdbQueryAllAsync).mockReturnValue(duckdbGate); + + const { readdir: mockReaddir } = await import("node:fs/promises"); + vi.mocked(mockReaddir).mockResolvedValue([] as unknown as Dirent[]); + + const { GET } = await import("./tree/route.js"); + const req = new Request("http://localhost/api/workspace/tree"); + + const startedAt = Date.now(); + const responsePromise = GET(req); + const elapsedMs = Date.now() - startedAt; + + expect(elapsedMs).toBeLessThan(40); + + releaseDuckdb!([]); + const res = await responsePromise; + expect(res.status).toBe(200); + }); }); // ─── GET /api/workspace/browse ────────────────────────────────── diff --git a/apps/web/app/api/workspace/tree/route.ts b/apps/web/app/api/workspace/tree/route.ts index 414247ccb61..d05187f594c 100644 --- a/apps/web/app/api/workspace/tree/route.ts +++ b/apps/web/app/api/workspace/tree/route.ts @@ -1,6 +1,14 @@ -import { readdirSync, readFileSync, existsSync, statSync, type Dirent } from "node:fs"; +import type { Dirent } from "node:fs"; +import { access, readdir, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; -import { resolveWorkspaceRoot, resolveOpenClawStateDir, getActiveWorkspaceName, parseSimpleYaml, duckdbQueryAll, isDatabaseFile } from "@/lib/workspace"; +import { + resolveWorkspaceRoot, + resolveOpenClawStateDir, + getActiveWorkspaceName, + parseSimpleYaml, + duckdbQueryAllAsync, + isDatabaseFile, +} from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -25,14 +33,24 @@ type DbObject = { }; /** Read .object.yaml metadata from a directory if it exists. */ -function readObjectMeta( +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +/** Read .object.yaml metadata from a directory if it exists. */ +async function readObjectMeta( dirPath: string, -): { icon?: string; defaultView?: string } | null { +): Promise<{ icon?: string; defaultView?: string } | null> { const yamlPath = join(dirPath, ".object.yaml"); - if (!existsSync(yamlPath)) {return null;} + if (!await pathExists(yamlPath)) {return null;} try { - const content = readFileSync(yamlPath, "utf-8"); + const content = await readFile(yamlPath, "utf-8"); const parsed = parseSimpleYaml(content); return { icon: parsed.icon as string | undefined, @@ -48,9 +66,9 @@ function readObjectMeta( * directories even when .object.yaml files are missing. * Shallower databases win on name conflicts (parent priority). */ -function loadDbObjects(): Map { +async function loadDbObjects(): Promise> { const map = new Map(); - const rows = duckdbQueryAll( + const rows = await duckdbQueryAllAsync( "SELECT name, icon, default_view FROM objects", "name", ); @@ -61,12 +79,15 @@ function loadDbObjects(): Map { } /** Resolve a dirent's effective type, following symlinks to their target. */ -function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" | null { +async function resolveEntryType( + entry: Dirent, + absPath: string, +): Promise<"directory" | "file" | null> { if (entry.isDirectory()) {return "directory";} if (entry.isFile()) {return "file";} if (entry.isSymbolicLink()) { try { - const st = statSync(absPath); + const st = await stat(absPath); if (st.isDirectory()) {return "directory";} if (st.isFile()) {return "file";} } catch { @@ -77,17 +98,17 @@ function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" } /** Recursively build a tree from a workspace directory. */ -function buildTree( +async function buildTree( absDir: string, relativeBase: string, dbObjects: Map, showHidden = false, -): TreeNode[] { +): Promise { const nodes: TreeNode[] = []; let entries: Dirent[]; try { - entries = readdirSync(absDir, { withFileTypes: true }); + entries = await readdir(absDir, { withFileTypes: true }); } catch { return nodes; } @@ -100,33 +121,33 @@ function buildTree( }); // Sort: directories first, then files, alphabetical within each group - const sorted = filtered.toSorted((a, b) => { - const absA = join(absDir, a.name); - const absB = join(absDir, b.name); - const typeA = resolveEntryType(a, absA); - const typeB = resolveEntryType(b, absB); - const dirA = typeA === "directory"; - const dirB = typeB === "directory"; + const typedEntries = await Promise.all(filtered.map(async (entry) => { + const absPath = join(absDir, entry.name); + const effectiveType = await resolveEntryType(entry, absPath); + return { entry, absPath, effectiveType }; + })); + + const sorted = typedEntries.toSorted((a, b) => { + const dirA = a.effectiveType === "directory"; + const dirB = b.effectiveType === "directory"; if (dirA && !dirB) {return -1;} if (!dirA && dirB) {return 1;} - return a.name.localeCompare(b.name); + return a.entry.name.localeCompare(b.entry.name); }); - for (const entry of sorted) { + for (const { entry, absPath, effectiveType } of sorted) { // .object.yaml is consumed for metadata; only show it as a visible node when revealing hidden files if (entry.name === ".object.yaml" && !showHidden) {continue;} - const absPath = join(absDir, entry.name); const relPath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name; const isSymlink = entry.isSymbolicLink(); - const effectiveType = resolveEntryType(entry, absPath); if (effectiveType === "directory") { - const objectMeta = readObjectMeta(absPath); + const objectMeta = await readObjectMeta(absPath); const dbObject = dbObjects.get(entry.name); - const children = buildTree(absPath, relPath, dbObjects, showHidden); + const children = await buildTree(absPath, relPath, dbObjects, showHidden); if (objectMeta || dbObject) { nodes.push({ @@ -184,7 +205,7 @@ function parseSkillFrontmatter(content: string): { name?: string; emoji?: string } /** Build a virtual "Skills" folder from /skills/. */ -function buildSkillsVirtualFolder(): TreeNode | null { +async function buildSkillsVirtualFolder(): Promise { const workspaceRoot = resolveWorkspaceRoot(); if (!workspaceRoot) { return null; @@ -195,19 +216,19 @@ function buildSkillsVirtualFolder(): TreeNode | null { const seen = new Set(); for (const dir of dirs) { - if (!existsSync(dir)) {continue;} + if (!await pathExists(dir)) {continue;} try { - const entries = readdirSync(dir, { withFileTypes: true }); + const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory() || seen.has(entry.name)) {continue;} if (entry.name === "crm" || entry.name === "browser") {continue;} const skillMdPath = join(dir, entry.name, "SKILL.md"); - if (!existsSync(skillMdPath)) {continue;} + if (!await pathExists(skillMdPath)) {continue;} seen.add(entry.name); let displayName = entry.name; try { - const content = readFileSync(skillMdPath, "utf-8"); + const content = await readFile(skillMdPath, "utf-8"); const meta = parseSkillFrontmatter(content); if (meta.name) {displayName = meta.name;} if (meta.emoji) {displayName = `${meta.emoji} ${displayName}`;} @@ -249,16 +270,16 @@ export async function GET(req: Request) { const root = resolveWorkspaceRoot(); if (!root) { const tree: TreeNode[] = []; - const skillsFolder = buildSkillsVirtualFolder(); + const skillsFolder = await buildSkillsVirtualFolder(); if (skillsFolder) {tree.push(skillsFolder);} return Response.json({ tree, exists: false, workspaceRoot: null, openclawDir, workspace }); } - const dbObjects = loadDbObjects(); + const dbObjects = await loadDbObjects(); - const tree = buildTree(root, "", dbObjects, showHidden); + const tree = await buildTree(root, "", dbObjects, showHidden); - const skillsFolder = buildSkillsVirtualFolder(); + const skillsFolder = await buildSkillsVirtualFolder(); if (skillsFolder) {tree.push(skillsFolder);} return Response.json({ tree, exists: true, workspaceRoot: root, openclawDir, workspace }); diff --git a/apps/web/app/components/chat-message.tsx b/apps/web/app/components/chat-message.tsx index c732b49561e..e5aba40b421 100644 --- a/apps/web/app/components/chat-message.tsx +++ b/apps/web/app/components/chat-message.tsx @@ -757,7 +757,7 @@ function FeedbackButtons({ messageId, sessionId }: { messageId: string; sessionI /* ─── Chat message ─── */ -export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onSubagentClick, onFilePathClick, sessionId }: { message: UIMessage; isStreaming?: boolean; onSubagentClick?: (task: string) => void; onFilePathClick?: FilePathClickHandler; sessionId?: string | null }) { +export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onSubagentClick, onFilePathClick, sessionId, userHtmlMap }: { message: UIMessage; isStreaming?: boolean; onSubagentClick?: (task: string) => void; onFilePathClick?: FilePathClickHandler; sessionId?: string | null; userHtmlMap?: Map }) { const isUser = message.role === "user"; const segments = groupParts(message.parts); const markdownComponents = useMemo( @@ -766,7 +766,6 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS ); if (isUser) { - // User: right-aligned subtle pill const textContent = segments .filter( (s): s is { type: "text"; text: string } => @@ -775,16 +774,18 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS .map((s) => s.text) .join("\n"); - // Parse attachment prefix from sent messages const attachmentInfo = parseAttachments(textContent); + const richHtml = userHtmlMap?.get(message.id) ?? userHtmlMap?.get(textContent) ?? userHtmlMap?.get(attachmentInfo?.message ?? ""); + + const bubbleContent = richHtml + ?
+ :

{attachmentInfo?.message ?? textContent}

; if (attachmentInfo) { return (
- {/* Attachment previews — standalone above the text bubble */} - {/* Text bubble */} - {attachmentInfo.message && ( + {(attachmentInfo.message || richHtml) && (
-

- {attachmentInfo.message} -

+ {bubbleContent}
)}
@@ -810,9 +809,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS color: "var(--color-user-bubble-text)", }} > -

- {textContent} -

+ {bubbleContent}
); diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx index abfe2772cfb..011fae2552a 100644 --- a/apps/web/app/components/chat-panel.tsx +++ b/apps/web/app/components/chat-panel.tsx @@ -765,6 +765,7 @@ type FileScopedSession = { type QueuedMessage = { id: string; text: string; + html: string; mentionedFiles: Array<{ name: string; path: string }>; attachedFiles: AttachedFile[]; createdAt: number; @@ -856,6 +857,9 @@ export const ChatPanel = forwardRef( const [isReconnecting, setIsReconnecting] = useState(false); const reconnectAbortRef = useRef(null); + // ── Stream-level error (empty response detection) ── + const [streamError, setStreamError] = useState(null); + // Track persisted messages to avoid double-saves const savedMessageIdsRef = useRef>(new Set()); // Set when /new or + triggers a new session @@ -868,6 +872,10 @@ export const ChatPanel = forwardRef( FileScopedSession[] >([]); + // ── Rich HTML for user messages (keyed by message ID or text fallback) ── + const userHtmlMapRef = useRef(new Map()); + const pendingHtmlRef = useRef(null); + // ── Message queue (messages to send after current run completes) ── const [queuedMessages, setQueuedMessages] = useState([]); const [rawView, _setRawView] = useState(false); @@ -926,10 +934,16 @@ export const ChatPanel = forwardRef( new DefaultChatTransport({ api: "/api/chat", body: () => { + const extra: Record = {}; const sk = subagentSessionKeyRef.current; - if (sk) {return { sessionKey: sk };} + if (sk) {extra.sessionKey = sk;} const sid = sessionIdRef.current; - return sid ? { sessionId: sid } : {}; + if (sid) {extra.sessionId = sid;} + if (pendingHtmlRef.current) { + extra.userHtml = pendingHtmlRef.current; + pendingHtmlRef.current = null; + } + return extra; }, }), [], @@ -1195,11 +1209,10 @@ export const ChatPanel = forwardRef( role: "user" | "assistant"; content: string; parts?: Array>; + html?: string; _streaming?: boolean; }> = msgData.messages || []; - // Filter out in-progress streaming messages - // (will be rebuilt from the live SSE stream) const hasStreaming = sessionMessages.some( (m) => m._streaming, ); @@ -1209,6 +1222,12 @@ export const ChatPanel = forwardRef( ) : sessionMessages; + for (const msg of completedMessages) { + if (msg.role === "user" && msg.html) { + userHtmlMapRef.current.set(msg.id, msg.html); + } + } + const uiMessages = completedMessages.map( (msg) => { savedMessageIdsRef.current.add(msg.id); @@ -1224,9 +1243,9 @@ export const ChatPanel = forwardRef( }; }, ); - if (!cancelled) { - setMessages(uiMessages); - } + if (!cancelled) { + setMessages(uiMessages); + } if (!cancelled) { await attemptReconnect( @@ -1292,6 +1311,7 @@ export const ChatPanel = forwardRef( role: "user" | "assistant"; content: string; parts?: Array>; + html?: string; _streaming?: boolean; }> = msgData.messages || []; @@ -1300,6 +1320,11 @@ export const ChatPanel = forwardRef( : sessionMessages; if (completedMessages.length > 0) { + for (const msg of completedMessages) { + if (msg.role === "user" && msg.html) { + userHtmlMapRef.current.set(msg.id, msg.html); + } + } const uiMessages = completedMessages.map((msg) => { savedMessageIdsRef.current.add(msg.id); return { @@ -1313,7 +1338,6 @@ export const ChatPanel = forwardRef( setMessages(baseMessages); } } - } else { // No persisted session file — use task message only } @@ -1427,6 +1451,36 @@ export const ChatPanel = forwardRef( onSessionsChange, ]); + // ── Empty-stream error detection ── + // When the stream completes (submitted/streaming → ready) but no + // assistant message was produced, surface an error so the user knows + // the request was lost. + useEffect(() => { + const wasActive = + prevStatusRef.current === "streaming" || + prevStatusRef.current === "submitted"; + const isNowReady = status === "ready"; + + if (wasActive && isNowReady) { + const lastMsg = messages[messages.length - 1]; + const hasAssistantContent = + lastMsg?.role === "assistant" && + lastMsg.parts.some( + (p) => + (p.type === "text" && (p as { text: string }).text.trim().length > 0) || + p.type === "tool-invocation", + ); + if (!hasAssistantContent && !error) { + setStreamError("No response received from agent."); + } else { + setStreamError(null); + } + } + if (status === "submitted") { + setStreamError(null); + } + }, [status, messages, error]); + // ── Actions ── // Ref for handleNewSession so handleEditorSubmit doesn't depend on the hook order @@ -1438,6 +1492,7 @@ export const ChatPanel = forwardRef( async ( text: string, mentionedFiles: Array<{ name: string; path: string }>, + html: string, overrideAttachments?: AttachedFile[], ) => { const hasText = text.trim().length > 0; @@ -1466,7 +1521,6 @@ export const ChatPanel = forwardRef( // Queue the message if the agent is still running. if (isStreaming) { - // Clear attachment strip but keep blob URLs alive for queue thumbnails if (!overrideAttachments) { setAttachedFiles([]); } @@ -1475,6 +1529,7 @@ export const ChatPanel = forwardRef( { id: crypto.randomUUID(), text: userText, + html, mentionedFiles, attachedFiles: currentAttachments, createdAt: Date.now(), @@ -1505,13 +1560,13 @@ export const ChatPanel = forwardRef( onActiveSessionChange?.(sessionId); onSessionsChange?.(); - if (filePath) { - void fetchFileSessionsRef.current?.().then( - (sessions) => { - setFileSessions(sessions); - }, - ); - } + if (filePath) { + void fetchFileSessionsRef.current?.().then( + (sessions) => { + setFileSessions(sessions); + }, + ); + } } // Build message with optional attachment prefix @@ -1535,10 +1590,13 @@ export const ChatPanel = forwardRef( isFirstFileMessageRef.current = false; } - // Reset scroll lock so we auto-scroll to the new user message - userScrolledAwayRef.current = false; - void sendMessage({ text: messageText }); - }, + // Store HTML for display and pipe to server via transport + userHtmlMapRef.current.set(messageText, html); + pendingHtmlRef.current = html; + + userScrolledAwayRef.current = false; + void sendMessage({ text: messageText }); + }, [ attachedFiles, isStreaming, @@ -1570,7 +1628,7 @@ export const ChatPanel = forwardRef( } // Use a microtask so React can settle the status update first. queueMicrotask(() => { - void handleEditorSubmit(next.text, next.mentionedFiles, next.attachedFiles); + void handleEditorSubmit(next.text, next.mentionedFiles, next.html, next.attachedFiles); }); } }, [status, queuedMessages, handleEditorSubmit]); @@ -1607,6 +1665,7 @@ export const ChatPanel = forwardRef( role: "user" | "assistant"; content: string; parts?: Array>; + html?: string; _streaming?: boolean; }> = data.messages || []; @@ -1619,39 +1678,46 @@ export const ChatPanel = forwardRef( ) : sessionMessages; - const uiMessages = completedMessages.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"], - }; - }, - ); + userHtmlMapRef.current.clear(); + for (const msg of completedMessages) { + if (msg.role === "user" && msg.html) { + userHtmlMapRef.current.set(msg.id, msg.html); + } + } - setMessages(uiMessages); + const uiMessages = completedMessages.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"], + }; + }, + ); - // Clear loading state *before* reconnecting — the - // persisted messages are now visible. attemptReconnect - // manages its own `isReconnecting` state which shows - // "Resuming stream..." instead of "Loading session...". - setLoadingSession(false); + setMessages(uiMessages); - // Always try to reconnect -- the stream endpoint - // returns 404 gracefully if no active run exists, - // and this avoids missing runs whose _streaming - // flag hasn't been persisted yet. - await attemptReconnect(sessionId, uiMessages); - } catch (err) { - console.error("Error loading session:", err); - setLoadingSession(false); - } + // Clear loading state *before* reconnecting — the + // persisted messages are now visible. attemptReconnect + // manages its own `isReconnecting` state which shows + // "Resuming stream..." instead of "Loading session...". + setLoadingSession(false); + + // Always try to reconnect -- the stream endpoint + // returns 404 gracefully if no active run exists, + // and this avoids missing runs whose _streaming + // flag hasn't been persisted yet. + await attemptReconnect(sessionId, uiMessages); + } catch (err) { + console.error("Error loading session:", err); + setLoadingSession(false); + } }, [ currentSessionId, @@ -1662,19 +1728,19 @@ export const ChatPanel = forwardRef( ], ); - const handleNewSession = useCallback(() => { - reconnectAbortRef.current?.abort(); - void stop(); + const handleNewSession = useCallback(() => { + reconnectAbortRef.current?.abort(); + void stop(); setIsReconnecting(false); setCurrentSessionId(null); sessionIdRef.current = null; onActiveSessionChange?.(null); setMessages([]); savedMessageIdsRef.current.clear(); + userHtmlMapRef.current.clear(); isFirstFileMessageRef.current = true; newSessionPendingRef.current = false; setQueuedMessages([]); - // Focus the chat input after state updates so "New Chat" is ready to type. requestAnimationFrame(() => { editorRef.current?.focus(); }); @@ -1758,7 +1824,7 @@ export const ChatPanel = forwardRef( await handleStop(); // Submit the message after a short delay to let status settle. setTimeout(() => { - void handleEditorSubmit(msg.text, msg.mentionedFiles, msg.attachedFiles); + void handleEditorSubmit(msg.text, msg.mentionedFiles, msg.html, msg.attachedFiles); }, 100); }, [queuedMessages, handleStop, handleEditorSubmit], @@ -2403,6 +2469,7 @@ export const ChatPanel = forwardRef( onSubagentClick={onSubagentClick} onFilePathClick={onFilePathClick} sessionId={currentSessionId} + userHtmlMap={userHtmlMapRef.current} /> ))} {showInlineSpinner && ( @@ -2419,8 +2486,8 @@ export const ChatPanel = forwardRef( )} - {/* Transport-level error display */} - {error && ( + {/* Transport / stream-level error display */} + {(error || streamError) && (
( y2="16" /> -

{error.message}

+

{error?.message ?? streamError}

)} diff --git a/apps/web/app/components/tiptap/chat-editor.tsx b/apps/web/app/components/tiptap/chat-editor.tsx index 81fd0fd5f5d..4903082fa11 100644 --- a/apps/web/app/components/tiptap/chat-editor.tsx +++ b/apps/web/app/components/tiptap/chat-editor.tsx @@ -37,7 +37,7 @@ export type ChatEditorHandle = { type ChatEditorProps = { /** Called when user presses Enter (without Shift). */ - onSubmit: (text: string, mentionedFiles: Array<{ name: string; path: string }>) => void; + onSubmit: (text: string, mentionedFiles: Array<{ name: string; path: string }>, html: string) => void; /** Called on every content change. */ onChange?: (isEmpty: boolean) => void; /** Called when native files (e.g. from Finder/Desktop) are dropped onto the editor. */ @@ -102,39 +102,40 @@ function serializeContent(editor: ReturnType): { if (!editor) {return { text: "", mentionedFiles: [] };} const mentionedFiles: Array<{ name: string; path: string }> = []; - const parts: string[] = []; + const lines: string[] = []; - editor.state.doc.descendants((node) => { - if (node.type.name === "chatFileMention") { - const label = node.attrs.label as string; - const path = node.attrs.path as string; - const mType = node.attrs.mentionType as string; - const objectName = node.attrs.objectName as string; - - mentionedFiles.push({ name: label, path }); - - if (mType === "object") { - parts.push(`[object: ${label}]`); - } else if (mType === "entry") { - parts.push(`[entry: ${objectName ? `${objectName}/` : ""}${label}]`); - } else { - parts.push(`[file: ${path}]`); - } - return false; + editor.state.doc.forEach((node) => { + if (node.type.name === "paragraph" || node.type.name === "hardBreak") { + let lineText = ""; + node.descendants((child) => { + if (child.type.name === "chatFileMention") { + const label = child.attrs.label as string; + const path = child.attrs.path as string; + const mType = child.attrs.mentionType as string; + const objectName = child.attrs.objectName as string; + mentionedFiles.push({ name: label, path }); + if (mType === "object") { + lineText += `[object: ${label}]`; + } else if (mType === "entry") { + lineText += `[entry: ${objectName ? `${objectName}/` : ""}${label}]`; + } else { + lineText += `[file: ${path}]`; + } + return false; + } + if (child.isText && child.text) { + lineText += child.text; + } + if (child.type.name === "hardBreak") { + lineText += "\n"; + } + return true; + }); + lines.push(lineText); } - if (node.isText && node.text) { - parts.push(node.text); - } - if (node.type.name === "paragraph" && parts.length > 0) { - const lastPart = parts[parts.length - 1]; - if (lastPart !== undefined && lastPart !== "\n") { - parts.push("\n"); - } - } - return true; }); - return { text: parts.join("").trim(), mentionedFiles }; + return { text: lines.join("\n").trim(), mentionedFiles }; } // ── File mention suggestion extension (wired to the async popup) ── @@ -359,14 +360,14 @@ export const ChatEditor = forwardRef( const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { - // Check if suggestion popup is active by checking if the plugin has active state const suggestState = chatFileMentionPluginKey.getState(editor.state); - if (suggestState?.active) {return;} // Let suggestion handle it + if (suggestState?.active) {return;} event.preventDefault(); const { text, mentionedFiles } = serializeContent(editor); if (text.trim() || mentionedFiles.length > 0) { - submitRef.current(text, mentionedFiles); + const html = editor.getHTML(); + submitRef.current(text, mentionedFiles, html); editor.commands.clearContent(true); } } @@ -411,7 +412,8 @@ export const ChatEditor = forwardRef( if (!editor) {return;} const { text, mentionedFiles } = serializeContent(editor); if (text.trim() || mentionedFiles.length > 0) { - submitRef.current(text, mentionedFiles); + const html = editor.getHTML(); + submitRef.current(text, mentionedFiles, html); editor.commands.clearContent(true); } }, diff --git a/apps/web/app/components/workspace/entry-detail-modal.tsx b/apps/web/app/components/workspace/entry-detail-modal.tsx index 60708e4bc1e..49a5e40e835 100644 --- a/apps/web/app/components/workspace/entry-detail-modal.tsx +++ b/apps/web/app/components/workspace/entry-detail-modal.tsx @@ -3,6 +3,8 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { RelationSelect } from "./relation-select"; import { FormattedFieldValue } from "./formatted-field-value"; +import { formatWorkspaceFieldValue } from "@/lib/workspace-cell-format"; +import { parseTagsValue } from "@/lib/parse-tags"; function safeString(val: unknown): string { @@ -211,6 +213,116 @@ function RelationChips({ ); } +function TagsBadges({ value }: { value: unknown }) { + const tags = parseTagsValue(value); + if (tags.length === 0) {return ;} + const chipStyle = { background: "rgba(148, 163, 184, 0.12)", border: "1px solid var(--color-border)" }; + return ( + + {tags.map((tag) => { + const formatted = formatWorkspaceFieldValue(tag); + const isLink = formatted.kind === "link" && formatted.href; + if (isLink) { + return ( + e.stopPropagation()} + className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium underline-offset-2 hover:underline" + style={{ ...chipStyle, color: "var(--color-accent)" }} + > + {formatted.text} + + ); + } + return ( + + {tag} + + ); + })} + + ); +} + +function TagsEditInput({ + value, + onChange, + autoFocus, +}: { + value: string; + onChange: (val: string) => void; + autoFocus?: boolean; +}) { + const tags = parseTagsValue(value); + const [inputVal, setInputVal] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + if (autoFocus && inputRef.current) {inputRef.current.focus();} + }, [autoFocus]); + + const addTag = (tag: string) => { + const t = tag.trim(); + if (!t || tags.includes(t)) {return;} + const next = [...tags, t]; + onChange(JSON.stringify(next)); + setInputVal(""); + }; + + const removeTag = (tag: string) => { + const next = tags.filter((t) => t !== tag); + onChange(next.length > 0 ? JSON.stringify(next) : ""); + }; + + return ( +
+ {tags.map((tag) => ( + + {tag} + + + ))} + setInputVal(e.target.value)} + onKeyDown={(e) => { + if ((e.key === "Enter" || e.key === ",") && inputVal.trim()) { + e.preventDefault(); + addTag(inputVal); + } + if (e.key === "Backspace" && !inputVal && tags.length > 0) { + removeTag(tags[tags.length - 1]); + } + }} + onBlur={() => { if (inputVal.trim()) {addTag(inputVal);} }} + placeholder={tags.length === 0 ? "Type and press Enter..." : ""} + className="flex-1 min-w-[80px] text-sm outline-none bg-transparent" + style={{ color: "var(--color-text)" }} + /> +
+ ); +} + function EmptyValue() { return ( -- @@ -311,6 +423,8 @@ function FieldValue({ onNavigateEntry={onNavigateEntry} /> ); + case "tags": + return ; case "email": case "number": case "date": @@ -319,7 +433,7 @@ function FieldValue({ case "file": return ; case "richtext": - return {safeString(value)}; + return ; default: return ; } @@ -556,7 +670,20 @@ export function EntryDetailModal({ style={{ color: "var(--color-text)" }} > {editingField === field.name ? ( - field.type === "relation" && field.related_object_name ? ( + field.type === "tags" ? ( +
+
+ { void handleSaveField(field.name, v); }} + autoFocus + /> +
+ +
+ ) : field.type === "relation" && field.related_object_name ? (
(null); const containerRef = useRef(null); + const dragExpandTimerRef = useRef | null>(null); + const currentDragOverRef = useRef(null); // Persist expanded paths to localStorage whenever they change useEffect(() => { @@ -820,22 +822,32 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact const handleDragOver = useCallback((event: DragOverEvent) => { const overData = event.over?.data.current as { node?: TreeNode; rootDrop?: boolean } | undefined; if (overData?.rootDrop) { + if (currentDragOverRef.current !== "__root__") { + if (dragExpandTimerRef.current) clearTimeout(dragExpandTimerRef.current); + currentDragOverRef.current = "__root__"; + } setDragOverPath("__root__"); } else if (overData?.node) { - setDragOverPath(overData.node.path); - // Auto-expand folders on drag hover (300ms delay) const path = overData.node.path; - if (overData.node.type === "folder" || overData.node.type === "object") { - setTimeout(() => { - setExpandedPaths((prev) => { - if (prev.has(path)) {return prev;} - const next = new Set(prev); - next.add(path); - return next; - }); - }, 300); + setDragOverPath(path); + if (currentDragOverRef.current !== path) { + if (dragExpandTimerRef.current) clearTimeout(dragExpandTimerRef.current); + currentDragOverRef.current = path; + if (overData.node.type === "folder" || overData.node.type === "object") { + dragExpandTimerRef.current = setTimeout(() => { + if (currentDragOverRef.current !== path) return; + setExpandedPaths((prev) => { + if (prev.has(path)) return prev; + const next = new Set(prev); + next.add(path); + return next; + }); + }, 300); + } } } else { + if (dragExpandTimerRef.current) clearTimeout(dragExpandTimerRef.current); + currentDragOverRef.current = null; setDragOverPath(null); } }, []); @@ -844,6 +856,8 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact async (event: DragEndEvent) => { setActiveNode(null); setDragOverPath(null); + if (dragExpandTimerRef.current) clearTimeout(dragExpandTimerRef.current); + currentDragOverRef.current = null; removePointerTracker(); const activeData = event.active.data.current as { node: TreeNode } | undefined; @@ -901,6 +915,8 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact const handleDragCancel = useCallback(() => { setActiveNode(null); setDragOverPath(null); + if (dragExpandTimerRef.current) clearTimeout(dragExpandTimerRef.current); + currentDragOverRef.current = null; removePointerTracker(); }, [removePointerTracker]); diff --git a/apps/web/app/components/workspace/formatted-field-value.tsx b/apps/web/app/components/workspace/formatted-field-value.tsx index 732caf12a48..4412eee0b0e 100644 --- a/apps/web/app/components/workspace/formatted-field-value.tsx +++ b/apps/web/app/components/workspace/formatted-field-value.tsx @@ -1,5 +1,6 @@ "use client"; +import { Fragment } from "react"; import { formatWorkspaceFieldValue } from "@/lib/workspace-cell-format"; type FormattedFieldValueProps = { @@ -53,6 +54,50 @@ function FileEmbed({ ); } +function normalizeNewlines(text: string): string { + return text.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\r/g, "\n"); +} + +/** + * Render a single line/segment with auto-detected formatting. + * For text/richtext fields, uses heuristic detection so URLs, emails, + * phone numbers are rendered as clickable links. + */ +function FormattedSegment({ text, fieldType }: { text: string; fieldType?: string }) { + const trimmed = text.trim(); + if (!trimmed) {return <>{text};} + const detectType = !fieldType || fieldType === "text" || fieldType === "richtext" ? undefined : fieldType; + const fmt = formatWorkspaceFieldValue(trimmed, detectType); + + if (fmt.kind === "link" && fmt.href) { + // In heuristic mode (text/richtext), file-path detection is prone to false positives + // on prose that happens to contain slashes and dotted words + // (e.g. "Alternate/legacy domains: getgc.ai, gc.ai"). + // Only trust file links when the line has no spaces (i.e. a standalone path). + if (!detectType && fmt.linkType === "file" && trimmed.includes(" ")) { + return <>{fmt.text}; + } + const openInNewTab = fmt.linkType === "url" || fmt.linkType === "file"; + return ( + e.stopPropagation()} + > + {fmt.text} + + ); + } + + if (fmt.kind === "number" || fmt.kind === "currency") { + return {fmt.text}; + } + + return <>{fmt.text}; +} + export function FormattedFieldValue({ value, fieldType, @@ -61,12 +106,32 @@ export function FormattedFieldValue({ }: FormattedFieldValueProps) { const formatted = formatWorkspaceFieldValue(value, fieldType); const isTableMode = mode === "table"; - const textClassName = className ?? (isTableMode ? "truncate block max-w-[300px]" : "break-words"); if (formatted.kind === "empty") { return ; } + const displayText = normalizeNewlines(formatted.text); + const hasNewlines = displayText.includes("\n"); + + if (hasNewlines) { + const lines = displayText.split("\n"); + const containerClass = className ?? (isTableMode ? "block max-w-[300px] line-clamp-3" : "break-words"); + return ( + + {lines.map((line, i) => ( + + {i > 0 &&
} + +
+ ))} +
+ ); + } + + // Single-line: full formatting with embeds + const textClassName = className ?? (isTableMode ? "truncate block max-w-[300px]" : "break-words"); + if (formatted.kind === "link" && formatted.href) { const openInNewTab = formatted.linkType === "url" || formatted.linkType === "file"; const canEmbedInModal = !isTableMode && !!formatted.embedUrl && !!formatted.mediaType; @@ -96,5 +161,5 @@ export function FormattedFieldValue({ return {formatted.text}; } - return {formatted.text}; + return {displayText}; } diff --git a/apps/web/app/components/workspace/object-filter-bar.tsx b/apps/web/app/components/workspace/object-filter-bar.tsx index 4fbb51bb567..94b9ec07fa3 100644 --- a/apps/web/app/components/workspace/object-filter-bar.tsx +++ b/apps/web/app/components/workspace/object-filter-bar.tsx @@ -569,7 +569,7 @@ function FilterRuleRow({ {/* Value editor */} {!noValueNeeded && ( <> - {(fieldType === "text" || fieldType === "richtext" || fieldType === "email") && ( + {(fieldType === "text" || fieldType === "richtext" || fieldType === "email" || fieldType === "tags") && ( onUpdate({ value: v })} diff --git a/apps/web/app/components/workspace/object-gallery.tsx b/apps/web/app/components/workspace/object-gallery.tsx index 41b27e2c0d8..35d5b56ec6d 100644 --- a/apps/web/app/components/workspace/object-gallery.tsx +++ b/apps/web/app/components/workspace/object-gallery.tsx @@ -2,6 +2,8 @@ import { useMemo } from "react"; import { FormattedFieldValue } from "./formatted-field-value"; +import { formatWorkspaceFieldValue } from "@/lib/workspace-cell-format"; +import { parseTagsValue } from "@/lib/parse-tags"; // --------------------------------------------------------------------------- // Types @@ -125,6 +127,7 @@ function GalleryCard({ {displayFields.map((field) => { const val = entry[field.name]; if (val == null || safeString(val) === "") {return null;} + const tags = field.type === "tags" ? parseTagsValue(val) : []; return (
{field.name} -
- +
+ {field.type === "tags" ? ( + + {tags.slice(0, 3).map((tag) => { + const fmt = formatWorkspaceFieldValue(tag); + const isLink = fmt.kind === "link" && fmt.href; + return isLink ? ( + e.stopPropagation()} className="inline-flex items-center px-1.5 py-0 rounded text-[11px] font-medium hover:underline underline-offset-2" style={{ background: "rgba(148, 163, 184, 0.12)", color: "var(--color-accent)" }}>{fmt.text} + ) : ( + {tag} + ); + })} + {tags.length > 3 && +{tags.length - 3}} + + ) : ( + + )}
); diff --git a/apps/web/app/components/workspace/object-kanban.tsx b/apps/web/app/components/workspace/object-kanban.tsx index b7c08a5c5ee..211643c1c55 100644 --- a/apps/web/app/components/workspace/object-kanban.tsx +++ b/apps/web/app/components/workspace/object-kanban.tsx @@ -13,6 +13,8 @@ import { type DragStartEvent, type DragEndEvent, } from "@dnd-kit/core"; +import { formatWorkspaceFieldValue } from "@/lib/workspace-cell-format"; +import { parseTagsValue } from "@/lib/parse-tags"; type Field = { id: string; @@ -187,6 +189,8 @@ function CardContent({ displayVal = labels.join(", "); } + const tags = field.type === "tags" ? parseTagsValue(val) : []; + return (
@@ -198,6 +202,37 @@ function CardContent({ enumValues={field.enum_values} enumColors={field.enum_colors} /> + ) : field.type === "tags" ? ( + + {tags.slice(0, 3).map((tag) => { + const fmt = formatWorkspaceFieldValue(tag); + const isLink = fmt.kind === "link" && fmt.href; + return isLink ? ( + e.stopPropagation()} + className="inline-flex items-center px-1.5 py-0 rounded text-[11px] font-medium hover:underline underline-offset-2" + style={{ background: "rgba(148, 163, 184, 0.12)", color: "var(--color-accent)" }} + > + {fmt.text} + + ) : ( + + {tag} + + ); + })} + {tags.length > 3 && ( + +{tags.length - 3} + )} + ) : field.type === "relation" ? ( ) : ( {displayVal} diff --git a/apps/web/app/components/workspace/object-list.tsx b/apps/web/app/components/workspace/object-list.tsx index 31f3c485d33..2e265176bbb 100644 --- a/apps/web/app/components/workspace/object-list.tsx +++ b/apps/web/app/components/workspace/object-list.tsx @@ -1,6 +1,8 @@ "use client"; import { useMemo } from "react"; +import { formatWorkspaceFieldValue } from "@/lib/workspace-cell-format"; +import { parseTagsValue } from "@/lib/parse-tags"; // --------------------------------------------------------------------------- // Types @@ -94,6 +96,9 @@ function ListRow({ const enumVal = enumField ? safeString(entry[enumField.name]) : null; const badge = enumField && enumVal ? getEnumBadge(enumVal, enumField) : null; + const tagsField = fields.find((f) => f.type === "tags"); + const tagsVal = tagsField ? parseTagsValue(entry[tagsField.name]) : []; + const dateField = fields.find((f) => f.type === "date"); const dateVal = dateField ? safeString(entry[dateField.name]) : null; @@ -127,15 +132,40 @@ function ListRow({ {badge.text} )} + {tagsVal.slice(0, 3).map((tag) => { + const fmt = formatWorkspaceFieldValue(tag); + const isLink = fmt.kind === "link" && fmt.href; + return isLink ? ( + e.stopPropagation()} + className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0 hover:underline underline-offset-2" + style={{ background: "rgba(148, 163, 184, 0.12)", color: "var(--color-accent)", border: "1px solid var(--color-border)" }} + > + {fmt.text} + + ) : ( + + {tag} + + ); + })}
- {subtitle && ( -
- {subtitle} -
- )} + {subtitle && ( +
+ {subtitle} +
+ )}
{/* Date on the right */} diff --git a/apps/web/app/components/workspace/object-table.tsx b/apps/web/app/components/workspace/object-table.tsx index bfb7d2d3c95..3fbf2a6c993 100644 --- a/apps/web/app/components/workspace/object-table.tsx +++ b/apps/web/app/components/workspace/object-table.tsx @@ -5,6 +5,8 @@ import { type ColumnDef, type CellContext } from "@tanstack/react-table"; import { DataTable, type RowAction } from "./data-table"; import { RelationSelect } from "./relation-select"; import { FormattedFieldValue } from "./formatted-field-value"; +import { formatWorkspaceFieldValue } from "@/lib/workspace-cell-format"; +import { parseTagsValue } from "@/lib/parse-tags"; /* ─── Types ─── */ @@ -190,6 +192,117 @@ function RelationCell({ ); } +function TagChip({ tag }: { tag: string }) { + const formatted = formatWorkspaceFieldValue(tag); + const isLink = formatted.kind === "link" && formatted.href; + const chipStyle = { background: "rgba(148, 163, 184, 0.12)", border: "1px solid var(--color-border)" }; + if (isLink) { + return ( + e.stopPropagation()} + className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium underline-offset-2 hover:underline" + style={{ ...chipStyle, color: "var(--color-accent)" }} + > + {formatted.text} + + ); + } + return ( + + {tag} + + ); +} + +function TagsCell({ value }: { value: unknown }) { + const tags = parseTagsValue(value); + if (tags.length === 0) {return --;} + return ( + + {tags.slice(0, 5).map((tag) => )} + {tags.length > 5 && +{tags.length - 5}} + + ); +} + +function TagsInput({ + value, + onChange, + autoFocus, +}: { + value: string; + onChange: (val: string) => void; + autoFocus?: boolean; +}) { + const tags = parseTagsValue(value); + const [inputVal, setInputVal] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + if (autoFocus && inputRef.current) {inputRef.current.focus();} + }, [autoFocus]); + + const addTag = (tag: string) => { + const t = tag.trim(); + if (!t || tags.includes(t)) {return;} + const next = [...tags, t]; + onChange(JSON.stringify(next)); + setInputVal(""); + }; + + const removeTag = (tag: string) => { + const next = tags.filter((t) => t !== tag); + onChange(next.length > 0 ? JSON.stringify(next) : ""); + }; + + return ( +
+ {tags.map((tag) => ( + + {tag} + + + ))} + setInputVal(e.target.value)} + onKeyDown={(e) => { + if ((e.key === "Enter" || e.key === ",") && inputVal.trim()) { + e.preventDefault(); + addTag(inputVal); + } + if (e.key === "Backspace" && !inputVal && tags.length > 0) { + removeTag(tags[tags.length - 1]); + } + }} + onBlur={() => { if (inputVal.trim()) {addTag(inputVal);} }} + placeholder={tags.length === 0 ? "Type and press Enter..." : ""} + className="flex-1 min-w-[80px] text-xs outline-none bg-transparent" + style={{ color: "var(--color-text)" }} + /> +
+ ); +} + function ReverseRelationCell({ links, sourceObjectName, onNavigateObject, onNavigateEntry }: { links: Array<{ id: string; label: string }>; sourceObjectName: string; @@ -269,6 +382,7 @@ function EditableCell({ // Non-editable types: render read-only (relations are now editable via dropdown) const isEditable = !["user"].includes(field.type); const isRelation = field.type === "relation" && !!field.related_object_name; + const isTags = field.type === "tags"; const save = useCallback(async (val: string) => { onLocalValueChange?.(val); @@ -327,6 +441,23 @@ function EditableCell({
); } + if (isTags) { + return ( +
+ { void save(v); }} + autoFocus + /> +
+ ); + } if (field.type === "enum" && field.enum_values) { editInput = ( + updateField(field.name, e.target.value)} - className="w-full px-3 py-2 text-sm rounded-lg outline-none" - style={{ - background: "var(--color-surface)", - color: "var(--color-text)", - border: "1px solid var(--color-border)", - }} - > - - {field.enum_values.map((v) => ( - - ))} - - ) : field.type === "boolean" ? ( + onChange={(v) => updateField(field.name, v)} + /> +
+ ) : field.type === "enum" && field.enum_values ? ( + + ) : field.type === "boolean" ? (