feat: Add intelligent auto-router and enhanced integrations

- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
},
},
defaultVariants: {
variant: 'default',
},
}
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-title"
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
{...props}
/>
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-description"
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,34 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,56 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,78 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

View File

@@ -0,0 +1,27 @@
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -0,0 +1,76 @@
import React, { useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
interface CollapsibleProps {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
errorCount?: number;
sectionErrors?: string[];
className?: string;
}
export function Collapsible({
title,
children,
defaultOpen = true,
open: controlledOpen,
onOpenChange,
errorCount = 0,
sectionErrors = [],
className,
}: CollapsibleProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
const handleToggle = () => {
const newOpen = !isOpen;
if (isControlled) {
onOpenChange?.(newOpen);
} else {
setUncontrolledOpen(newOpen);
}
};
return (
<div className={cn('border border-border rounded-lg overflow-hidden', className)}>
<button
onClick={handleToggle}
className="w-full flex items-center justify-between px-4 py-3 bg-muted/50 hover:bg-muted transition-colors text-left"
>
<div className="flex items-center gap-2">
<span className="font-medium">{title}</span>
{errorCount > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center justify-center px-2 py-0.5 text-xs font-medium bg-destructive text-destructive-foreground rounded-full cursor-help">
{errorCount} {errorCount === 1 ? 'error' : 'errors'}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-sm">
<ul className="space-y-1 text-left">
{sectionErrors.map((error, idx) => (
<li key={idx}> {error}</li>
))}
</ul>
</TooltipContent>
</Tooltip>
)}
</div>
<ChevronDown
className={cn(
'h-4 w-4 transition-transform duration-200',
isOpen && 'transform rotate-180'
)}
/>
</button>
{isOpen && <div className="px-4 py-4 space-y-4">{children}</div>}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useState } from 'react';
import { Check as CheckIcon, Copy as CopyIcon } from 'lucide-react';
import { TooltipIconButton } from '@/components/ui/tooltip-icon-button';
export type CopyButtonProps = {
value: string;
tooltip?: string;
copiedTooltip?: string;
className?: string;
size?: number;
};
export function CopyButton({
value,
tooltip = 'Copy',
copiedTooltip = 'Copied!',
className,
size = 12,
}: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const onCopy = () => {
navigator.clipboard
.writeText(value)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {});
};
return (
<TooltipIconButton
tooltip={copied ? copiedTooltip : tooltip}
onClick={onCopy}
className={className}
>
{copied ? <CheckIcon size={size} /> : <CopyIcon size={size} />}
</TooltipIconButton>
);
}

View File

@@ -0,0 +1,126 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-sm',
className
)}
{...props}
/>
);
}
interface DialogContentProps extends React.ComponentProps<typeof DialogPrimitive.Content> {
hideCloseButton?: boolean;
}
function DialogContent({
className,
children,
hideCloseButton = false,
...props
}: DialogContentProps) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-150 sm:max-w-lg',
className
)}
{...props}
>
{children}
{!hideCloseButton && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,70 @@
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { cn } from '@/lib/utils';
// Mirror Popover Root props so consumers can control open state, etc.
const DropdownMenu = ({
children,
...props
}: React.PropsWithChildren<React.ComponentProps<typeof PopoverPrimitive.Root>>) => (
<Popover {...props}>{children}</Popover>
);
const DropdownMenuTrigger = PopoverTrigger;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof PopoverContent>,
React.ComponentPropsWithoutRef<typeof PopoverContent>
>(({ className, align = 'end', sideOffset = 8, ...props }, ref) => (
<PopoverContent
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-xl bg-popover/95 backdrop-blur-xl p-1.5 text-popover-foreground',
'border border-border/40 shadow-lg shadow-black/5',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
DropdownMenuContent.displayName = 'DropdownMenuContent';
const DropdownMenuItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
disabled?: boolean;
}
>(({ className, disabled, ...props }, ref) => (
<div
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-lg px-2.5 py-2 text-sm outline-none transition-all duration-150',
'focus:bg-accent/60 focus:text-accent-foreground',
disabled && 'pointer-events-none opacity-50',
!disabled &&
'hover:bg-accent/60 hover:text-accent-foreground cursor-pointer hover:scale-[0.98]',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = 'DropdownMenuItem';
const DropdownMenuSeparator = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('my-1.5 h-px bg-border/40', className)} {...props} />
));
DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
};

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface FormSectionProps {
title?: string;
description?: string;
children: React.ReactNode;
className?: string;
}
interface FormGroupProps {
children: React.ReactNode;
className?: string;
}
interface FormRowProps {
children: React.ReactNode;
className?: string;
columns?: 1 | 2 | 3;
}
/**
* A card-like section for grouping related form fields.
*/
export function FormSection({ title, description, children, className }: FormSectionProps) {
return (
<div
className={cn('bg-card rounded-lg border border-border p-4 shadow-minimal', className)}
>
{(title || description) && (
<div className="mb-4">
{title && <h3 className="text-sm font-semibold text-foreground">{title}</h3>}
{description && (
<p className="text-xs text-muted-foreground mt-1">{description}</p>
)}
</div>
)}
{children}
</div>
);
}
/**
* A group of form fields with vertical spacing.
*/
export function FormGroup({ children, className }: FormGroupProps) {
return <div className={cn('space-y-4', className)}>{children}</div>;
}
/**
* A row for side-by-side form fields.
*/
export function FormRow({ children, className, columns = 2 }: FormRowProps) {
const gridCols = {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
};
return <div className={cn('grid gap-4', gridCols[columns], className)}>{children}</div>;
}
/**
* A divider line between form sections.
*/
export function FormDivider({ className }: { className?: string }) {
return <hr className={cn('border-t border-border my-4', className)} />;
}
/**
* An alert/info box for important messages within forms.
*/
export function FormAlert({
variant = 'info',
children,
className,
}: {
variant?: 'info' | 'warning' | 'error' | 'success';
children: React.ReactNode;
className?: string;
}) {
const variants = {
info: 'bg-primary/5 border-primary/20 text-foreground',
warning: 'bg-yellow-500/10 border-yellow-500/20 text-yellow-700 dark:text-yellow-500',
error: 'bg-destructive/10 border-destructive/20 text-destructive',
success: 'bg-green-500/10 border-green-500/20 text-green-700 dark:text-green-500',
};
return (
<div className={cn('rounded-lg border px-4 py-3 text-sm', variants[variant], className)}>
{children}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,174 @@
import React, { useState } from 'react';
import { Button } from './button';
import { Input } from './input';
import { Label } from './label';
import { Plus, Trash2, Eye, EyeOff } from 'lucide-react';
interface KeyValuePair {
key: string;
value: string;
id: string;
}
interface KeyValueEditorProps {
label?: string;
placeholder?: {
key?: string;
value?: string;
};
pairs: KeyValuePair[];
onChange: (pairs: KeyValuePair[]) => void;
disabled?: boolean;
className?: string;
keyLabel?: string;
valueLabel?: string;
maskSensitiveValues?: boolean;
}
const SENSITIVE_KEY_PATTERNS = [
/\bapi[_-]?key\b/i,
/\bapikey\b/i,
/\bsecret\b/i,
/\btoken\b/i,
/\bpassword\b/i,
/\bauthorization\b/i,
/\bauth[_-]?token\b/i,
/\bbearer\b/i,
/\bcredential\b/i,
/\bclient[_-]?secret\b/i,
];
export function KeyValueEditor({
label = 'Key-Value Pairs',
placeholder = { key: 'Key', value: 'Value' },
pairs,
onChange,
disabled = false,
className = '',
keyLabel = 'Key',
valueLabel = 'Value',
maskSensitiveValues = true,
}: KeyValueEditorProps) {
const [visibleValues, setVisibleValues] = useState<Set<string>>(new Set());
const isSensitiveKey = (key: string): boolean => {
if (!maskSensitiveValues || !key) return false;
return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key));
};
const toggleValueVisibility = (id: string) => {
setVisibleValues((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
};
const addPair = () => {
const newPair: KeyValuePair = {
key: '',
value: '',
id: `kv-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
};
onChange([...pairs, newPair]);
};
const removePair = (id: string) => {
const filteredPairs = pairs.filter((pair) => pair.id !== id);
// Allow removing all pairs - don't force an empty pair
onChange(filteredPairs);
};
const updatePair = (id: string, field: 'key' | 'value', newValue: string) => {
onChange(pairs.map((pair) => (pair.id === id ? { ...pair, [field]: newValue } : pair)));
};
return (
<div className={`space-y-3 ${className}`}>
{label && <Label className="text-sm font-medium">{label}</Label>}
<div className="space-y-2">
{/* Header row - only show if there are pairs */}
{pairs.length > 0 && (
<div className="grid grid-cols-12 gap-2 items-center text-xs text-muted-foreground">
<div className="col-span-5">{keyLabel}</div>
<div className="col-span-6">{valueLabel}</div>
<div className="col-span-1"></div>
</div>
)}
{/* Key-value pair rows */}
{pairs.map((pair) => {
const isSensitive = isSensitiveKey(pair.key);
const isVisible = visibleValues.has(pair.id);
return (
<div key={pair.id} className="grid grid-cols-12 gap-2 items-center">
<Input
placeholder={placeholder.key}
value={pair.key}
onChange={(e) => updatePair(pair.id, 'key', e.target.value)}
disabled={disabled}
className="col-span-5"
/>
<div className="col-span-6 relative">
<Input
type={isSensitive && !isVisible ? 'password' : 'text'}
placeholder={placeholder.value}
value={pair.value}
onChange={(e) => updatePair(pair.id, 'value', e.target.value)}
disabled={disabled}
className="pr-10"
/>
{isSensitive && pair.value && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => toggleValueVisibility(pair.id)}
disabled={disabled}
className="absolute right-0 top-0 h-full w-10 p-0 hover:bg-transparent"
aria-label={isVisible ? 'Hide value' : 'Show value'}
>
{isVisible ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removePair(pair.id)}
disabled={disabled}
className="col-span-1 h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
{/* Add button */}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPair}
disabled={disabled}
className="w-full mt-2"
>
<Plus className="h-4 w-4 mr-2" />
Add {keyLabel}
</Button>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { Label } from './label';
import { HelpCircle } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip';
interface LabelWithTooltipProps {
htmlFor: string;
children: React.ReactNode;
tooltip?: string;
className?: string;
}
export function LabelWithTooltip({ htmlFor, children, tooltip, className }: LabelWithTooltipProps) {
return (
<div className="flex items-center gap-1.5 mb-2">
<Label htmlFor={htmlFor} className={className}>
{children}
</Label>
{tooltip && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="More information"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-sm">{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,460 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks';
import { memo, useState } from 'react';
import { CheckIcon, CopyIcon } from 'lucide-react';
import { TooltipIconButton } from '@/components/ui/tooltip-icon-button';
// Helper functions for media validation (copied from MessageList to avoid circular imports)
function isValidDataUri(src: string, expectedType?: 'image' | 'video' | 'audio'): boolean {
const typePattern = expectedType ? `${expectedType}/` : '[a-z0-9.+-]+/';
const dataUriRegex = new RegExp(
`^data:${typePattern}[a-z0-9.+-]+;base64,[A-Za-z0-9+/]+={0,2}$`,
'i'
);
return dataUriRegex.test(src);
}
function isSafeHttpUrl(src: string): boolean {
try {
const url = new URL(src);
const hostname = url.hostname.toLowerCase();
// Check protocol
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return false;
}
// Block localhost and common local names
if (hostname === 'localhost' || hostname === '::1') {
return false;
}
// Check for IPv4 addresses
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const ipv4Match = hostname.match(ipv4Regex);
if (ipv4Match) {
const [, a, b, c, d] = ipv4Match.map(Number);
// Validate IP range (0-255)
if (a > 255 || b > 255 || c > 255 || d > 255) {
return false;
}
// Block loopback (127.0.0.0/8)
if (a === 127) {
return false;
}
// Block private networks (RFC 1918)
// 10.0.0.0/8
if (a === 10) {
return false;
}
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) {
return false;
}
// 192.168.0.0/16
if (a === 192 && b === 168) {
return false;
}
// Block link-local (169.254.0.0/16)
if (a === 169 && b === 254) {
return false;
}
// Block 0.0.0.0
if (a === 0 && b === 0 && c === 0 && d === 0) {
return false;
}
}
// Check for IPv6 addresses
if (hostname.includes(':')) {
// Block IPv6 loopback
if (hostname === '::1' || hostname === '0:0:0:0:0:0:0:1') {
return false;
}
// Block IPv6 unique-local (fc00::/7)
if (hostname.startsWith('fc') || hostname.startsWith('fd')) {
return false;
}
// Block IPv6 link-local (fe80::/10)
if (
hostname.startsWith('fe8') ||
hostname.startsWith('fe9') ||
hostname.startsWith('fea') ||
hostname.startsWith('feb')
) {
return false;
}
}
return true;
} catch {
return false;
}
}
function isSafeMediaUrl(src: string, expectedType?: 'image' | 'video' | 'audio'): boolean {
if (src.startsWith('blob:') || isSafeHttpUrl(src)) return true;
if (src.startsWith('data:')) {
return expectedType ? isValidDataUri(src, expectedType) : isValidDataUri(src);
}
return false;
}
function isVideoUrl(url: string): boolean {
// Check for video file extensions
if (url.match(/\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i)) {
return true;
}
// Check for video MIME types in URL or common video hosting patterns
if (url.includes('/video/') || url.includes('video_')) {
return true;
}
return false;
}
function isAudioUrl(url: string): boolean {
// Check for audio file extensions
if (url.match(/\.(mp3|wav|ogg|m4a|aac|flac|wma)(\?.*)?$/i)) {
return true;
}
// Check for audio patterns in URL
if (url.includes('/audio/') || url.includes('audio_')) {
return true;
}
return false;
}
// Auto-linkify plain text URLs
function linkifyText(text: string): React.ReactNode {
// URL regex that matches http(s) URLs
const urlRegex = /(https?:\/\/[^\s<]+)/g;
const parts = text.split(urlRegex);
return parts.map((part, index) => {
if (part.match(urlRegex)) {
// Validate URL safety before rendering as link
if (!isSafeHttpUrl(part)) {
return (
<span
key={index}
className="text-muted-foreground underline decoration-dotted"
title="Unsafe URL"
>
{part}
</span>
);
}
return (
<a
key={index}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline-offset-2 hover:underline hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium break-all overflow-wrap-anywhere max-w-full inline"
title={part}
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
>
{part}
</a>
);
}
return part;
});
}
// Code block component with copy functionality
const CodeBlock = ({
className,
children,
...props
}: {
className?: string;
children?: React.ReactNode;
[key: string]: any;
}) => {
const [copied, setCopied] = useState(false);
const text = String(children ?? '').replace(/\n$/, '');
const isInline = !className;
if (isInline) {
return (
<code
className="text-xs px-1.5 py-0.5 bg-muted rounded font-mono break-all overflow-wrap-anywhere"
{...props}
>
{children}
</code>
);
}
return (
<div className="relative group my-4 min-w-0 max-w-full">
<TooltipIconButton
tooltip={copied ? 'Copied!' : 'Copy code'}
onClick={() => {
navigator.clipboard
.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {});
}}
className="absolute right-2 top-2 z-10 opacity-70 hover:opacity-100 transition-opacity bg-background/80 hover:bg-background"
>
{copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
</TooltipIconButton>
<pre className="overflow-auto bg-muted p-3 rounded-lg text-sm max-w-full">
<code className={className}>{text}</code>
</pre>
</div>
);
};
// Enhanced markdown component with proper emoji support and spacing
const MarkdownTextImpl = ({ children }: { children: string }) => {
const blobUrlsRef = React.useRef<Set<string>>(new Set());
React.useEffect(() => {
// Capture the current ref value for cleanup
const blobUrls = blobUrlsRef.current;
return () => {
// Clean up any created blob URLs on unmount
blobUrls.forEach((url) => {
try {
URL.revokeObjectURL(url);
} catch {
// Silently fail if revoke fails
}
});
};
}, []);
return (
<div className="prose max-w-none dark:prose-invert min-w-0 overflow-hidden break-words overflow-wrap-anywhere [&>p]:my-5 [&>p]:leading-7 [&>p]:first:mt-0 [&>p]:last:mb-0 [&>p]:break-words [&>p]:overflow-wrap-anywhere [&>h1]:mb-8 [&>h1]:text-4xl [&>h1]:font-extrabold [&>h1]:tracking-tight [&>h1]:last:mb-0 [&>h1]:break-words [&>h2]:mb-4 [&>h2]:mt-8 [&>h2]:text-3xl [&>h2]:font-semibold [&>h2]:tracking-tight [&>h2]:first:mt-0 [&>h2]:last:mb-0 [&>h2]:break-words [&>h3]:mb-4 [&>h3]:mt-6 [&>h3]:text-2xl [&>h3]:font-semibold [&>h3]:tracking-tight [&>h3]:first:mt-0 [&>h3]:last:mb-0 [&>h3]:break-words [&>h4]:mb-4 [&>h4]:mt-6 [&>h4]:text-xl [&>h4]:font-semibold [&>h4]:tracking-tight [&>h4]:first:mt-0 [&>h4]:last:mb-0 [&>h4]:break-words [&>ul]:my-5 [&>ul]:ml-6 [&>ul]:list-disc [&>ul>li]:mt-2 [&>ol]:my-5 [&>ol]:ml-6 [&>ol]:list-decimal [&>ol>li]:mt-2 [&_ul]:my-5 [&_ul]:ml-6 [&_ul]:list-disc [&_ul>li]:mt-2 [&_ol]:my-5 [&_ol]:ml-6 [&_ol]:list-decimal [&_ol>li]:mt-2 [&>blockquote]:border-l-2 [&>blockquote]:pl-6 [&>blockquote]:italic [&>blockquote]:break-words [&>hr]:my-5 [&>hr]:border-b">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
skipHtml={true}
components={{
a: ({ href, children, ...props }) => {
const url = (href as string | undefined) ?? '';
const isHttp = /^https?:\/\//i.test(url);
const isAllowed = isHttp; // extend if you want: || url.startsWith('mailto:') || url.startsWith('tel:')
if (!isAllowed || !isSafeHttpUrl(url)) {
return (
<span
className="text-muted-foreground underline decoration-dotted"
{...props}
>
{children}
</span>
);
}
// Regular link rendering with better overflow handling (no truncation)
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline-offset-2 hover:underline hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium break-all overflow-wrap-anywhere max-w-full inline"
title={url}
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
{...props}
>
{children}
</a>
);
},
img: ({ src, alt, ...props }) => {
if (!src) {
return (
<span className="text-xs text-muted-foreground">
No media source provided
</span>
);
}
// Handle Blob sources - validate and convert to string URL
let srcString: string | null = null;
if (typeof src === 'string') {
srcString = src;
} else if ((src as any) instanceof Blob || (src as any) instanceof File) {
// Safe to convert Blob or File to object URL
try {
const objectUrl = URL.createObjectURL(src as Blob | File);
srcString = objectUrl;
// Track the URL for cleanup
blobUrlsRef.current.add(objectUrl);
} catch {
// URL.createObjectURL failed, treat as invalid
srcString = null;
}
} else if (
typeof src === 'object' &&
src !== null &&
(src as any) instanceof MediaSource
) {
// MediaSource objects can also be used with createObjectURL
try {
const objectUrl = URL.createObjectURL(
src as unknown as MediaSource
);
srcString = objectUrl;
// Track the URL for cleanup
blobUrlsRef.current.add(objectUrl);
} catch {
// URL.createObjectURL failed, treat as invalid
srcString = null;
}
} else {
// Invalid or unsafe type - not a string, Blob, File, or MediaSource
srcString = null;
}
// If we couldn't get a valid source string, show error
if (!srcString) {
return (
<span className="text-xs text-muted-foreground">
Invalid or unsafe media source
</span>
);
}
// Check if this is a video URL - render video player
if (isVideoUrl(srcString) && isSafeMediaUrl(srcString, 'video')) {
return (
<div className="my-4 max-w-full overflow-hidden">
<video
controls
src={srcString}
className="w-full max-h-[360px] rounded-lg bg-black"
preload="metadata"
>
Your browser does not support the video tag.
</video>
{alt && (
<p className="text-xs text-muted-foreground mt-1">{alt}</p>
)}
</div>
);
}
// Check if this is an audio URL - render audio player
if (isAudioUrl(srcString) && isSafeMediaUrl(srcString, 'audio')) {
return (
<div className="my-4 max-w-full overflow-hidden">
<div className="flex items-center gap-3 p-3 rounded-lg border border-border bg-muted/30">
<audio
controls
src={srcString}
className="flex-1 min-w-0 h-10"
>
Your browser does not support the audio tag.
</audio>
</div>
{alt && (
<p className="text-xs text-muted-foreground mt-1">{alt}</p>
)}
</div>
);
}
// Default to image rendering
if (!isSafeMediaUrl(srcString, 'image')) {
return (
<span className="text-xs text-muted-foreground">
Invalid or unsafe media source
</span>
);
}
return (
<img
src={srcString}
alt={alt || 'Image'}
className="max-w-full max-h-[500px] object-contain rounded-lg border border-border my-4"
loading="lazy"
{...props}
/>
);
},
p: ({ children, ...props }) => {
// Auto-linkify plain text URLs in paragraphs
if (typeof children === 'string') {
return <p {...props}>{linkifyText(children)}</p>;
}
return <p {...props}>{children}</p>;
},
table: ({ className, children, ...props }) => (
<div className="my-4 overflow-x-auto -mx-1 px-1">
<table
className={['w-full border-separate border-spacing-0', className]
.filter(Boolean)
.join(' ')}
{...props}
>
{children}
</table>
</div>
),
thead: ({ className, ...props }) => <thead className={className} {...props} />,
tr: ({ className, ...props }) => (
<tr
className={[
'm-0 border-b first:border-t',
'[&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg',
className,
]
.filter(Boolean)
.join(' ')}
{...props}
/>
),
th: ({ className, ...props }) => (
<th
className={[
'bg-muted text-left font-bold align-top',
'px-4 py-2 first:rounded-tl-lg last:rounded-tr-lg',
'[&[align=center]]:text-center [&[align=right]]:text-right',
className,
]
.filter(Boolean)
.join(' ')}
{...props}
/>
),
td: ({ className, ...props }) => (
<td
className={[
'border-b border-l last:border-r text-left align-top',
'px-4 py-2 whitespace-normal break-words',
'[&[align=center]]:text-center [&[align=right]]:text-right',
className,
]
.filter(Boolean)
.join(' ')}
{...props}
/>
),
code: CodeBlock,
}}
>
{children}
</ReactMarkdown>
</div>
);
};
export const MarkdownText = memo(MarkdownTextImpl);

View File

@@ -0,0 +1,40 @@
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -0,0 +1,15 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface ScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {}
const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
({ className, children, ...props }, ref) => (
<div ref={ref} className={cn('relative overflow-auto', className)} {...props}>
{children}
</div>
)
);
ScrollArea.displayName = 'ScrollArea';
export { ScrollArea };

View File

@@ -0,0 +1,168 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,23 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: 'horizontal' | 'vertical';
}
const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
({ className, orientation = 'horizontal', ...props }, ref) => (
<div
ref={ref}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
)
);
Separator.displayName = 'Separator';
export { Separator };

View File

@@ -0,0 +1,7 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
}
export { Skeleton };

View File

@@ -0,0 +1,49 @@
import { useEffect, useState } from 'react';
import { Volume2 as VolumeIcon, Square as StopIcon } from 'lucide-react';
import { TooltipIconButton } from '@/components/ui/tooltip-icon-button';
import { speechController } from '@/components/ui/speech-controller';
export type SpeakButtonProps = {
value: string;
tooltip?: string;
stopTooltip?: string;
className?: string;
rate?: number;
pitch?: number;
lang?: string;
};
export function SpeakButton({
value,
tooltip = 'Speak',
stopTooltip = 'Stop',
className,
rate = 1,
pitch = 1,
lang,
}: SpeakButtonProps) {
const [speaking, setSpeaking] = useState(false);
const supported = speechController.supported;
const hasText = (value ?? '').trim().length > 0;
useEffect(() => {
setSpeaking(speechController.isSpeaking());
return speechController.subscribe(() => setSpeaking(speechController.isSpeaking()));
}, []);
const onClick = () => {
if (!supported || !hasText) return;
if (speaking) return speechController.stop();
speechController.speak(value, { rate, pitch, lang });
};
return (
<TooltipIconButton
tooltip={speaking ? stopTooltip : tooltip}
onClick={onClick}
className={className}
disabled={!supported || !hasText}
>
{speaking ? <StopIcon size={12} /> : <VolumeIcon size={12} />}
</TooltipIconButton>
);
}

View File

@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react';
type Subscriber = () => void;
class SpeechController {
private _speaking = false;
private subscribers = new Set<Subscriber>();
private utterance: SpeechSynthesisUtterance | null = null;
private voices: SpeechSynthesisVoice[] = [];
private voiceSubscribers = new Set<Subscriber>();
private preferredVoiceName: string | null = null;
get supported() {
return (
typeof window !== 'undefined' &&
'speechSynthesis' in window &&
'SpeechSynthesisUtterance' in window
);
}
constructor() {
if (this.supported) {
try {
const v = window.localStorage.getItem('ttsVoiceName');
this.preferredVoiceName = v && v.length ? v : null;
} catch {
/* noop */
}
const populate = () => {
try {
const list = window.speechSynthesis.getVoices() || [];
const changed =
list.length !== this.voices.length ||
list.some((v, i) => v.name !== this.voices[i]?.name);
if (changed) {
this.voices = list;
this.voiceSubscribers.forEach((cb) => cb());
}
} catch {
/* noop */
}
};
populate();
try {
// Cast to any to avoid referencing DOM EventListener identifier at runtime
window.speechSynthesis.addEventListener('voiceschanged', populate as any);
} catch {
/* noop */
}
}
}
isSpeaking() {
return this._speaking;
}
subscribe(cb: Subscriber) {
this.subscribers.add(cb);
return () => {
this.subscribers.delete(cb);
};
}
subscribeVoices(cb: Subscriber) {
this.voiceSubscribers.add(cb);
return () => {
this.voiceSubscribers.delete(cb);
};
}
private notify() {
for (const cb of this.subscribers) {
try {
cb();
} catch {
/* noop: subscriber error */
}
}
}
stop() {
if (!this.supported) return;
try {
window.speechSynthesis.cancel();
} catch {
/* noop */
}
this.utterance = null;
if (this._speaking) {
this._speaking = false;
this.notify();
}
}
speak(text: string, opts?: { rate?: number; pitch?: number; lang?: string }) {
if (!this.supported) return;
if (!text || !text.trim()) return;
this.stop();
const utter = new window.SpeechSynthesisUtterance(text);
if (opts?.rate != null) utter.rate = opts.rate;
if (opts?.pitch != null) utter.pitch = opts.pitch;
const voice = this.resolveVoice();
if (voice) {
try {
utter.voice = voice;
if (opts?.lang) utter.lang = opts.lang;
else if (voice.lang) utter.lang = voice.lang;
} catch {
/* noop */
}
} else if (opts?.lang) {
utter.lang = opts.lang;
}
utter.onstart = () => {
if (this.utterance !== utter) return;
this._speaking = true;
this.notify();
};
const end = () => {
if (this.utterance !== utter) return; // ignore events from stale utterances
this._speaking = false;
this.utterance = null;
this.notify();
};
utter.onend = end;
utter.onerror = end;
try {
// mark current first to correlate with handlers
this.utterance = utter;
window.speechSynthesis.speak(utter);
if (!this._speaking) {
this._speaking = true;
this.notify();
}
} catch {
// ensure state resets if speaking fails
end();
}
}
getVoices() {
return this.voices;
}
setPreferredVoice(name: string | null) {
this.preferredVoiceName = name && name.length ? name : null;
try {
if (this.preferredVoiceName)
window.localStorage.setItem('ttsVoiceName', this.preferredVoiceName);
else window.localStorage.removeItem('ttsVoiceName');
} catch {
/* noop */
}
this.voiceSubscribers.forEach((cb) => cb());
}
getPreferredVoiceName() {
return this.preferredVoiceName;
}
private resolveVoice(): SpeechSynthesisVoice | null {
if (!this.voices.length) return null;
const byName = this.preferredVoiceName
? this.voices.find((v) => v.name === this.preferredVoiceName)
: null;
if (byName) return byName;
// Prefer Google UK English Female when available (Chrome typically exposes this voice)
const ukFemale = this.voices.find(
(v) =>
v.name.toLowerCase() === 'google uk english female' ||
(v.name.toLowerCase().includes('google uk english female') &&
v.lang?.toLowerCase().startsWith('en-gb'))
);
if (ukFemale) return ukFemale;
const score = (v: SpeechSynthesisVoice) => {
const n = v.name.toLowerCase();
const lang = (v.lang ?? '').toLowerCase();
let s = 0;
if (lang === 'en' || lang.startsWith('en-')) s += 3;
if (n.includes('google')) s += 5;
if (n.includes('microsoft') || n.includes('azure')) s += 4;
if (n.includes('natural') || n.includes('neural') || n.includes('premium')) s += 4;
if (n.includes('siri')) s += 3;
if (n.includes('female')) s += 1;
return s;
};
const sorted = [...this.voices].sort((a, b) => score(b) - score(a));
return sorted[0] || this.voices[0] || null;
}
}
declare global {
var __speechController: SpeechController | undefined;
}
const g = globalThis as typeof globalThis & { __speechController?: SpeechController };
export const speechController =
g.__speechController ?? (g.__speechController = new SpeechController());
export function useSpeechVoices(): {
voices: SpeechSynthesisVoice[];
selected: string | null;
setSelected: (name: string | null) => void;
} {
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>(speechController.getVoices());
const [selected, setSelectedState] = useState<string | null>(
speechController.getPreferredVoiceName()
);
useEffect(() => {
const unsub = speechController.subscribeVoices(() => {
setVoices(speechController.getVoices());
setSelectedState(speechController.getPreferredVoiceName());
});
return () => unsub();
}, []);
const setSelected = (name: string | null) => {
speechController.setPreferredVoice(name);
setSelectedState(speechController.getPreferredVoiceName());
};
return { voices, selected, setSelected };
}

View File

@@ -0,0 +1,33 @@
import { useEffect } from 'react';
export function SpeechReset() {
useEffect(() => {
const cancel = () => {
try {
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
window.speechSynthesis.cancel();
}
} catch {}
};
// Cancel any lingering speech on mount (e.g., after a refresh)
cancel();
// Cancel on page hide/unload as well
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') cancel();
};
window.addEventListener('pagehide', cancel);
window.addEventListener('beforeunload', cancel);
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
window.removeEventListener('pagehide', cancel);
window.removeEventListener('beforeunload', cancel);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, []);
return null;
}

View File

@@ -0,0 +1,58 @@
import React, { useEffect, useState } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useSpeechVoices } from '@/components/ui/speech-controller';
type SpeechVoiceSelectProps = {
active?: boolean;
id?: string;
};
export function SpeechVoiceSelect({ active = false, id }: SpeechVoiceSelectProps) {
const { voices, selected, setSelected } = useSpeechVoices();
const [ready, setReady] = useState(false);
useEffect(() => {
if (!active) {
setReady(false);
return;
}
const idleId = (window as any).requestIdleCallback
? (window as any).requestIdleCallback(() => setReady(true))
: setTimeout(() => setReady(true), 0);
return () => {
if ((window as any).cancelIdleCallback && typeof idleId === 'number')
(window as any).cancelIdleCallback(idleId);
else clearTimeout(idleId as any);
};
}, [active]);
const onChange = (val: string) => {
const name = val === 'auto' ? null : val;
setSelected(name);
};
if (!active) return null;
return (
<Select value={selected ?? 'auto'} onValueChange={onChange}>
<SelectTrigger id={id} className="h-8 w-[12rem] text-xs">
<SelectValue placeholder="Voice" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto (best available)</SelectItem>
{ready &&
voices.map((v) => (
<SelectItem key={`${v.name}-${v.lang}`} value={v.name}>
{v.name} ({v.lang})
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@@ -0,0 +1,27 @@
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:border-transparent data-[state=unchecked]:bg-muted data-[state=unchecked]:border-border',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -0,0 +1,113 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface TabsProps {
value: string;
onValueChange: (value: string) => void;
children: React.ReactNode;
className?: string;
}
interface TabsListProps {
children: React.ReactNode;
className?: string;
}
interface TabsTriggerProps {
value: string;
children: React.ReactNode;
className?: string;
disabled?: boolean;
icon?: React.ReactNode;
badge?: React.ReactNode;
}
interface TabsContentProps {
value: string;
children: React.ReactNode;
className?: string;
}
const TabsContext = React.createContext<{
value: string;
onValueChange: (value: string) => void;
} | null>(null);
function useTabsContext() {
const context = React.useContext(TabsContext);
if (!context) {
throw new Error('Tabs components must be used within a Tabs provider');
}
return context;
}
export function Tabs({ value, onValueChange, children, className }: TabsProps) {
return (
<TabsContext.Provider value={{ value, onValueChange }}>
<div className={cn('flex flex-col h-full', className)}>{children}</div>
</TabsContext.Provider>
);
}
export function TabsList({ children, className }: TabsListProps) {
return (
<div
className={cn(
'flex items-center gap-1 px-4 py-2 border-b border-border bg-muted/30',
className
)}
role="tablist"
>
{children}
</div>
);
}
export function TabsTrigger({
value,
children,
className,
disabled,
icon,
badge,
}: TabsTriggerProps) {
const { value: selectedValue, onValueChange } = useTabsContext();
const isSelected = value === selectedValue;
return (
<button
role="tab"
aria-selected={isSelected}
aria-disabled={disabled}
disabled={disabled}
onClick={() => onValueChange(value)}
className={cn(
'inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-md transition-all duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
isSelected
? 'bg-background text-foreground shadow-sm border border-border/50'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
>
{icon && <span className="flex-shrink-0">{icon}</span>}
<span>{children}</span>
{badge}
</button>
);
}
export function TabsContent({ value, children, className }: TabsContentProps) {
const { value: selectedValue } = useTabsContext();
if (value !== selectedValue) {
return null;
}
return (
<div role="tabpanel" className={cn('flex-1 overflow-auto animate-fade-in', className)}>
{children}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -0,0 +1,34 @@
import { ComponentPropsWithoutRef, forwardRef } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export type TooltipIconButtonProps = ComponentPropsWithoutRef<typeof Button> & {
tooltip: string;
side?: 'top' | 'bottom' | 'left' | 'right';
};
export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>(
({ children, tooltip, side = 'bottom', className, ...rest }, ref) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
{...rest}
className={cn('size-6 p-1', className)}
ref={ref}
>
{children}
<span className="sr-only">{tooltip}</span>
</Button>
</TooltipTrigger>
<TooltipContent side={side}>{tooltip}</TooltipContent>
</Tooltip>
);
}
);
TooltipIconButton.displayName = 'TooltipIconButton';

View File

@@ -0,0 +1,55 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground border border-border/60 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-popover fill-popover z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] border-l border-b border-border/60" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -0,0 +1,114 @@
import { UIResourceRenderer } from '@mcp-ui/client';
import type { UIResourcePart } from '@dexto/core';
import { AlertTriangle } from 'lucide-react';
interface UIResourceRendererWrapperProps {
resource: UIResourcePart;
/** Callback when the UI resource triggers an action */
onAction?: (action: { type: string; payload?: unknown }) => void;
}
/**
* Wrapper component that adapts Dexto's UIResourcePart to @mcp-ui/client's UIResourceRenderer.
* Renders interactive MCP-UI resources (HTML, external URLs, Remote DOM) in sandboxed iframes.
*/
export function UIResourceRendererWrapper({ resource, onAction }: UIResourceRendererWrapperProps) {
// Map UIResourcePart to the format expected by @mcp-ui/client
// MCP SDK uses discriminated unions - either text OR blob, not both
// Store metadata in _meta since annotations has a specific schema in MCP SDK
const mcpResource = resource.blob
? {
type: 'resource' as const,
resource: {
uri: resource.uri,
blob: resource.blob,
...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
_meta: {
...(resource.metadata?.title ? { title: resource.metadata.title } : {}),
...(resource.metadata?.preferredSize
? { preferredSize: resource.metadata.preferredSize }
: {}),
},
},
}
: {
type: 'resource' as const,
resource: {
uri: resource.uri,
text: resource.content || '',
...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
_meta: {
...(resource.metadata?.title ? { title: resource.metadata.title } : {}),
...(resource.metadata?.preferredSize
? { preferredSize: resource.metadata.preferredSize }
: {}),
},
},
};
// Handle UI actions from the rendered component
const handleUIAction = async (result: { type: string; payload?: unknown }) => {
if (onAction) {
onAction(result);
}
// Return undefined to acknowledge the action
return undefined;
};
return (
<div className="ui-resource-container rounded-lg border border-border overflow-hidden">
{resource.metadata?.title && (
<div className="px-3 py-2 bg-muted/50 border-b border-border text-xs font-medium text-muted-foreground flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
{resource.metadata.title}
</div>
)}
<div
className="ui-resource-content"
style={{
width: resource.metadata?.preferredSize?.width
? `${resource.metadata.preferredSize.width}px`
: '100%',
height: resource.metadata?.preferredSize?.height
? `${resource.metadata.preferredSize.height}px`
: 'auto',
minHeight: '100px',
maxWidth: '100%',
}}
>
<UIResourceRenderer
resource={mcpResource}
onUIAction={handleUIAction}
htmlProps={{
autoResizeIframe: !resource.metadata?.preferredSize?.height,
style: {
width: '100%',
border: 'none',
},
}}
/>
</div>
</div>
);
}
/**
* Fallback component shown when UI resource rendering fails or is unsupported.
*/
export function UIResourceFallback({ resource }: { resource: UIResourcePart }) {
return (
<div className="flex items-start gap-2 p-3 rounded-lg border border-border bg-muted/30 text-sm">
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 flex-shrink-0" />
<div className="flex flex-col gap-1 min-w-0">
<span className="font-medium text-muted-foreground">Interactive UI Resource</span>
<span className="text-xs text-muted-foreground/80 break-all">{resource.uri}</span>
<span className="text-xs text-muted-foreground/60">Type: {resource.mimeType}</span>
{resource.metadata?.title && (
<span className="text-xs text-muted-foreground/60">
Title: {resource.metadata.title}
</span>
)}
</div>
</div>
);
}