From bd1855da30bf20b8efe42c5f04ce76dab6420f7f Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 4 Aug 2026 20:31:30 -0700 Subject: [PATCH] [eric] tool-ui: string columns coerce instead of failing, validation runs per payload not per render, rowIdKey nags only when actionable --- frontend/src/toolui/VendoredToolUi.tsx | 15 ++++++-- .../components/data-table/data-table.tsx | 4 +- .../toolui/components/data-table/schema.ts | 37 +++++++++++++------ 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/frontend/src/toolui/VendoredToolUi.tsx b/frontend/src/toolui/VendoredToolUi.tsx index eb1da00d..0e0179e4 100644 --- a/frontend/src/toolui/VendoredToolUi.tsx +++ b/frontend/src/toolui/VendoredToolUi.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useEffect, useState } from 'react'; +import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react'; import { useThemeMode } from '@/shared/styles/ThemeContext'; import { TOOL_UI_REGISTRY } from './registry'; @@ -90,6 +90,8 @@ function VendoredToolUi({ name, props, extraProps, quietFail = false }: Vendored const { mode } = useThemeMode(); const entry = TOOL_UI_REGISTRY[name]; const [gate, setGate] = useState({ state: 'pending' }); + // Parents rebuild the props object every render; keying the validation on identity re-ran an async zod parse per transcript render (real typing-lag cost in table-bearing chats). Content is the real dependency. + const propsKey = useMemo(() => { try { return JSON.stringify(props); } catch { return String(Math.random()); } }, [props]); useEffect(() => { let cancelled = false; @@ -101,12 +103,17 @@ function VendoredToolUi({ name, props, extraProps, quietFail = false }: Vendored }) .catch(() => { if (!cancelled) setGate({ state: 'bad', problem: 'component failed to load' }); }); return () => { cancelled = true; }; - }, [entry, props]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [entry, propsKey]); + const warnedRef = useRef(null); if (!entry) return null; if (gate.state === 'bad') { - // Schema jargon is for the console; the transcript gets one quiet human line. - console.warn(`[tool-ui] ${name} payload didn't validate:`, gate.problem); + // Schema jargon is for the console; the transcript gets one quiet human line. Once per payload, not per render. + if (warnedRef.current !== propsKey) { + warnedRef.current = propsKey; + console.warn(`[tool-ui] ${name} payload didn't validate:`, gate.problem); + } if (quietFail) return null; return (
diff --git a/frontend/src/toolui/components/data-table/data-table.tsx b/frontend/src/toolui/components/data-table/data-table.tsx index a6ffd0bc..2a4abfee 100644 --- a/frontend/src/toolui/components/data-table/data-table.tsx +++ b/frontend/src/toolui/components/data-table/data-table.tsx @@ -651,7 +651,9 @@ function DataTableBody() { React.useEffect(() => { if (hasWarnedRowKeyRef.current) return; - if (process.env.NODE_ENV !== "production" && !rowIdKey && data.length > 0) { + // Only nag when the data actually CARRIES an id-like field the caller forgot to point at; model payloads usually have none, and the warning was pure console noise for them. + const hasIdLikeField = data.length > 0 && ["id", "uuid", "key", "symbol"].some((k) => k in (data[0] as Record)); + if (process.env.NODE_ENV !== "production" && !rowIdKey && hasIdLikeField) { hasWarnedRowKeyRef.current = true; console.warn( "[DataTable] Missing `rowIdKey` prop. Falling back to inferred/content-derived row keys. " + diff --git a/frontend/src/toolui/components/data-table/schema.ts b/frontend/src/toolui/components/data-table/schema.ts index e64df356..ee0b8e86 100644 --- a/frontend/src/toolui/components/data-table/schema.ts +++ b/frontend/src/toolui/components/data-table/schema.ts @@ -79,18 +79,31 @@ const formatSchema = z.discriminatedUnion("kind", [ }), ]); -export const serializableColumnSchema = z.object({ - key: z.string(), - label: z.string(), - abbr: z.string().optional(), - sortable: z.boolean().optional(), - align: AlignEnum.optional(), - width: z.string().optional(), - truncate: z.boolean().optional(), - priority: PriorityEnum.optional(), - hideOnMobile: z.boolean().optional(), - format: formatSchema.optional(), -}); +// Models routinely send columns as bare strings or as {key}-only / {label}-only objects; rejecting those threw away perfectly renderable tables, so coerce instead of failing. +export const serializableColumnSchema = z.preprocess( + (raw) => { + if (typeof raw === "string") return { key: raw, label: raw }; + if (raw && typeof raw === "object") { + const o = raw as Record; + const key = typeof o.key === "string" ? o.key : typeof o.label === "string" ? o.label : undefined; + const label = typeof o.label === "string" ? o.label : typeof o.key === "string" ? o.key : undefined; + if (key !== undefined || label !== undefined) return { ...o, key, label }; + } + return raw; + }, + z.object({ + key: z.string(), + label: z.string(), + abbr: z.string().optional(), + sortable: z.boolean().optional(), + align: AlignEnum.optional(), + width: z.string().optional(), + truncate: z.boolean().optional(), + priority: PriorityEnum.optional(), + hideOnMobile: z.boolean().optional(), + format: formatSchema.optional(), + }), +); const JsonPrimitiveSchema = z.union([ z.string(),