feat: add reusable ToolHealthBadge and ToolHealthBanner components

- Create ToolHealthBadge component in @tpmjs/ui for broken tool indicator
- Create ToolHealthBanner component in @tpmjs/ui for detailed health warnings
- Integrate both components into playground ToolsSidebar:
  - Badge shows in tool cards in left sidebar
  - Banner shows in tool detail modal
- Update search-registry to include health fields in API responses
- Add package.json exports for new health components

These components provide consistent UI for displaying broken tool status
across the application (tool search, tool detail pages, playground).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2025-12-04 19:12:07 +10:00
parent 54ee6439cb
commit a9e91c02d1
24 changed files with 729 additions and 405 deletions

View file

@ -10,17 +10,10 @@
"clean": "rm -rf dist",
"type-check": "tsc --noEmit"
},
"keywords": [
"tpmjs-tool",
"ai-sdk",
"hello",
"example"
],
"keywords": ["tpmjs-tool", "ai-sdk", "hello", "example"],
"tpmjs": {
"category": "text-analysis",
"frameworks": [
"vercel-ai"
],
"frameworks": ["vercel-ai"],
"tools": [
{
"exportName": "helloWorldTool",
@ -70,8 +63,5 @@
"tsx": "^4.20.6",
"typescript": "^5.9.3"
},
"files": [
"dist",
"README.md"
]
"files": ["dist", "README.md"]
}

View file

@ -118,6 +118,10 @@ export const searchTpmjsToolsTool = tool({
env: tool.package.env,
version: tool.package.npmVersion,
importUrl: `https://esm.sh/${tool.package.npmPackageName}@${tool.package.npmVersion}`,
importHealth: tool.importHealth,
executionHealth: tool.executionHealth,
healthCheckError: tool.healthCheckError,
lastHealthCheck: tool.lastHealthCheck,
})),
};
},

View file

@ -170,9 +170,10 @@ export function validateTpmjsField(tpmjs: unknown): ValidationResult {
const data = multiResult.data;
// Determine tier based on tool richness
const hasRichFields = data.tools.some(
(tool) => tool.parameters || tool.returns || tool.aiAgent
) || data.env || data.frameworks;
const hasRichFields =
data.tools.some((tool) => tool.parameters || tool.returns || tool.aiAgent) ||
data.env ||
data.frameworks;
return {
valid: true,
@ -203,8 +204,11 @@ export function validateTpmjsField(tpmjs: unknown): ValidationResult {
};
const hasRichFields =
legacyData.parameters || legacyData.returns || legacyData.env ||
legacyData.frameworks || legacyData.aiAgent;
legacyData.parameters ||
legacyData.returns ||
legacyData.env ||
legacyData.frameworks ||
legacyData.aiAgent;
return {
valid: true,

View file

@ -134,6 +134,14 @@
"./ActivityStream/ActivityStream": {
"types": "./dist/ActivityStream/ActivityStream.d.ts",
"default": "./dist/ActivityStream/ActivityStream.js"
},
"./ToolHealthBadge/ToolHealthBadge": {
"types": "./dist/ToolHealthBadge/ToolHealthBadge.d.ts",
"default": "./dist/ToolHealthBadge/ToolHealthBadge.js"
},
"./ToolHealthBanner/ToolHealthBanner": {
"types": "./dist/ToolHealthBanner/ToolHealthBanner.d.ts",
"default": "./dist/ToolHealthBanner/ToolHealthBanner.js"
}
},
"files": ["dist"],
@ -152,6 +160,7 @@
},
"dependencies": {
"@tpmjs/utils": "workspace:*",
"react-syntax-highlighter": "^16.1.0",
"zod": "^4.1.13"
},
"devDependencies": {
@ -163,6 +172,7 @@
"@tpmjs/tsconfig": "workspace:*",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/react-syntax-highlighter": "^15.5.13",
"eslint": "^9.39.1",
"glob": "^13.0.0",
"happy-dom": "^15.11.7",

View file

@ -1,5 +1,7 @@
import { cn } from '@tpmjs/utils/cn';
import { forwardRef, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { Icon } from '../Icon/Icon';
import type { CodeBlockProps } from './types';
import {
@ -8,11 +10,75 @@ import {
codeBlockCopyButtonVariants,
} from './variants';
// Custom light theme with better contrast
const customLightTheme = {
'code[class*="language-"]': {
color: '#24292e',
fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
fontSize: '1em',
textAlign: 'left',
whiteSpace: 'pre',
wordSpacing: 'normal',
wordBreak: 'normal',
wordWrap: 'normal',
lineHeight: '1.5',
tabSize: '4',
hyphens: 'none',
},
'pre[class*="language-"]': {
color: '#24292e',
fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
fontSize: '1em',
textAlign: 'left',
whiteSpace: 'pre',
wordSpacing: 'normal',
wordBreak: 'normal',
wordWrap: 'normal',
lineHeight: '1.5',
tabSize: '4',
hyphens: 'none',
padding: '1em',
margin: '0.5em 0',
overflow: 'auto',
},
comment: { color: '#6a737d', fontStyle: 'italic' },
prolog: { color: '#6a737d' },
doctype: { color: '#6a737d' },
cdata: { color: '#6a737d' },
punctuation: { color: '#24292e' },
property: { color: '#005cc5' },
tag: { color: '#22863a' },
boolean: { color: '#005cc5' },
number: { color: '#005cc5' },
constant: { color: '#005cc5' },
symbol: { color: '#005cc5' },
deleted: { color: '#b31d28' },
selector: { color: '#22863a' },
'attr-name': { color: '#6f42c1' },
string: { color: '#032f62' },
char: { color: '#032f62' },
builtin: { color: '#005cc5' },
inserted: { color: '#22863a' },
operator: { color: '#d73a49' },
entity: { color: '#6f42c1' },
url: { color: '#032f62' },
variable: { color: '#e36209' },
atrule: { color: '#d73a49' },
'attr-value': { color: '#032f62' },
function: { color: '#6f42c1' },
'class-name': { color: '#6f42c1' },
keyword: { color: '#d73a49' },
regex: { color: '#032f62' },
important: { color: '#d73a49', fontWeight: 'bold' },
bold: { fontWeight: 'bold' },
italic: { fontStyle: 'italic' },
};
/**
* CodeBlock component
*
* Displays formatted code with optional copy functionality.
* Includes syntax-highlighted display and copy-to-clipboard button.
* Displays formatted code with syntax highlighting and optional copy functionality.
* Uses Prism syntax highlighter for beautiful code display.
* Built with React and JSX.
*
* @example
@ -32,7 +98,10 @@ import {
* ```
*/
export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
({ className, code, language = 'text', size = 'md', showCopy = true, ...props }, ref) => {
(
{ className, code, language = 'text', size = 'md', showCopy = true, theme = 'light', ...props },
ref
) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
@ -46,16 +115,42 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
}
};
// Map language aliases to supported languages
const languageMap: Record<string, string> = {
js: 'javascript',
ts: 'typescript',
jsx: 'jsx',
tsx: 'tsx',
py: 'python',
rb: 'ruby',
sh: 'bash',
yml: 'yaml',
};
const normalizedLanguage = languageMap[language] || language;
return (
<div ref={ref} className={cn(codeBlockContainerVariants(), className)} {...props}>
<code
className={codeBlockCodeVariants({
size,
})}
data-language={language}
>
{code}
</code>
<div className={codeBlockCodeVariants({ size })} data-language={normalizedLanguage}>
<SyntaxHighlighter
language={normalizedLanguage}
style={theme === 'dark' ? oneDark : oneLight}
customStyle={{
margin: 0,
padding: 0,
background: 'transparent',
fontSize: 'inherit',
lineHeight: 'inherit',
}}
codeTagProps={{
style: {
fontFamily: 'inherit',
},
}}
>
{code}
</SyntaxHighlighter>
</div>
{showCopy && (
<button
type="button"

View file

@ -10,7 +10,7 @@ export interface CodeBlockProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ch
code: string;
/**
* Programming language (for display/metadata only)
* Programming language for syntax highlighting
* @default 'text'
*/
language?: string;
@ -26,6 +26,12 @@ export interface CodeBlockProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ch
* @default true
*/
showCopy?: boolean;
/**
* Color theme for syntax highlighting
* @default 'light'
*/
theme?: 'light' | 'dark';
}
/**

View file

@ -12,8 +12,8 @@ export const inputVariants = createVariants({
'font-sans',
// Borders & Radius
'rounded-md border',
// Background
'bg-background',
// Background - Pure white to stand out
'bg-surface',
// Transitions
'transition-base',
// Focus

View file

@ -12,8 +12,8 @@ export const textareaVariants = createVariants({
'font-sans',
// Borders & Radius
'rounded-md border',
// Background
'bg-background',
// Background - Pure white to stand out
'bg-surface',
// Transitions
'transition-base',
// Focus

View file

@ -0,0 +1,35 @@
'use client';
import type React from 'react';
import { Badge } from '../Badge/Badge';
import { Icon } from '../Icon/Icon';
export interface ToolHealthBadgeProps {
importHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
/**
* Badge component to indicate if a tool is broken (import or execution failed)
*/
export function ToolHealthBadge({
importHealth,
executionHealth,
size = 'sm',
className,
}: ToolHealthBadgeProps): React.ReactElement | null {
const isBroken = importHealth === 'BROKEN' || executionHealth === 'BROKEN';
if (!isBroken) {
return null;
}
return (
<Badge variant="error" size={size} className={className}>
<Icon icon="x" size="sm" className="mr-1" />
Broken
</Badge>
);
}

View file

@ -0,0 +1,86 @@
'use client';
import type React from 'react';
import { Badge } from '../Badge/Badge';
export interface ToolHealthBannerProps {
importHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
healthCheckError?: string | null;
lastHealthCheck?: string | null;
onRecheck?: () => void;
recheckLoading?: boolean;
className?: string;
}
/**
* Banner component to display detailed health status for broken tools
*/
export function ToolHealthBanner({
importHealth,
executionHealth,
healthCheckError,
lastHealthCheck,
onRecheck,
recheckLoading = false,
className,
}: ToolHealthBannerProps): React.ReactElement | null {
const isBroken = importHealth === 'BROKEN' || executionHealth === 'BROKEN';
if (!isBroken) {
return null;
}
return (
<div
className={`p-4 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 ${className || ''}`}
>
<div className="flex items-start gap-3">
<span className="text-xl mt-0.5">⚠️</span>
<div className="flex-1">
<h3 className="text-sm font-semibold text-red-800 dark:text-red-300 mb-1">
This tool is currently broken
</h3>
<div className="space-y-1 text-sm text-red-700 dark:text-red-400">
{importHealth === 'BROKEN' && (
<div className="flex items-center gap-2">
<Badge variant="error" size="sm">
Import Failed
</Badge>
<span className="text-xs">Cannot load from Railway service</span>
</div>
)}
{executionHealth === 'BROKEN' && (
<div className="flex items-center gap-2">
<Badge variant="error" size="sm">
Execution Failed
</Badge>
<span className="text-xs">Runtime error with test parameters</span>
</div>
)}
</div>
{healthCheckError && (
<pre className="mt-2 p-2 rounded bg-red-100 dark:bg-red-900/30 text-xs font-mono text-red-800 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">
{healthCheckError}
</pre>
)}
{lastHealthCheck && (
<p className="text-xs text-red-600 dark:text-red-500 mt-2">
Last checked: {new Date(lastHealthCheck).toLocaleString()}
</p>
)}
{onRecheck && (
<button
type="button"
onClick={onRecheck}
disabled={recheckLoading}
className="mt-3 text-sm font-medium text-red-700 dark:text-red-400 hover:underline disabled:opacity-50"
>
{recheckLoading ? 'Rechecking...' : 'Recheck health →'}
</button>
)}
</div>
</div>
</div>
);
}

View file

@ -14,8 +14,8 @@ export const formInputBase = [
'font-sans text-base',
// Borders and radius
'rounded-md border border-border',
// Colors
'bg-background text-foreground',
// Colors - Pure white background to stand out
'bg-surface text-foreground',
// Placeholder
'placeholder:text-foreground-tertiary',
// Transitions