diff --git a/frontend/package.json b/frontend/package.json index dac14e81..2f297583 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,9 @@ "knip": "knip" }, "dependencies": { + "@assistant-ui/core": "^0.1.9", + "@assistant-ui/core": "^0.1.9", + "@assistant-ui/core": "^0.1.9", "@assistant-ui/react": "^0.12.21", "@assistant-ui/react-lexical": "^0.0.3", "@assistant-ui/react-markdown": "^0.12.7", diff --git a/frontend/src/app/pages/AgentChat/ContextRing.tsx b/frontend/src/app/pages/AgentChat/ContextRing.tsx index 70b5c9c7..c7bb9e85 100644 --- a/frontend/src/app/pages/AgentChat/ContextRing.tsx +++ b/frontend/src/app/pages/AgentChat/ContextRing.tsx @@ -2,7 +2,7 @@ import React from 'react'; import Box from '@mui/material/Box'; import Tooltip from '@mui/material/Tooltip'; -export function formatTokenCount(n: number): string { +function formatTokenCount(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return String(n); diff --git a/frontend/src/app/pages/AgentChat/composer/OpenSwarmComposer.tsx b/frontend/src/app/pages/AgentChat/composer/OpenSwarmComposer.tsx index ade526e0..7b358afb 100644 --- a/frontend/src/app/pages/AgentChat/composer/OpenSwarmComposer.tsx +++ b/frontend/src/app/pages/AgentChat/composer/OpenSwarmComposer.tsx @@ -10,7 +10,7 @@ import { useComposerAttachments } from './useComposerAttachments'; import { MentionSelectOverride, MentionPopover, ComposerAttachmentChips } from './ComposerParts'; import ModelModeSelector from '../ModelModeSelector'; -export interface OpenSwarmComposerProps { +interface OpenSwarmComposerProps { composerExtrasRef: MutableRefObject; mode: string; onModeChange: (mode: string) => void; diff --git a/frontend/src/app/pages/AgentChat/composer/useComposerAttachments.ts b/frontend/src/app/pages/AgentChat/composer/useComposerAttachments.ts index 3b7dddd7..3f451ad8 100644 --- a/frontend/src/app/pages/AgentChat/composer/useComposerAttachments.ts +++ b/frontend/src/app/pages/AgentChat/composer/useComposerAttachments.ts @@ -2,19 +2,19 @@ import { useState, useCallback, useRef } from 'react'; import { API_BASE } from '@/shared/config'; import type { ContextPath } from '@/app/components/DirectoryBrowser'; -export interface AttachedImage { +interface AttachedImage { data: string; media_type: string; preview: string; } -export interface ForcedToolGroup { +interface ForcedToolGroup { label: string; tools: string[]; iconKey?: string; } -export interface AttachedSkill { +interface AttachedSkill { id: string; name: string; content: string; diff --git a/frontend/src/app/pages/AgentChat/composer/useComposerHandle.ts b/frontend/src/app/pages/AgentChat/composer/useComposerHandle.ts deleted file mode 100644 index d8e38f85..00000000 --- a/frontend/src/app/pages/AgentChat/composer/useComposerHandle.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useMemo, type MutableRefObject } from 'react'; -import { useAui } from '@assistant-ui/react'; -import type { ContextPath } from '@/app/components/DirectoryBrowser'; -import type { ComposerExtras } from '../runtime/useOpenSwarmRuntime'; - -export interface ForcedToolGroup { - label: string; - tools: string[]; - iconKey?: string; -} - -export interface ComposerHandle { - getConfig: () => { - prompt: string; - contextPaths: ContextPath[]; - forcedTools: ForcedToolGroup[]; - }; - setContent: ( - prompt: string, - contextPaths?: ContextPath[], - forcedTools?: ForcedToolGroup[], - ) => void; -} - -/** - * Provides a ChatInputHandle-compatible interface backed by the assistant-ui - * ComposerRuntime and a shared ComposerExtras ref. - * - * Agent 7 can wire this into useAgentChat to replace the old chatInputRef. - */ -export function useComposerHandle( - composerExtrasRef: MutableRefObject, -): ComposerHandle { - const aui = useAui(); - - return useMemo( - () => ({ - getConfig: () => { - const text = aui.composer().getState().text; - const extras = composerExtrasRef.current; - const contextPaths: ContextPath[] = (extras.contextPaths ?? []) as ContextPath[]; - const forcedTools: ForcedToolGroup[] = extras.forcedTools - ? extras.forcedTools.map((t) => ({ label: t, tools: [t] })) - : []; - return { prompt: text, contextPaths, forcedTools }; - }, - setContent: (prompt, contextPaths, forcedTools) => { - aui.composer().setText(prompt); - composerExtrasRef.current = { - ...composerExtrasRef.current, - contextPaths: contextPaths as ComposerExtras['contextPaths'], - forcedTools: forcedTools?.flatMap((g) => g.tools), - }; - }, - }), - [aui, composerExtrasRef], - ); -} diff --git a/frontend/src/app/pages/AgentChat/hooks/useChatSubmit.ts b/frontend/src/app/pages/AgentChat/hooks/useChatSubmit.ts index c13b6dad..8c6a5432 100644 --- a/frontend/src/app/pages/AgentChat/hooks/useChatSubmit.ts +++ b/frontend/src/app/pages/AgentChat/hooks/useChatSubmit.ts @@ -12,7 +12,7 @@ import { import type { AttachedImage } from '../ImageAttachments'; import type { ForcedToolGroup } from '../AttachmentChips'; -export interface ChatSubmitParams { +interface ChatSubmitParams { editorRef: React.RefObject; attachedSkillsRef: React.MutableRefObject>; generalFileInputRef: React.RefObject; disabled?: boolean; autoRunMode?: boolean; images: AttachedImage[]; contextPaths: ContextPath[]; diff --git a/frontend/src/app/pages/AgentChat/runtime/useOpenSwarmRuntime.ts b/frontend/src/app/pages/AgentChat/runtime/useOpenSwarmRuntime.ts index ebe6e89b..c5303d97 100644 --- a/frontend/src/app/pages/AgentChat/runtime/useOpenSwarmRuntime.ts +++ b/frontend/src/app/pages/AgentChat/runtime/useOpenSwarmRuntime.ts @@ -30,7 +30,7 @@ export interface DispatchableMessage { selectedBrowserIds?: string[]; } -export interface RuntimeOptions { +interface RuntimeOptions { composerExtrasRef?: MutableRefObject; dispatchMessage?: (msg: DispatchableMessage) => void; } diff --git a/frontend/src/app/pages/AgentChat/thread/OpenSwarmThread.tsx b/frontend/src/app/pages/AgentChat/thread/OpenSwarmThread.tsx index 65ba192a..bdaba868 100644 --- a/frontend/src/app/pages/AgentChat/thread/OpenSwarmThread.tsx +++ b/frontend/src/app/pages/AgentChat/thread/OpenSwarmThread.tsx @@ -20,7 +20,7 @@ const BranchChatContext = createContext< >(undefined); export const useBranchChatCallback = () => useContext(BranchChatContext); -export interface OpenSwarmThreadProps { +interface OpenSwarmThreadProps { sessionId?: string; onBranchChat?: (newSessionId: string) => void; children?: ReactNode; diff --git a/frontend/src/app/pages/AgentChat/toolkit/mcp-tools.tsx b/frontend/src/app/pages/AgentChat/toolkit/mcp-tools.tsx index 906bd794..ff1dc77c 100644 --- a/frontend/src/app/pages/AgentChat/toolkit/mcp-tools.tsx +++ b/frontend/src/app/pages/AgentChat/toolkit/mcp-tools.tsx @@ -1,54 +1,16 @@ import type { ReactNode } from 'react'; import type { Toolkit } from '@assistant-ui/react'; import { MessageDraft } from '@/components/tool-ui/message-draft'; -import { DataTable } from '@/components/tool-ui/data-table'; +import { DataTable } from '@/components/tool-ui/data-table/data-table'; import { - parseMcpToolName, getGmailHeader, formatTimestamp, stripHtml, + getGmailHeader, formatTimestamp, stripHtml, } from '../toolCallUtils'; export { BrowserFeedTracker } from './mcp-browser-feed'; // -- Helpers ---------------------------------------------------------------- -/** Unwrap MCP result (string / content-block array / object) into data. */ -export function extractMcpData(result: unknown): Record { - if (result == null) return {}; - - let text: string | undefined; - - if (typeof result === 'string') { - text = result; - } else if (typeof result === 'object') { - const r = result as Record; - for (const k of ['text', 'output', 'content', 'result']) { - if (typeof r[k] === 'string') { text = r[k] as string; break; } - } - if (text === undefined) return result as Record; - } else { - return {}; - } - - if (!text) return {}; - - try { - let parsed = JSON.parse(text); - if ( - Array.isArray(parsed) && - parsed.some((b: any) => b?.type === 'text' && typeof b?.text === 'string') - ) { - const joined = parsed - .filter((b: any) => b?.type === 'text') - .map((b: any) => b.text) - .join('\n'); - try { parsed = JSON.parse(joined); } catch { return {}; } - } - if (typeof parsed === 'object' && parsed !== null) return parsed; - } catch { /* not JSON */ } - - return {}; -} - -export function extractEmailFields(msg: any) { +function extractEmailFields(msg: any) { const subject = msg.subject || getGmailHeader(msg, 'Subject') || '(no subject)'; const from = msg.from || msg.sender || getGmailHeader(msg, 'From') || ''; const to = msg.to || msg.recipient || getGmailHeader(msg, 'To') || ''; @@ -210,15 +172,6 @@ export function renderParsedMcpData( } } -/** Full MCP dispatch — parses raw tool name + result, routes to renderer. */ -export function renderMcpResult( - toolName: string, result: unknown, toolCallId: string, -): ReactNode | null { - const mcpInfo = parseMcpToolName(toolName); - const data = extractMcpData(result); - return renderParsedMcpData(mcpInfo.service, mcpInfo.action, data, toolCallId); -} - // -- Exported toolkit (empty — MCP tool names are dynamic; Agent 7 wires) --- export const mcpToolkit: Partial = {}; diff --git a/frontend/src/app/pages/Views/FileTree.tsx b/frontend/src/app/pages/Views/FileTree.tsx index 70985ae2..02d9e9ae 100644 --- a/frontend/src/app/pages/Views/FileTree.tsx +++ b/frontend/src/app/pages/Views/FileTree.tsx @@ -14,14 +14,14 @@ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -export interface FileTreeNode { +interface FileTreeNode { name: string; path: string; isDir: boolean; children?: FileTreeNode[]; } -export function getFileIcon(filename: string): React.ReactNode { +function getFileIcon(filename: string): React.ReactNode { const ext = filename.split('.').pop()?.toLowerCase(); const size = 15; switch (ext) { @@ -74,7 +74,7 @@ export function buildFileTree(filePaths: string[]): FileTreeNode[] { return root; } -export interface FileTreeItemProps { +interface FileTreeItemProps { node: FileTreeNode; depth: number; activeFile: string; @@ -83,7 +83,7 @@ export interface FileTreeItemProps { c: ReturnType; } -export const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json', 'SKILL.md']); +const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json', 'SKILL.md']); export const FileTreeItem: React.FC = ({ node, depth, activeFile, onSelect, onDelete, c }) => { const [open, setOpen] = useState(true); diff --git a/frontend/src/components/tool-ui/data-table/_adapter.tsx b/frontend/src/components/tool-ui/data-table/_adapter.tsx index b726b78d..d6dc7f71 100644 --- a/frontend/src/components/tool-ui/data-table/_adapter.tsx +++ b/frontend/src/components/tool-ui/data-table/_adapter.tsx @@ -1,11 +1,5 @@ export { cn } from "@/lib/utils"; export { Button } from "@/components/ui/button"; -export { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; export { Accordion, AccordionContent, diff --git a/frontend/src/components/tool-ui/data-table/data-table.tsx b/frontend/src/components/tool-ui/data-table/data-table.tsx index ffbb8ba5..415b8ab1 100644 --- a/frontend/src/components/tool-ui/data-table/data-table.tsx +++ b/frontend/src/components/tool-ui/data-table/data-table.tsx @@ -35,7 +35,7 @@ import type { } from "./types"; import type { FormatConfig } from "./formatters"; -export const DEFAULT_LOCALE = "en-US" as const; +const DEFAULT_LOCALE = "en-US" as const; function isNumericFormat(format?: FormatConfig): boolean { const kind = format?.kind; @@ -60,8 +60,8 @@ const DataTableContext = React.createContext< DataTableContextValue | undefined >(undefined); -export function useDataTable() { - const context = React.use(DataTableContext) as +function useDataTable() { + const context = React.useContext(DataTableContext) as | DataTableContextValue | undefined; if (!context) { diff --git a/frontend/src/components/tool-ui/data-table/formatters.tsx b/frontend/src/components/tool-ui/data-table/formatters.tsx index 52ef70c2..ed6515c2 100644 --- a/frontend/src/components/tool-ui/data-table/formatters.tsx +++ b/frontend/src/components/tool-ui/data-table/formatters.tsx @@ -2,7 +2,48 @@ import * as React from "react"; import { cn, Badge, Tooltip, TooltipContent, TooltipTrigger } from "./_adapter"; -import { resolveSafeNavigationHref } from "../shared/media"; + +function sanitizeHref(href?: string): string | undefined { + if (!href) return undefined; + const candidate = href.trim(); + if (!candidate) return undefined; + + if ( + candidate.startsWith("/") || + candidate.startsWith("./") || + candidate.startsWith("../") || + candidate.startsWith("?") || + candidate.startsWith("#") + ) { + if (candidate.startsWith("//")) return undefined; + // eslint-disable-next-line no-control-regex -- intentionally matching control characters + if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined; + return candidate; + } + + try { + const url = new URL(candidate); + if (url.protocol === "http:" || url.protocol === "https:") { + return url.toString(); + } + } catch { + return undefined; + } + return undefined; +} + +function resolveSafeNavigationHref( + ...candidates: Array +): string | undefined { + for (const candidate of candidates) { + const safeHref = sanitizeHref(candidate ?? undefined); + if (safeHref) { + return safeHref; + } + } + + return undefined; +} type Tone = "success" | "warning" | "danger" | "info" | "neutral"; @@ -44,7 +85,7 @@ interface DeltaValueProps { locale?: string; } -export function DeltaValue({ value, options, locale }: DeltaValueProps) { +function DeltaValue({ value, options, locale }: DeltaValueProps) { const decimals = options?.decimals ?? 2; const upIsPositive = options?.upIsPositive ?? true; const showSign = options?.showSign ?? true; @@ -90,7 +131,7 @@ interface StatusBadgeProps { options?: Extract; } -export function StatusBadge({ value, options }: StatusBadgeProps) { +function StatusBadge({ value, options }: StatusBadgeProps) { const config = options?.statusMap?.[value] ?? { tone: "neutral" as Tone, label: value, @@ -130,7 +171,7 @@ interface CurrencyValueProps { locale?: string; } -export function CurrencyValue({ value, options, locale }: CurrencyValueProps) { +function CurrencyValue({ value, options, locale }: CurrencyValueProps) { const currency = options?.currency ?? "USD"; const decimals = options?.decimals ?? 2; @@ -150,7 +191,7 @@ interface PercentValueProps { locale?: string; } -export function PercentValue({ value, options, locale }: PercentValueProps) { +function PercentValue({ value, options, locale }: PercentValueProps) { const decimals = options?.decimals ?? 2; const showSign = options?.showSign ?? false; const basis = options?.basis ?? "fraction"; @@ -173,7 +214,7 @@ interface DateValueProps { locale?: string; } -export function DateValue({ value, options, locale }: DateValueProps) { +function DateValue({ value, options, locale }: DateValueProps) { const dateFormat = options?.dateFormat ?? "short"; const date = new Date(value); @@ -248,7 +289,7 @@ interface BooleanValueProps { options?: Extract; } -export function BooleanValue({ value, options }: BooleanValueProps) { +function BooleanValue({ value, options }: BooleanValueProps) { const labels = options?.labels ?? { true: "Yes", false: "No" }; const label = value ? labels.true : labels.false; const variant = value ? "secondary" : "outline"; @@ -265,7 +306,7 @@ interface LinkValueProps { >; } -export function LinkValue({ value, options, row }: LinkValueProps) { +function LinkValue({ value, options, row }: LinkValueProps) { const rawHref = options?.hrefKey && row ? String(row[options.hrefKey] ?? "") : value; const href = resolveSafeNavigationHref(rawHref); @@ -300,7 +341,7 @@ interface NumberValueProps { locale?: string; } -export function NumberValue({ value, options, locale }: NumberValueProps) { +function NumberValue({ value, options, locale }: NumberValueProps) { const decimals = options?.decimals ?? 0; const unit = options?.unit ?? ""; const compact = options?.compact ?? false; @@ -327,7 +368,7 @@ interface BadgeValueProps { options?: Extract; } -export function BadgeValue({ value, options }: BadgeValueProps) { +function BadgeValue({ value, options }: BadgeValueProps) { const tone = options?.colorMap?.[value] ?? "neutral"; const variant = @@ -362,7 +403,7 @@ interface ArrayValueProps { options?: Extract; } -export function ArrayValue({ value, options }: ArrayValueProps) { +function ArrayValue({ value, options }: ArrayValueProps) { const maxVisible = options?.maxVisible ?? 3; const items: (string | number | boolean | null)[] = Array.isArray(value) ? value diff --git a/frontend/src/components/tool-ui/data-table/index.tsx b/frontend/src/components/tool-ui/data-table/index.tsx deleted file mode 100644 index 3716272a..00000000 --- a/frontend/src/components/tool-ui/data-table/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -export { DataTable, useDataTable } from "./data-table"; - -export { renderFormattedValue } from "./formatters"; -export { - NumberValue, - CurrencyValue, - PercentValue, - DeltaValue, - DateValue, - BooleanValue, - LinkValue, - BadgeValue, - StatusBadge, - ArrayValue, -} from "./formatters"; - -export type { - Column, - DataTableProps, - DataTableSerializableProps, - DataTableClientProps, - DataTableRowData, - RowPrimitive, - RowData, - ColumnKey, -} from "./types"; -export type { FormatConfig } from "./formatters"; - -export { sortData, parseNumericLike } from "./utilities"; diff --git a/frontend/src/components/tool-ui/data-table/schema.ts b/frontend/src/components/tool-ui/data-table/schema.ts deleted file mode 100644 index e64df356..00000000 --- a/frontend/src/components/tool-ui/data-table/schema.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { z } from "zod"; -import { - ToolUIIdSchema, - ToolUIReceiptSchema, - ToolUIRoleSchema, -} from "../shared/schema"; -import { defineToolUiContract } from "../shared/contract"; -import type { Column, DataTableProps, RowData } from "./types"; - -const AlignEnum = z.enum(["left", "right", "center"]); -const PriorityEnum = z.enum(["primary", "secondary", "tertiary"]); - -const formatSchema = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("text") }), - z.object({ - kind: z.literal("number"), - decimals: z.number().optional(), - unit: z.string().optional(), - compact: z.boolean().optional(), - showSign: z.boolean().optional(), - }), - z.object({ - kind: z.literal("currency"), - currency: z.string(), - decimals: z.number().optional(), - }), - z.object({ - kind: z.literal("percent"), - decimals: z.number().optional(), - showSign: z.boolean().optional(), - basis: z.enum(["fraction", "unit"]).optional(), - }), - z.object({ - kind: z.literal("date"), - dateFormat: z.enum(["short", "long", "relative"]).optional(), - }), - z.object({ - kind: z.literal("delta"), - decimals: z.number().optional(), - upIsPositive: z.boolean().optional(), - showSign: z.boolean().optional(), - }), - z.object({ - kind: z.literal("status"), - statusMap: z.record( - z.string(), - z.object({ - tone: z.enum(["success", "warning", "danger", "info", "neutral"]), - label: z.string().optional(), - }), - ), - }), - z.object({ - kind: z.literal("boolean"), - labels: z - .object({ - true: z.string(), - false: z.string(), - }) - .optional(), - }), - z.object({ - kind: z.literal("link"), - hrefKey: z.string().optional(), - external: z.boolean().optional(), - }), - z.object({ - kind: z.literal("badge"), - colorMap: z - .record( - z.string(), - z.enum(["success", "warning", "danger", "info", "neutral"]), - ) - .optional(), - }), - z.object({ - kind: z.literal("array"), - maxVisible: z.number().optional(), - }), -]); - -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(), -}); - -const JsonPrimitiveSchema = z.union([ - z.string(), - z.number(), - z.boolean(), - z.null(), -]); - -/** - * Schema for serializable row data. - * - * Supports: - * - Primitives: string, number, boolean, null - * - Arrays of primitives: string[], number[], boolean[], or mixed primitive arrays - * - * Does NOT support: - * - Functions - * - Class instances (Date, Map, Set, etc.) - * - Plain objects (use format configs instead) - * - * @example - * Valid row data: - * ```json - * { - * "name": "Widget", - * "price": 29.99, - * "active": true, - * "tags": ["electronics", "featured"], - * "metrics": [1.2, 3.4, 5.6], - * "flags": [true, false, true], - * "mixed": ["label", 42, true] - * } - * ``` - */ -export const serializableDataSchema = z.record( - z.string(), - z.union([JsonPrimitiveSchema, z.array(JsonPrimitiveSchema)]), -); - -/** - * Zod schema for validating DataTable payloads from LLM tool calls. - * - * This schema validates the serializable parts of a DataTable: - * - id: Unique identifier for this tool UI in the conversation - * - columns: Column definitions (keys, labels, formatting, etc.) - * - data: Data rows (primitives only - no functions or class instances) - * - optional presentation props: rowIdKey, sort/defaultSort, locale, etc. - * - * Non-serializable props like `onSortChange`, `className`, and sibling action surfaces - * must be provided separately in your React component. - * - * @example - * ```ts - * const result = SerializableDataTableSchema.safeParse(llmResponse) - * if (result.success) { - * // result.data contains validated id, columns, and data - * } - * ``` - */ -export const SerializableDataTableSchema = z.object({ - id: ToolUIIdSchema, - role: ToolUIRoleSchema.optional(), - receipt: ToolUIReceiptSchema.optional(), - columns: z.array(serializableColumnSchema), - data: z.array(serializableDataSchema), - rowIdKey: z.string().optional(), - defaultSort: z - .object({ - by: z.string().optional(), - direction: z.enum(["asc", "desc"]).optional(), - }) - .optional(), - sort: z - .object({ - by: z.string().optional(), - direction: z.enum(["asc", "desc"]).optional(), - }) - .optional(), - emptyMessage: z.string().optional(), - maxHeight: z.string().optional(), - locale: z.string().optional(), -}); - -const SerializableDataTableSchemaContract = defineToolUiContract( - "DataTable", - SerializableDataTableSchema, -); - -/** - * Type representing the serializable parts of a DataTable payload. - * - * This type includes only JSON-serializable data that can come from LLM tool calls: - * - Column definitions (format configs, alignment, labels, etc.) - * - Row data (primitives: strings, numbers, booleans, null, string arrays) - * - * Excluded from this type: - * - Event handlers (`onSortChange`) - * - React-specific props (`className`) - * - * @example - * ```ts - * const payload: SerializableDataTable = { - * id: "data-table-expenses", - * columns: [ - * { key: "name", label: "Name" }, - * { key: "price", label: "Price", format: { kind: "currency", currency: "USD" } } - * ], - * data: [ - * { name: "Widget", price: 29.99 } - * ] - * } - * ``` - */ -export type SerializableDataTable = z.infer; - -/** - * Validates and parses a DataTable payload from unknown data (e.g., LLM tool call result). - * - * This function: - * 1. Validates the input against the `SerializableDataTableSchema` - * 2. Throws a descriptive error if validation fails - * 3. Returns typed serializable props ready to pass to the `` component - * - * The returned props are **serializable only** - you must provide client-side props - * separately (onSortChange, className). - * - * @param input - Unknown data to validate (typically from an LLM tool call) - * @returns Validated and typed DataTable serializable props (id, columns, data) - * @throws Error with validation details if input is invalid - * - * @example - * ```tsx - * function MyToolUI({ result }: { result: unknown }) { - * const serializableProps = parseSerializableDataTable(result) - * - * return ( - * - * ) - * } - * ``` - */ -export function parseSerializableDataTable( - input: unknown, -): Pick< - DataTableProps, - | "id" - | "role" - | "receipt" - | "columns" - | "data" - | "rowIdKey" - | "defaultSort" - | "sort" - | "emptyMessage" - | "maxHeight" - | "locale" -> { - const { - id, - role, - receipt, - columns, - data, - rowIdKey, - defaultSort, - sort, - emptyMessage, - maxHeight, - locale, - } = SerializableDataTableSchemaContract.parse(input); - return { - id, - role, - receipt, - columns: columns as unknown as Column[], - data: data as RowData[], - rowIdKey: rowIdKey as keyof RowData | undefined, - defaultSort: defaultSort - ? { - by: defaultSort.by as keyof RowData | undefined, - direction: defaultSort.direction, - } - : undefined, - sort: sort - ? { - by: sort.by as keyof RowData | undefined, - direction: sort.direction, - } - : undefined, - emptyMessage, - maxHeight, - locale, - }; -} - -export function safeParseSerializableDataTable( - input: unknown, -): Pick< - DataTableProps, - | "id" - | "role" - | "receipt" - | "columns" - | "data" - | "rowIdKey" - | "defaultSort" - | "sort" - | "emptyMessage" - | "maxHeight" - | "locale" -> | null { - const res = SerializableDataTableSchemaContract.safeParse(input); - if (!res) return null; - const { - id, - role, - receipt, - columns, - data, - rowIdKey, - defaultSort, - sort, - emptyMessage, - maxHeight, - locale, - } = res; - return { - id, - role, - receipt, - columns: columns as unknown as Column[], - data: data as RowData[], - rowIdKey: rowIdKey as keyof RowData | undefined, - defaultSort: defaultSort - ? { - by: defaultSort.by as keyof RowData | undefined, - direction: defaultSort.direction, - } - : undefined, - sort: sort - ? { - by: sort.by as keyof RowData | undefined, - direction: sort.direction, - } - : undefined, - emptyMessage, - maxHeight, - locale, - }; -} diff --git a/frontend/src/components/tool-ui/data-table/utilities.ts b/frontend/src/components/tool-ui/data-table/utilities.ts index 2175e0cb..ee4116dc 100644 --- a/frontend/src/components/tool-ui/data-table/utilities.ts +++ b/frontend/src/components/tool-ui/data-table/utilities.ts @@ -69,7 +69,7 @@ export function sortData>( * Accepts any JSON-serializable primitive or array of primitives. * Arrays are converted to comma-separated strings. */ -export function getRowIdentifier( +function getRowIdentifier( row: Record< string, string | number | boolean | null | (string | number | boolean | null)[] @@ -210,7 +210,7 @@ export function getDataTableMobileDescriptionId(surfaceId: string): string { * parseNumericLike("50%") // 50 * parseNumericLike("(1234)") // -1234 */ -export function parseNumericLike(input: string): number | null { +function parseNumericLike(input: string): number | null { // Normalize whitespace (spaces, NBSPs, thin spaces) let s = input.replace(/[\u00A0\u202F\s]/g, "").trim(); if (!s) return null; diff --git a/frontend/src/components/tool-ui/shared/media/aspect-ratio.ts b/frontend/src/components/tool-ui/shared/media/aspect-ratio.ts deleted file mode 100644 index 21352fd0..00000000 --- a/frontend/src/components/tool-ui/shared/media/aspect-ratio.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { z } from "zod"; - -export const AspectRatioSchema = z - .enum(["auto", "1:1", "4:3", "16:9", "9:16"]) - .default("auto"); - -export type AspectRatio = z.infer; - -export const MediaFitSchema = z.enum(["cover", "contain"]).default("cover"); - -export type MediaFit = z.infer; - -export const RATIO_CLASS_MAP: Record = { - auto: "", - "1:1": "aspect-square", - "4:3": "aspect-[4/3]", - "16:9": "aspect-video", - "9:16": "aspect-[9/16]", -}; - -export function getRatioClass(ratio: AspectRatio): string { - return RATIO_CLASS_MAP[ratio]; -} - -export function getFitClass(fit: MediaFit): string { - return fit === "cover" ? "object-cover" : "object-contain"; -} diff --git a/frontend/src/components/tool-ui/shared/media/format-utils.ts b/frontend/src/components/tool-ui/shared/media/format-utils.ts deleted file mode 100644 index b00757cb..00000000 --- a/frontend/src/components/tool-ui/shared/media/format-utils.ts +++ /dev/null @@ -1,30 +0,0 @@ -export function formatDuration(durationMs: number): string { - const totalSeconds = Math.round(durationMs / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds - .toString() - .padStart(2, "0")}`; - } - return `${minutes}:${seconds.toString().padStart(2, "0")}`; -} - -/** - * Format file size in bytes to human-readable string. - * @example formatFileSize(1024) => "1 KB" - * @example formatFileSize(1536000) => "1.5 MB" - */ -export function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - const units = ["KB", "MB", "GB"]; - let size = bytes / 1024; - let unit = 0; - while (size >= 1024 && unit < units.length - 1) { - size /= 1024; - unit += 1; - } - return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`; -} diff --git a/frontend/src/components/tool-ui/shared/media/index.ts b/frontend/src/components/tool-ui/shared/media/index.ts deleted file mode 100644 index 5b655c70..00000000 --- a/frontend/src/components/tool-ui/shared/media/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { - AspectRatioSchema, - MediaFitSchema, - RATIO_CLASS_MAP, - getRatioClass, - getFitClass, - type AspectRatio, - type MediaFit, -} from "./aspect-ratio"; - -export { OVERLAY_GRADIENT } from "./overlay-gradient"; - -export { formatDuration, formatFileSize } from "./format-utils"; - -export { sanitizeHref } from "./sanitize-href"; -export { - resolveSafeNavigationHref, - openSafeNavigationHref, -} from "./safe-navigation"; diff --git a/frontend/src/components/tool-ui/shared/media/overlay-gradient.ts b/frontend/src/components/tool-ui/shared/media/overlay-gradient.ts deleted file mode 100644 index f59aa44f..00000000 --- a/frontend/src/components/tool-ui/shared/media/overlay-gradient.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const OVERLAY_GRADIENT = `linear-gradient( - to bottom, - hsl(0, 0%, 0%) 0%, - hsla(0, 0%, 0%, 0.987) 8.3%, - hsla(0, 0%, 0%, 0.951) 16.6%, - hsla(0, 0%, 0%, 0.896) 24.6%, - hsla(0, 0%, 0%, 0.825) 32.5%, - hsla(0, 0%, 0%, 0.741) 40.1%, - hsla(0, 0%, 0%, 0.648) 47.6%, - hsla(0, 0%, 0%, 0.55) 54.8%, - hsla(0, 0%, 0%, 0.45) 61.7%, - hsla(0, 0%, 0%, 0.352) 68.3%, - hsla(0, 0%, 0%, 0.259) 74.5%, - hsla(0, 0%, 0%, 0.175) 80.4%, - hsla(0, 0%, 0%, 0.104) 86%, - hsla(0, 0%, 0%, 0.049) 91.1%, - hsla(0, 0%, 0%, 0.013) 95.8%, - hsla(0, 0%, 0%, 0) 100% -)` as const; diff --git a/frontend/src/components/tool-ui/shared/media/safe-navigation.ts b/frontend/src/components/tool-ui/shared/media/safe-navigation.ts deleted file mode 100644 index b1f38bf4..00000000 --- a/frontend/src/components/tool-ui/shared/media/safe-navigation.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { sanitizeHref } from "./sanitize-href"; - -export function resolveSafeNavigationHref( - ...candidates: Array -): string | undefined { - for (const candidate of candidates) { - const safeHref = sanitizeHref(candidate ?? undefined); - if (safeHref) { - return safeHref; - } - } - - return undefined; -} - -export function openSafeNavigationHref(href: string | undefined): boolean { - if (!href || typeof window === "undefined") { - return false; - } - - window.open(href, "_blank", "noopener,noreferrer"); - return true; -} diff --git a/frontend/src/components/tool-ui/shared/media/sanitize-href.ts b/frontend/src/components/tool-ui/shared/media/sanitize-href.ts deleted file mode 100644 index de7ced70..00000000 --- a/frontend/src/components/tool-ui/shared/media/sanitize-href.ts +++ /dev/null @@ -1,28 +0,0 @@ -export function sanitizeHref(href?: string): string | undefined { - if (!href) return undefined; - const candidate = href.trim(); - if (!candidate) return undefined; - - if ( - candidate.startsWith("/") || - candidate.startsWith("./") || - candidate.startsWith("../") || - candidate.startsWith("?") || - candidate.startsWith("#") - ) { - if (candidate.startsWith("//")) return undefined; - // eslint-disable-next-line no-control-regex -- intentionally matching control characters - if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined; - return candidate; - } - - try { - const url = new URL(candidate); - if (url.protocol === "http:" || url.protocol === "https:") { - return url.toString(); - } - } catch { - return undefined; - } - return undefined; -} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index e5f0a96d..d975aeae 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -3,7 +3,6 @@ import { XIcon } from "lucide-react" import { Dialog as DialogPrimitive } from "radix-ui" import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" function Dialog({ ...props @@ -23,12 +22,6 @@ function DialogPortal({ return } -function DialogClose({ - ...props -}: React.ComponentProps) { - return -} - function DialogOverlay({ className, ...props @@ -79,43 +72,6 @@ function DialogContent({ ) } -function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function DialogFooter({ - className, - showCloseButton = false, - children, - ...props -}: React.ComponentProps<"div"> & { - showCloseButton?: boolean -}) { - return ( -
- {children} - {showCloseButton && ( - - - - )} -
- ) -} - function DialogTitle({ className, ...props @@ -129,28 +85,9 @@ function DialogTitle({ ) } -function DialogDescription({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - export { Dialog, - DialogClose, DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogOverlay, - DialogPortal, DialogTitle, DialogTrigger, }