{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "task",
  "title": "Task",
  "description": "Real-time task display with streaming support, status indicators, and logs viewer.",
  "dependencies": [
    "@inferencesh/sdk"
  ],
  "registryDependencies": [
    "button",
    "card",
    "label",
    "tooltip",
    "spinner",
    "https://ui.inference.sh/r/pretext-md.json"
  ],
  "files": [
    {
      "path": "registry/blocks/task/task-output.tsx",
      "content": "'use client';\n\n/**\n * TaskOutput Component\n *\n * Displays task output with status, logs, and output fields.\n * Supports streaming updates for real-time progress.\n */\n\nimport React, { memo, useState } from 'react';\nimport { cn } from '@/lib/utils';\nimport type { TaskDTO as Task } from '@inferencesh/sdk';\nimport { TaskStatusCompleted, TaskStatusFailed, TaskStatusCancelled } from '@inferencesh/sdk';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Spinner } from '@/components/ui/spinner';\nimport { CircleAlert, XCircle, Copy, Check } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { StatusPill } from './task-status';\nimport { TaskLogs, SimpleLogs } from './task-logs';\nimport { OutputFields } from './output-fields';\n\ntype OutputView = 'output' | 'logs' | 'json';\n\nexport interface TaskOutputProps {\n  /** The task to display */\n  task: Task | null;\n  /** Whether the initial fetch is loading */\n  isLoading?: boolean;\n  /** Whether streaming is active */\n  isStreaming?: boolean;\n  /** Additional CSS classes */\n  className?: string;\n  /** Compact mode (no card wrapper) */\n  compact?: boolean;\n  /** Show error details */\n  showError?: boolean;\n  /** Optional cancel handler */\n  onCancel?: () => void;\n}\n\n/** Check if task status is terminal */\nfunction isTerminalStatus(status: number | undefined): boolean {\n  return (\n    status !== undefined &&\n    [TaskStatusCompleted, TaskStatusFailed, TaskStatusCancelled].includes(status)\n  );\n}\n\n/** Loading state display */\nconst LoadingContent = memo(function LoadingContent({\n  task,\n  compact,\n}: {\n  task: Task | null;\n  compact: boolean;\n}) {\n  const content = (\n    <div className=\"flex flex-col items-center justify-center py-12 gap-4\">\n      <Spinner className=\"h-8 w-8\" />\n      {task && <SimpleLogs task={task} compact onlyLastLine />}\n    </div>\n  );\n\n  if (compact) {\n    return (\n      <Card>\n        <CardContent className=\"p-4\">{content}</CardContent>\n      </Card>\n    );\n  }\n\n  return content;\n});\n\n/** Empty state display */\nconst EmptyContent = memo(function EmptyContent({ compact }: { compact: boolean }) {\n  const content = (\n    <div className=\"flex flex-col items-center justify-center py-12\">\n      <div className=\"h-8 w-8 rounded-full border-2 border-muted-foreground/20\" />\n      <span className=\"text-sm text-muted-foreground/50 mt-4\">no output yet</span>\n    </div>\n  );\n\n  if (compact) {\n    return (\n      <Card>\n        <CardContent className=\"p-4\">{content}</CardContent>\n      </Card>\n    );\n  }\n\n  return content;\n});\n\n/** Error state display */\nconst ErrorContent = memo(function ErrorContent({\n  error,\n  compact,\n}: {\n  error?: string | null;\n  compact: boolean;\n}) {\n  const content = (\n    <div className={cn('rounded-lg', !compact && 'border border-muted p-4')}>\n      <div className=\"flex items-start gap-3\">\n        <CircleAlert className=\"w-5 h-5 text-red-500/50 flex-shrink-0 mt-0.5\" />\n        <div className=\"flex-1 min-w-0\">\n          <h3 className=\"text-sm font-medium text-muted-foreground\">task error</h3>\n          {error && (\n            <p className=\"mt-2 text-sm text-muted-foreground whitespace-pre-wrap break-all\">\n              {error}\n            </p>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n\n  if (compact) {\n    return (\n      <Card>\n        <CardContent className=\"p-4\">{content}</CardContent>\n      </Card>\n    );\n  }\n\n  return content;\n});\n\n/** Cancelled state display */\nconst CancelledContent = memo(function CancelledContent({\n  message,\n  compact,\n}: {\n  message?: string;\n  compact: boolean;\n}) {\n  const content = (\n    <div className={cn('rounded-lg', !compact && 'border border-muted p-4')}>\n      <div className=\"flex items-start gap-3\">\n        <XCircle className=\"w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5\" />\n        <div className=\"flex-1 min-w-0\">\n          <h3 className=\"text-sm font-medium text-muted-foreground\">task cancelled</h3>\n          {message && (\n            <p className=\"mt-2 text-sm text-foreground whitespace-pre-wrap break-all\">{message}</p>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n\n  if (compact) {\n    return (\n      <Card>\n        <CardContent className=\"p-4\">{content}</CardContent>\n      </Card>\n    );\n  }\n\n  return content;\n});\n\n/** Output view toggle */\nconst OutputToggle = memo(function OutputToggle({\n  output,\n  setOutput,\n  hasOutput,\n}: {\n  output: OutputView;\n  setOutput: (o: OutputView) => void;\n  hasOutput: boolean;\n}) {\n  const views: { key: OutputView; label: string; show: boolean }[] = [\n    { key: 'output', label: 'output', show: true },\n    { key: 'logs', label: 'logs', show: true },\n    { key: 'json', label: 'json', show: hasOutput },\n  ];\n\n  return (\n    <div className=\"inline-flex border rounded-full gap-0.5 overflow-hidden p-0.5\">\n      {views.filter(v => v.show).map(({ key, label }) => (\n        <button\n          key={key}\n          onClick={() => setOutput(key)}\n          className={cn(\n            'rounded-full px-2.5 py-0.5 h-6 text-xs cursor-pointer transition-colors',\n            output === key\n              ? 'bg-primary text-primary-foreground'\n              : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'\n          )}\n        >\n          {label}\n        </button>\n      ))}\n    </div>\n  );\n});\n\n/** Header section with status and controls */\nconst HeaderSection = memo(function HeaderSection({\n  task,\n  output,\n  setOutput,\n  isStreaming,\n  onCancel,\n}: {\n  task: Task | null;\n  output: OutputView;\n  setOutput: (o: OutputView) => void;\n  isStreaming: boolean;\n  onCancel?: () => void;\n}) {\n  return (\n    <div className=\"flex justify-between items-center\">\n      <div className=\"flex flex-row gap-2\">\n        {task && <OutputToggle output={output} setOutput={setOutput} hasOutput={!!task.output} />}\n      </div>\n      <div className=\"flex flex-row items-center gap-2\">\n        {task?.status !== undefined && (\n          <>\n            <StatusPill task={task} isStreaming={isStreaming} />\n            {!isTerminalStatus(task.status) && onCancel && (\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"w-8 h-8 p-0 flex items-center justify-center border border-red-500/30 hover:border-red-500/50 rounded-full hover:bg-red-500/20\"\n                onClick={onCancel}\n              >\n                <XCircle className=\"h-4 w-4 text-red-500\" />\n              </Button>\n            )}\n          </>\n        )}\n      </div>\n    </div>\n  );\n});\n\n/** JSON output view */\nconst JsonContent = memo(function JsonContent({ task }: { task: Task }) {\n  const [copied, setCopied] = useState(false);\n\n  const handleCopy = async () => {\n    await navigator.clipboard.writeText(JSON.stringify(task.output, null, 2));\n    setCopied(true);\n    setTimeout(() => setCopied(false), 1500);\n  };\n\n  return (\n    <Card className='p-0 gap-0'>\n      <CardContent className=\"p-4 relative group\">\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity\"\n          onClick={handleCopy}\n        >\n          {copied ? <Check className=\"h-4 w-4 text-green-500\" /> : <Copy className=\"h-4 w-4\" />}\n        </Button>\n        <div className=\"overflow-auto max-w-full max-h-[500px]\">\n          <pre className=\"whitespace-pre-wrap break-all text-xs font-mono\">\n            {task.output ? JSON.stringify(task.output, null, 2) : '{}'}\n          </pre>\n        </div>\n      </CardContent>\n    </Card>\n  );\n});\n\n/** Output content - renders task output fields */\nconst OutputContent = memo(function OutputContent({ task }: { task: Task }) {\n  const output = task.output;\n\n  if (!output || typeof output !== 'object') {\n    return (\n      <div className=\"text-sm text-muted-foreground\">\n        {output !== undefined ? String(output) : 'no output'}\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      {!isTerminalStatus(task.status) && (\n        <div className=\"flex justify-end\">\n          <Spinner className=\"h-6 w-6\" />\n        </div>\n      )}\n      <OutputFields output={output as Record<string, unknown>} />\n    </div>\n  );\n});\n\n/** Main TaskOutput component */\nexport const TaskOutput = memo(function TaskOutput({\n  task,\n  isLoading = false,\n  isStreaming = false,\n  className,\n  compact = false,\n  showError = true,\n  onCancel,\n}: TaskOutputProps) {\n  const [output, setOutput] = useState<OutputView>('output');\n\n  let content = null;\n\n  if (isLoading && !task) {\n    content = <LoadingContent task={null} compact={compact} />;\n  } else if (!task) {\n    content = <EmptyContent compact={compact} />;\n  } else if (output === 'logs') {\n    content = <TaskLogs task={task} />;\n  } else if (output === 'json') {\n    content = <JsonContent task={task} />;\n  } else if (task.output) {\n    content = <OutputContent task={task} />;\n  } else if (task.status === TaskStatusFailed) {\n    content = <ErrorContent error={showError ? task.error : null} compact={compact} />;\n  } else if (task.status === TaskStatusCancelled) {\n    content = <CancelledContent message={task.error} compact={compact} />;\n  } else {\n    content = <LoadingContent task={task} compact={compact} />;\n  }\n\n  if (compact) {\n    return <div className={className}>{content}</div>;\n  }\n\n  return (\n    <div className={className}>\n      <Card className=\"gap-2 p-2\">\n        {task && (\n          <CardHeader className=\"p-0\">\n            <CardTitle>\n              <HeaderSection\n                task={task}\n                output={output}\n                setOutput={setOutput}\n                isStreaming={isStreaming}\n                onCancel={onCancel}\n              />\n            </CardTitle>\n          </CardHeader>\n        )}\n        <CardContent className=\"p-0\">{content}</CardContent>\n      </Card>\n    </div>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/infsh/task/task-output.tsx"
    },
    {
      "path": "registry/blocks/task/task-output-wrapper.tsx",
      "content": "'use client';\n\n/**\n * TaskOutputWrapper\n *\n * A self-contained component that fetches and streams a task by ID.\n * Combines useTask hook with TaskOutput for easy drop-in usage.\n *\n * @example\n * ```tsx\n * // With explicit client\n * const client = new Inference({ apiKey: 'your-key' });\n * <TaskOutputWrapper client={client} taskId=\"abc123\" />\n *\n * // Within AgentProvider (client comes from context via useAgentClient)\n * <AgentChatProvider client={client} agentConfig={...}>\n *   <TaskOutputWrapper client={useAgentClient()} taskId=\"abc123\" />\n * </AgentChatProvider>\n * ```\n */\n\nimport React, { memo, useCallback } from 'react';\nimport type { TaskDTO as Task } from '@inferencesh/sdk';\nimport type { AgentClient } from '@inferencesh/sdk/agent';\nimport { cn } from '@/lib/utils';\nimport { useTask } from '@/hooks/use-task';\nimport { TaskOutput } from '@/components/infsh/task/task-output';\n\nexport interface TaskOutputWrapperProps {\n  /** The inference client instance (AgentClient compatible) */\n  client: AgentClient;\n  /** The task ID to display */\n  taskId: string;\n  /** Additional CSS classes */\n  className?: string;\n  /** Compact mode (no card wrapper) */\n  compact?: boolean;\n  /** Show error details */\n  showError?: boolean;\n  /** Called when task data updates */\n  onUpdate?: (task: Task) => void;\n  /** Called when task reaches terminal status */\n  onComplete?: (task: Task) => void;\n  /** Called when an error occurs */\n  onError?: (error: Error) => void;\n  /** Called when cancel button is clicked */\n  onCancel?: (taskId: string) => void;\n}\n\nexport const TaskOutputWrapper = memo(function TaskOutputWrapper({\n  client,\n  taskId,\n  className,\n  compact = false,\n  showError = true,\n  onUpdate,\n  onComplete,\n  onError,\n  onCancel,\n}: TaskOutputWrapperProps) {\n\n  const { task, isLoading, isStreaming } = useTask({\n    client,\n    taskId,\n    onUpdate,\n    onComplete,\n    onError,\n  });\n\n  const handleCancel = useCallback(async () => {\n    if (onCancel) {\n      onCancel(taskId);\n    } else {\n      // Default cancel behavior using client.http\n      try {\n        await client.http.request('post', `/tasks/${taskId}/cancel`);\n      } catch (err) {\n        onError?.(err instanceof Error ? err : new Error('Failed to cancel task'));\n      }\n    }\n  }, [client, taskId, onCancel, onError]);\n\n  return (\n    <TaskOutput\n      task={task}\n      isLoading={isLoading}\n      isStreaming={isStreaming}\n      className={cn(className)}\n      compact={compact}\n      showError={showError}\n      onCancel={handleCancel}\n    />\n  );\n});\n",
      "type": "registry:component",
      "target": "components/infsh/task/task-output-wrapper.tsx"
    },
    {
      "path": "registry/blocks/task/task-status.tsx",
      "content": "'use client';\n\n/**\n * Task Status Components\n *\n * Visual components for displaying task status.\n */\n\nimport React, { memo } from 'react';\nimport type { TaskDTO as Task, TaskEvent } from '@inferencesh/sdk';\nimport {\n  TaskStatusReceived,\n  TaskStatusQueued,\n  TaskStatusDispatched,\n  TaskStatusPreparing,\n  TaskStatusServing,\n  TaskStatusSettingUp,\n  TaskStatusRunning,\n  TaskStatusUploading,\n  TaskStatusCompleted,\n  TaskStatusFailed,\n  TaskStatusCancelled,\n  TaskStatusCancelling,\n} from '@inferencesh/sdk';\nimport { cn } from '@/lib/utils';\nimport { TimeSince } from './time-since';\n\ntype TaskStatus = number;\n\n/** Get CSS classes for status color */\nexport function getStatusColor(status: TaskStatus): string {\n  switch (status) {\n    case TaskStatusQueued:\n      return 'text-yellow-600 bg-yellow-600/15 dark:text-yellow-400 dark:bg-yellow-400/15';\n    case TaskStatusDispatched:\n      return 'text-orange-600 bg-orange-600/15 dark:text-orange-400 dark:bg-orange-400/15';\n    case TaskStatusPreparing:\n    case TaskStatusServing:\n    case TaskStatusSettingUp:\n    case TaskStatusRunning:\n      return 'text-purple-600 bg-purple-600/15 dark:text-purple-400 dark:bg-purple-400/15';\n    case TaskStatusFailed:\n      return 'text-red-600 bg-red-600/15 dark:text-red-400 dark:bg-red-400/15';\n    case TaskStatusCompleted:\n      return 'text-green-600 bg-green-600/15 dark:text-green-400 dark:bg-green-400/15';\n    case TaskStatusCancelled:\n    case TaskStatusCancelling:\n      return 'text-gray-600 bg-gray-600/15 dark:text-gray-400 dark:bg-gray-400/15';\n    default:\n      return 'text-muted-foreground bg-muted/15';\n  }\n}\n\n/** Get status display text */\nexport function getStatusText(status: TaskStatus): string | null {\n  switch (status) {\n    case TaskStatusReceived:\n      return 'received';\n    case TaskStatusQueued:\n      return 'queued';\n    case TaskStatusDispatched:\n      return 'dispatched';\n    case TaskStatusPreparing:\n      return 'preparing';\n    case TaskStatusServing:\n      return 'serving';\n    case TaskStatusSettingUp:\n      return 'setting up';\n    case TaskStatusRunning:\n      return 'running';\n    case TaskStatusUploading:\n      return 'uploading';\n    case TaskStatusCompleted:\n      return null; // Show only time for completed\n    case TaskStatusFailed:\n      return 'failed';\n    case TaskStatusCancelled:\n      return 'cancelled';\n    case TaskStatusCancelling:\n      return 'cancelling';\n    default:\n      return null;\n  }\n}\n\n/** Get full status text (always returns text) */\nexport function getStatusTextFull(status: TaskStatus): string {\n  const text = getStatusText(status);\n  if (text) return text;\n  if (status === TaskStatusCompleted) return 'completed';\n  return 'unknown';\n}\n\nexport interface TaskEventTimes {\n  received?: string;\n  queued?: string;\n  dispatched?: string;\n  preparing?: string;\n  serving?: string;\n  settingUp?: string;\n  running?: string;\n  uploading?: string;\n  completed?: string;\n  failed?: string;\n  cancelled?: string;\n}\n\n/** Extract event times from task events */\nexport function extractTaskEventTimes(task: Task): TaskEventTimes {\n  const findEventTime = (status: TaskStatus) =>\n    task.events?.find((e: TaskEvent) => e.status === status)?.event_time;\n\n  return {\n    received: findEventTime(TaskStatusReceived),\n    queued: findEventTime(TaskStatusQueued),\n    dispatched: findEventTime(TaskStatusDispatched),\n    preparing: findEventTime(TaskStatusPreparing),\n    serving: findEventTime(TaskStatusServing),\n    settingUp: findEventTime(TaskStatusSettingUp),\n    running: findEventTime(TaskStatusRunning),\n    uploading: findEventTime(TaskStatusUploading),\n    completed: findEventTime(TaskStatusCompleted),\n    failed: findEventTime(TaskStatusFailed),\n    cancelled: findEventTime(TaskStatusCancelled),\n  };\n}\n\nexport interface StatusPillProps {\n  task: Task;\n  isStreaming?: boolean;\n  timeOnly?: boolean;\n  className?: string;\n}\n\n/** Status pill showing task status with optional elapsed time */\nexport const StatusPill = memo(function StatusPill({\n  task,\n  isStreaming = false,\n  timeOnly = false,\n  className,\n}: StatusPillProps) {\n  if (!task?.status || !task?.events) {\n    return null;\n  }\n\n  const status = task.status;\n  const times = extractTaskEventTimes(task);\n  const startTime = times.preparing || times.received;\n  const endTime = times.completed || times.failed || times.cancelled;\n\n  return (\n    <div\n      className={cn(\n        'px-3 py-1.5 rounded-full text-xs font-medium inline-flex items-center gap-1',\n        getStatusColor(status),\n        isStreaming && 'animate-pulse',\n        className\n      )}\n    >\n      {!timeOnly && getStatusText(status)}\n      <TimeSince start={startTime} end={endTime} parentheses />\n    </div>\n  );\n});\n\nexport interface StatusPillSimpleProps {\n  status: TaskStatus;\n  className?: string;\n}\n\n/** Simple status pill without time */\nexport const StatusPillSimple = memo(function StatusPillSimple({\n  status,\n  className,\n}: StatusPillSimpleProps) {\n  return (\n    <div\n      className={cn(\n        'px-3 py-1.5 rounded-full text-xs font-medium',\n        getStatusColor(status),\n        className\n      )}\n    >\n      {getStatusTextFull(status)}\n    </div>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/infsh/task/task-status.tsx"
    },
    {
      "path": "registry/blocks/task/task-logs.tsx",
      "content": "'use client';\n\n/**\n * Task Logs Components\n *\n * Display task logs with auto-scroll and copy functionality.\n */\n\nimport React, { memo, useMemo, useRef, useState, useEffect, useLayoutEffect } from 'react';\nimport { cn } from '@/lib/utils';\nimport type { TaskDTO as Task, TaskLog } from '@inferencesh/sdk';\nimport {\n  TaskLogTypeRun,\n  TaskLogTypeServe,\n  TaskLogTypeSetup,\n  TaskLogTypeTask,\n} from '@inferencesh/sdk';\nimport { Button } from '@/components/ui/button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@/components/ui/tooltip';\nimport { Check, Copy } from 'lucide-react';\n\ntype TaskLogType = number;\n\n// Base64 encoding/decoding utilities\nfunction utf8ToBase64(str: string): string {\n  return btoa(\n    encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) =>\n      String.fromCharCode(parseInt(p1, 16))\n    )\n  );\n}\n\nfunction base64ToUtf8(str: string): string {\n  try {\n    return decodeURIComponent(\n      Array.prototype.map\n        .call(atob(str), (c: string) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))\n        .join('')\n    );\n  } catch {\n    return str;\n  }\n}\n\nconst LOG_TYPES = [\n  { key: TaskLogTypeRun, title: 'run' },\n  { key: TaskLogTypeServe, title: 'serve' },\n  { key: TaskLogTypeSetup, title: 'setup' },\n  { key: TaskLogTypeTask, title: 'inference' },\n] as const;\n\nfunction getTaskLogs(task: Task): Record<TaskLogType, TaskLog | undefined> {\n  return {\n    [TaskLogTypeRun]: task.logs?.find((log: TaskLog) => log.log_type === TaskLogTypeRun),\n    [TaskLogTypeServe]: task.logs?.find((log: TaskLog) => log.log_type === TaskLogTypeServe),\n    [TaskLogTypeSetup]: task.logs?.find((log: TaskLog) => log.log_type === TaskLogTypeSetup),\n    [TaskLogTypeTask]: task.logs?.find((log: TaskLog) => log.log_type === TaskLogTypeTask),\n  };\n}\n\nfunction getMergedLogsContent(\n  logs: Record<TaskLogType, TaskLog | undefined>,\n  onlyLastLine = false\n): string {\n  let mergedContent = '';\n\n  for (const { key, title } of LOG_TYPES) {\n    const log = logs[key];\n    if (log?.content) {\n      let decodedContent = onlyLastLine ? '' : '--- ' + title + ' ---\\n';\n      decodedContent += base64ToUtf8(log.content);\n\n      if (decodedContent) {\n        mergedContent += '\\n' + decodedContent;\n      }\n    }\n  }\n\n  if (onlyLastLine) {\n    const lines = mergedContent.split('\\n').filter((line) => line.trim());\n    return lines.length > 0 ? lines[lines.length - 1] : '';\n  }\n\n  return mergedContent.trim();\n}\n\nfunction getHighestAvailableLogType(\n  logs: Record<TaskLogType, TaskLog | undefined>\n): TaskLogType {\n  return LOG_TYPES.reduce((highest, current) => {\n    return logs[current.key] && current.key > highest ? current.key : highest;\n  }, TaskLogTypeRun);\n}\n\nexport interface TaskLogsProps {\n  task: Task;\n  className?: string;\n}\n\n/** Full task logs with tabs for different log types */\nexport const TaskLogs = memo(function TaskLogs({ task, className }: TaskLogsProps) {\n  const logs = useMemo(() => getTaskLogs(task), [task]);\n  const [selectedTab, setSelectedTab] = useState<TaskLogType>(() =>\n    getHighestAvailableLogType(logs)\n  );\n  const [copied, setCopied] = useState(false);\n\n  useEffect(() => {\n    if (logs) {\n      // If current selection is not available, find the next available log\n      if (!logs[selectedTab]) {\n        const availableLogs = LOG_TYPES.filter((type) => logs[type.key] !== undefined);\n        if (availableLogs.length > 0) {\n          const currentIndex = LOG_TYPES.findIndex((type) => type.key === selectedTab);\n          const nextAvailable = LOG_TYPES.slice(currentIndex + 1).find(\n            (type) => logs[type.key] !== undefined\n          );\n          const logToSelect = nextAvailable?.key || availableLogs[0].key;\n          setSelectedTab(logToSelect);\n        }\n      }\n    }\n  }, [logs, selectedTab]);\n\n  const handleCopy = async () => {\n    const content = logs[selectedTab];\n    let textToCopy = '';\n\n    if (content?.content) {\n      textToCopy = base64ToUtf8(content.content);\n    }\n\n    if (textToCopy) {\n      await navigator.clipboard.writeText(textToCopy);\n      setCopied(true);\n      setTimeout(() => setCopied(false), 1500);\n    }\n  };\n\n  return (\n    <div className={cn('', className)}>\n      <div className=\"flex overflow-x-auto border-b\">\n        {LOG_TYPES.map(({ key, title }) =>\n          !logs[key] ? (\n            <TooltipProvider key={key} delay={100}>\n              <Tooltip>\n                <TooltipTrigger\n                  render={\n                    <button\n                      type=\"button\"\n                      // aria-disabled + tabIndex rather than the native\n                      // `disabled` attribute: a disabled form control fires no\n                      // pointer events in Chrome/Firefox, so the tooltip that\n                      // explains *why* the tab is unavailable would never open.\n                      // No onClick is bound here, so it is inert either way.\n                      aria-disabled=\"true\"\n                      tabIndex={-1}\n                      className={cn(\n                        'px-4 py-2 text-sm font-medium whitespace-nowrap',\n                        'text-muted-foreground cursor-not-allowed',\n                        selectedTab === key && 'border-b-2 border-primary'\n                      )}\n                    />\n                  }\n                >\n                  {title}\n                </TooltipTrigger>\n                <TooltipContent>\n                  <p>n/a</p>\n                </TooltipContent>\n              </Tooltip>\n            </TooltipProvider>\n          ) : (\n            <button\n              key={key}\n              className={cn(\n                'px-4 py-2 text-sm font-medium whitespace-nowrap',\n                'hover:bg-muted/50 cursor-pointer',\n                selectedTab === key && 'border-b-2 border-primary'\n              )}\n              onClick={() => setSelectedTab(key)}\n            >\n              {title}\n            </button>\n          )\n        )}\n      </div>\n      <div className=\"relative group\">\n        <div className=\"absolute right-2 top-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity\">\n          <TooltipProvider>\n            <Tooltip>\n              <TooltipTrigger\n                render={\n                  <Button\n                    size=\"icon\"\n                    variant=\"ghost\"\n                    onClick={handleCopy}\n                    aria-label=\"Copy logs\"\n                    disabled={logs[selectedTab] === undefined}\n                  />\n                }\n              >\n                {copied ? (\n                  <Check className=\"w-4 h-4 text-green-500\" />\n                ) : (\n                  <Copy className=\"w-4 h-4\" />\n                )}\n              </TooltipTrigger>\n              <TooltipContent>\n                <span>{copied ? 'copied!' : 'copy logs'}</span>\n              </TooltipContent>\n            </Tooltip>\n          </TooltipProvider>\n        </div>\n        {logs[selectedTab] && <LogViewer key={selectedTab} content={logs[selectedTab].content} />}\n      </div>\n    </div>\n  );\n});\n\nexport interface SimpleLogsProps {\n  task: Task;\n  className?: string;\n  compact?: boolean;\n  onlyLastLine?: boolean;\n}\n\n/** Simple merged logs view */\nexport const SimpleLogs = memo(function SimpleLogs({\n  task,\n  className,\n  compact = false,\n  onlyLastLine = false,\n}: SimpleLogsProps) {\n  const [copied, setCopied] = useState(false);\n  const logs = useMemo(() => getTaskLogs(task), [task]);\n\n  const handleCopy = async () => {\n    const mergedContent = getMergedLogsContent(logs, onlyLastLine);\n\n    if (mergedContent) {\n      await navigator.clipboard.writeText(mergedContent);\n      setCopied(true);\n      setTimeout(() => setCopied(false), 1500);\n    }\n  };\n\n  const hasAnyLogs = task.logs && task.logs.length > 0;\n  const mergedContent = getMergedLogsContent(logs, onlyLastLine);\n  const combinedLogsContent = utf8ToBase64(mergedContent);\n\n  if (compact) {\n    return (\n      <div className=\"relative\">\n        <pre className=\"font-mono text-xs text-center text-muted-foreground/50 max-w-[500px] whitespace-pre-wrap overflow-hidden truncate max-h-[1.5em] line-clamp-1 break-all\">\n          {mergedContent.split('\\n').map((line: string, i: number) => (\n            <div key={i} className=\"whitespace-pre-wrap break-all\">\n              {line}\n            </div>\n          ))}\n        </pre>\n      </div>\n    );\n  }\n\n  if (!hasAnyLogs) return null;\n\n  return (\n    <div className={cn('', className)}>\n      <div className=\"relative group\">\n        <div className=\"absolute right-2 top-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity\">\n          <TooltipProvider>\n            <Tooltip>\n              <TooltipTrigger\n                render={<Button size=\"icon\" variant=\"ghost\" onClick={handleCopy} aria-label=\"Copy logs\" />}\n              >\n                {copied ? (\n                  <Check className=\"w-4 h-4 text-green-500\" />\n                ) : (\n                  <Copy className=\"w-4 h-4\" />\n                )}\n              </TooltipTrigger>\n              <TooltipContent>\n                <span>{copied ? 'copied!' : 'copy logs'}</span>\n              </TooltipContent>\n            </Tooltip>\n          </TooltipProvider>\n        </div>\n        <LogViewer content={combinedLogsContent} />\n      </div>\n    </div>\n  );\n});\n\ninterface LogViewerProps {\n  content: string;\n}\n\n/** Base log viewer with auto-scroll */\nexport const LogViewer = memo(function LogViewer({ content }: LogViewerProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [autoScroll, setAutoScroll] = useState(true);\n  const contentRef = useRef(content);\n\n  useLayoutEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n    container.scrollTop = container.scrollHeight;\n    if (container.scrollHeight === 0) {\n      setTimeout(() => {\n        container.scrollTop = container.scrollHeight;\n      }, 10);\n    }\n  }, []);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const handleScroll = () => {\n      const threshold = 10;\n      const distanceFromBottom =\n        container.scrollHeight - container.scrollTop - container.clientHeight;\n      setAutoScroll(distanceFromBottom <= threshold);\n    };\n\n    container.addEventListener('scroll', handleScroll);\n    return () => container.removeEventListener('scroll', handleScroll);\n  }, []);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container || content === contentRef.current) return;\n\n    contentRef.current = content;\n\n    if (autoScroll) {\n      requestAnimationFrame(() => {\n        container.scrollTop = container.scrollHeight;\n      });\n    }\n  }, [content, autoScroll]);\n\n  let decodedContent = '';\n  try {\n    decodedContent = decodeURIComponent(escape(atob(content)));\n  } catch {\n    decodedContent = content;\n  }\n\n  const lines = decodedContent.split('\\n');\n\n  return (\n    <div className=\"relative\">\n      <div\n        ref={containerRef}\n        className=\"max-h-[400px] bg-muted/50 rounded-lg text-[12px] p-4 overflow-y-auto\"\n      >\n        <pre className=\"block p-0 m-0 leading-tight font-mono\" style={{ fontFamily: 'ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace' }}>\n          {lines.map((line: string, i: number) => (\n            <div key={i} className=\"flex m-0 p-0\">\n              <span className=\"flex-none w-10 text-muted-foreground/30 select-none\">{i + 1}</span>\n              <span className=\"flex-1 whitespace-pre text-muted-foreground\">\n                {line}\n              </span>\n            </div>\n          ))}\n        </pre>\n      </div>\n    </div>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/infsh/task/task-logs.tsx"
    },
    {
      "path": "registry/blocks/task/output-fields.tsx",
      "content": "'use client';\n\n/**\n * OutputField Component\n *\n * Renders task output fields with smart type detection:\n * - Files (images, videos, audio) via FilePreview\n * - Markdown text via pretext-md\n * - Objects and arrays recursively\n * - Booleans with colored pills\n */\n\nimport { memo, useState } from 'react';\nimport { cn } from '@/lib/utils';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Label } from '@/components/ui/label';\nimport { ChevronDownIcon, ChevronRightIcon, Copy, Check } from 'lucide-react';\nimport { FilePreview, type PartialFile } from './file-preview';\nimport { Markdown } from '@/lib/pretext-md/react';\n\n/** Field schema (simplified from SDK) */\nexport interface Field {\n  key?: string;\n  type?: string;\n  properties?: Record<string, Field>;\n  items?: Field;\n}\n\n/** Check if data is a file object */\nexport function isFile(data: unknown): data is PartialFile {\n  return typeof data === 'object' && data !== null && 'uri' in data && typeof (data as PartialFile).uri === 'string';\n}\n\n/** Check if data is a URL string */\nexport function isUrl(data: unknown): boolean {\n  return typeof data === 'string' && (data.startsWith('http://') || data.startsWith('https://'));\n}\n\n/** Transform URL or file to PartialFile */\nfunction transformToFileObject(data: unknown): PartialFile {\n  if (isUrl(data)) {\n    const uri = data as string;\n    return {\n      uri,\n      path: uri,\n      filename: uri.split('/').pop()?.split('?')[0],\n    };\n  }\n  return data as PartialFile;\n}\n\nexport interface OutputFieldProps {\n  /** Field schema (optional, for labels) */\n  field?: Field;\n  /** The data to render */\n  data: unknown;\n  /** Additional classes */\n  className?: string;\n  /** Compact mode (hide labels) */\n  compact?: boolean;\n  /** Show action buttons */\n  buttons?: boolean;\n  /** Allow clicking to interact */\n  clickable?: boolean;\n  /** Auto-play videos */\n  autoplay?: boolean;\n}\n\nexport const OutputField = memo(function OutputField({\n  field,\n  data,\n  className,\n  compact,\n  buttons = true,\n  clickable = true,\n  autoplay,\n}: OutputFieldProps) {\n  const [isOpen, setIsOpen] = useState(false);\n  const [copied, setCopied] = useState(false);\n\n  // Handle null/undefined\n  if (data === null || data === undefined) {\n    return null;\n  }\n\n  const isArray = Array.isArray(data);\n  const isFileArray =\n    isArray && data.length > 0 && data.every((item) => item !== null && (isUrl(item) || isFile(item)));\n  const isSingleFile = !isArray && (isUrl(data) || isFile(data));\n\n  // Transform data if needed\n  const transformedData = isFileArray\n    ? (data as unknown[]).map(transformToFileObject)\n    : isSingleFile\n      ? transformToFileObject(data)\n      : data;\n\n  const isObject = !isSingleFile && !isFileArray && typeof data === 'object' && !isArray;\n\n  // Empty arrays\n  if (isArray && data.length === 0) {\n    return null;\n  }\n\n  const handleCopy = async () => {\n    const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);\n    await navigator.clipboard.writeText(text);\n    setCopied(true);\n    setTimeout(() => setCopied(false), 1500);\n  };\n\n  return (\n    <div className={cn('space-y-2', className)}>\n      {/* Label */}\n      {field?.key && !compact && (\n        <div className=\"flex flex-row gap-2 items-center\">\n          <Label className=\"text-muted-foreground flex items-center gap-2 lowercase\">\n            {field.key}\n            {field.type && (\n              <span className=\"text-xs text-muted-foreground/60 font-normal\">{field.type}</span>\n            )}\n          </Label>\n          {isObject && (\n            <Button variant=\"ghost\" size=\"sm\" className=\"h-6 w-6 p-0\" onClick={() => setIsOpen(!isOpen)}>\n              {isOpen ? <ChevronDownIcon className=\"h-4 w-4\" /> : <ChevronRightIcon className=\"h-4 w-4\" />}\n            </Button>\n          )}\n        </div>\n      )}\n\n      {/* File array */}\n      {isFileArray ? (\n        <div className=\"space-y-4\">\n          {(transformedData as PartialFile[]).map((item, index) => (\n            <FilePreview\n              key={item.uri || index}\n              file={item}\n              index={index}\n              buttons={buttons}\n              clickable={clickable}\n              autoplay={autoplay}\n            />\n          ))}\n        </div>\n      ) : /* Single file */ isSingleFile ? (\n        <FilePreview\n          file={transformedData as PartialFile}\n          buttons={buttons}\n          clickable={clickable}\n          autoplay={autoplay}\n        />\n      ) : /* Object */ isObject ? (\n        <>\n          {compact && (\n            <Card className=\"p-0\">\n              <CardContent\n                onClick={() => setIsOpen(!isOpen)}\n                className=\"cursor-pointer hover:bg-muted/50 px-4 py-2\"\n              >\n                <div className=\"flex flex-row justify-between items-center\">\n                  <Label className=\"text-muted-foreground flex items-center gap-2 lowercase\">\n                    {field?.key}\n                    {field?.type && (\n                      <span className=\"text-xs text-muted-foreground/60 font-normal\">{field.type}</span>\n                    )}\n                  </Label>\n                  {isOpen ? <ChevronDownIcon className=\"h-4 w-4\" /> : <ChevronRightIcon className=\"h-4 w-4\" />}\n                </div>\n              </CardContent>\n            </Card>\n          )}\n          {(isOpen || !compact) && (\n            <Card className=\"p-0\">\n              <CardContent className=\"p-4\">\n                <div className=\"space-y-3\">\n                  {Object.entries(data as Record<string, unknown>).map(\n                    ([key, value]) =>\n                      value != null && (\n                        <OutputField\n                          key={key}\n                          field={{ key, ...(field?.properties?.[key] || {}) }}\n                          data={value}\n                          buttons={buttons}\n                          clickable={clickable}\n                        />\n                      )\n                  )}\n                </div>\n              </CardContent>\n            </Card>\n          )}\n        </>\n      ) : /* Boolean */ typeof data === 'boolean' ? (\n        <Card className=\"p-0\">\n          <CardContent className=\"p-4\">\n            <div className=\"flex items-center gap-2\">\n              <div\n                className={cn(\n                  'px-2 py-1 rounded-full text-sm font-medium',\n                  data ? 'bg-green-500/15 text-green-600' : 'bg-red-500/15 text-red-600'\n                )}\n              >\n                {data ? 'true' : 'false'}\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n      ) : /* Array */ isArray ? (\n        <Card className=\"p-0\">\n          <CardContent className=\"p-4\">\n            <div className=\"space-y-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto\">\n              {(data as unknown[]).map((item, index) => (\n                <OutputField\n                  key={index}\n                  field={field?.items}\n                  data={item}\n                  buttons={buttons}\n                  clickable={clickable}\n                />\n              ))}\n            </div>\n          </CardContent>\n        </Card>\n      ) : (\n        /* Primitive (string, number) */\n        <Card className=\"p-0\">\n          <CardContent className=\"p-2 h-full\">\n            <div className=\"w-full h-full relative group max-h-[300px]\">\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"absolute right-0 top-0 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer z-10\"\n                onClick={handleCopy}\n                aria-label=\"Copy output\"\n              >\n                {copied ? <Check className=\"h-3 w-3 text-green-500\" /> : <Copy className=\"h-3 w-3\" />}\n              </Button>\n              <div className={cn('max-h-[300px] break-all pr-8', clickable ? 'overflow-y-auto' : 'overflow-y-hidden')}>\n                {typeof data === 'string' ? (\n                  <Markdown content={data} />\n                ) : (\n                  <span className=\"text-sm\">{String(data)}</span>\n                )}\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n      )}\n    </div>\n  );\n});\n\nexport interface OutputFieldsProps {\n  /** The output data object */\n  output: Record<string, unknown>;\n  /** Field schema for labels */\n  fields?: Record<string, Field>;\n  /** Additional classes */\n  className?: string;\n  /** Compact mode */\n  compact?: boolean;\n}\n\n/** Render multiple output fields */\nexport const OutputFields = memo(function OutputFields({\n  output,\n  fields,\n  className,\n  compact,\n}: OutputFieldsProps) {\n  if (!output || typeof output !== 'object') {\n    return null;\n  }\n\n  return (\n    <div className={cn('space-y-4', className)}>\n      {Object.entries(output).map(([key, value]) => (\n        <OutputField\n          key={key}\n          field={{ key, ...(fields?.[key] || {}) }}\n          data={value}\n          compact={compact}\n        />\n      ))}\n    </div>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/infsh/task/output-fields.tsx"
    },
    {
      "path": "registry/blocks/task/file-preview.tsx",
      "content": "'use client';\n\n/**\n * FilePreview Component\n *\n * Renders file previews based on content type: images, videos, audio, text, and generic files.\n * Supports zoomable images, copy URL, open in new tab, and download actions.\n */\n\nimport { Button } from '@/components/ui/button';\nimport { DownloadIcon, ExternalLinkIcon, FileIcon, LinkIcon, Copy, Check } from 'lucide-react';\nimport { useCallback, useEffect, useState } from 'react';\nimport { cn } from '@/lib/utils';\n\n/** Partial file structure from SDK */\nexport interface PartialFile {\n  uri: string;\n  path?: string;\n  filename?: string;\n  content_type?: string;\n  size?: number;\n}\n\nexport interface FilePreviewProps {\n  file: PartialFile;\n  index?: number;\n  onLoad?: () => void;\n  onError?: () => void;\n  /** Show action buttons on hover */\n  buttons?: boolean;\n  /** Allow clicking to interact */\n  clickable?: boolean;\n  /** Auto-play videos */\n  autoplay?: boolean;\n  /** Object fit mode */\n  objectFit?: 'contain' | 'cover';\n  /** Show card border */\n  card?: boolean;\n  /** Allow dragging */\n  draggable?: boolean;\n  className?: string;\n}\n\n/** Guess content type from URL extension */\nfunction guessContentType(uri: string): string | null {\n  const ext = uri.split('.').pop()?.toLowerCase().split('?')[0];\n  const mimeTypes: Record<string, string> = {\n    // Images\n    jpg: 'image/jpeg',\n    jpeg: 'image/jpeg',\n    png: 'image/png',\n    gif: 'image/gif',\n    webp: 'image/webp',\n    svg: 'image/svg+xml',\n    avif: 'image/avif',\n    bmp: 'image/bmp',\n    ico: 'image/x-icon',\n    // Video\n    mp4: 'video/mp4',\n    webm: 'video/webm',\n    mov: 'video/quicktime',\n    avi: 'video/x-msvideo',\n    mkv: 'video/x-matroska',\n    // Audio\n    mp3: 'audio/mpeg',\n    wav: 'audio/wav',\n    ogg: 'audio/ogg',\n    m4a: 'audio/mp4',\n    flac: 'audio/flac',\n    // Text\n    txt: 'text/plain',\n    md: 'text/markdown',\n    json: 'application/json',\n    xml: 'application/xml',\n    html: 'text/html',\n    css: 'text/css',\n    js: 'text/javascript',\n    // Documents\n    pdf: 'application/pdf',\n  };\n  return ext ? mimeTypes[ext] || null : null;\n}\n\n/** Get friendly type name from MIME type */\nfunction getFriendlyType(mime: string | undefined): string | null {\n  if (!mime) return null;\n\n  const friendlyNames: Record<string, string> = {\n    'application/pdf': 'PDF',\n    'application/json': 'JSON',\n    'text/plain': 'Text',\n    'text/markdown': 'Markdown',\n    'image/jpeg': 'JPEG',\n    'image/png': 'PNG',\n    'image/gif': 'GIF',\n    'image/webp': 'WebP',\n    'image/svg+xml': 'SVG',\n    'video/mp4': 'MP4',\n    'video/webm': 'WebM',\n    'audio/mpeg': 'MP3',\n    'audio/wav': 'WAV',\n  };\n\n  if (friendlyNames[mime]) return friendlyNames[mime];\n\n  // Fallback: extract subtype\n  const parts = mime.split('/');\n  if (parts.length === 2) {\n    let subtype = parts[1].replace(/^x-/, '').replace(/^vnd\\./, '').split('.')[0];\n    return subtype.charAt(0).toUpperCase() + subtype.slice(1);\n  }\n  return null;\n}\n\n/** Get content category for rendering */\nfunction getContentCategory(\n  contentType: string | undefined,\n  uri: string\n): 'image' | 'video' | 'audio' | 'text' | 'file' {\n  if (contentType?.startsWith('image/')) return 'image';\n  if (contentType?.startsWith('video/')) return 'video';\n  if (contentType?.startsWith('audio/')) return 'audio';\n  if (contentType === 'text/plain' || contentType === 'text/markdown') return 'text';\n  return 'file';\n}\n\nexport function FilePreview({\n  file,\n  index,\n  onLoad,\n  onError,\n  buttons = true,\n  clickable = true,\n  autoplay = false,\n  objectFit = 'contain',\n  card = true,\n  draggable = true,\n  className,\n}: FilePreviewProps) {\n  const [contentType, setContentType] = useState(file.content_type);\n  const [copied, setCopied] = useState(false);\n\n  useEffect(() => {\n    if (!file.content_type) {\n      const guessed = guessContentType(file.uri);\n      if (guessed) {\n        setContentType(guessed);\n      }\n    }\n  }, [file.content_type, file.uri]);\n\n  const handleDragStart = useCallback(\n    (e: React.DragEvent<HTMLDivElement>) => {\n      if (!draggable) return;\n      e.dataTransfer.setData('text/uri-list', file.uri);\n      e.dataTransfer.setData('text/plain', file.uri);\n      e.dataTransfer.effectAllowed = 'copyMove';\n    },\n    [draggable, file.uri]\n  );\n\n  const handleCopyUrl = useCallback(\n    (e: React.MouseEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n      navigator.clipboard.writeText(file.uri);\n      setCopied(true);\n      setTimeout(() => setCopied(false), 1500);\n    },\n    [file.uri]\n  );\n\n  const handleOpenExternal = useCallback(\n    (e: React.MouseEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n      window.open(file.uri, '_blank');\n    },\n    [file.uri]\n  );\n\n  const category = getContentCategory(contentType, file.uri);\n\n  const renderContent = () => {\n    switch (category) {\n      case 'image':\n        return (\n          <img\n            src={file.uri}\n            alt={file.filename || 'Image'}\n            className={cn(\n              'rounded-md w-full h-full',\n              objectFit === 'cover' ? 'object-cover' : 'object-contain'\n            )}\n            onLoad={onLoad}\n            onError={() => onError?.()}\n          />\n        );\n\n      case 'video':\n        return (\n          <video\n            controls={!autoplay && clickable}\n            autoPlay={autoplay}\n            preload=\"auto\"\n            loop={autoplay}\n            muted={autoplay}\n            playsInline={autoplay}\n            className={cn(\n              'rounded-md w-full h-full',\n              objectFit === 'cover' ? 'object-cover' : 'object-contain'\n            )}\n            onLoadedData={onLoad}\n            onError={() => onError?.()}\n          >\n            <source src={file.uri} type={contentType || undefined} />\n            Your browser does not support the video tag.\n          </video>\n        );\n\n      case 'audio':\n        return (\n          <div className=\"flex items-center justify-center p-4 w-full\">\n            <audio controls className=\"w-full max-w-md\" onLoadedData={onLoad} onError={() => onError?.()}>\n              <source src={file.uri} type={contentType || undefined} />\n              Your browser does not support the audio tag.\n            </audio>\n          </div>\n        );\n\n      case 'text':\n        return (\n          <div className=\"p-4 font-mono text-xs whitespace-pre-wrap break-words max-h-[300px] overflow-y-auto text-muted-foreground\">\n            <TextPreview url={file.uri} onLoad={onLoad} onError={onError} />\n          </div>\n        );\n\n      case 'file':\n      default:\n        const friendlyType = getFriendlyType(contentType);\n        return (\n          <div\n            className=\"flex flex-col items-center justify-center gap-2 p-4 w-full h-full min-h-[120px] text-xs text-muted-foreground cursor-pointer hover:bg-muted/10 transition-colors\"\n            onClick={handleOpenExternal}\n          >\n            <FileIcon className=\"h-8 w-8 text-muted-foreground\" />\n            <span className=\"text-foreground font-mono text-center truncate max-w-full px-2\">\n              {file.filename || 'Unknown file'}\n            </span>\n            {friendlyType && <span className=\"text-muted-foreground/60 text-[10px]\">{friendlyType}</span>}\n          </div>\n        );\n    }\n  };\n\n  return (\n    <div\n      key={file.uri || index}\n      className={cn(\n        'relative w-full h-full group overflow-hidden',\n        card && 'border rounded-xl bg-background',\n        draggable && 'cursor-grab active:cursor-grabbing',\n        className\n      )}\n      draggable={draggable}\n      onDragStart={handleDragStart}\n    >\n      {renderContent()}\n      {buttons && (\n        <div className=\"absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10\">\n          <Button\n            onClick={handleCopyUrl}\n            variant=\"outline\"\n            size=\"icon\"\n            className=\"h-8 w-8\"\n            title=\"Copy URL\"\n          >\n            {copied ? <Check className=\"h-3 w-3 text-green-500\" /> : <LinkIcon className=\"h-3 w-3\" />}\n          </Button>\n          <Button\n            onClick={handleOpenExternal}\n            variant=\"outline\"\n            size=\"icon\"\n            className=\"h-8 w-8\"\n            title=\"Open in new tab\"\n          >\n            <ExternalLinkIcon className=\"h-3 w-3\" />\n          </Button>\n          <Button\n            onClick={handleOpenExternal}\n            variant=\"outline\"\n            size=\"icon\"\n            className=\"h-8 w-8\"\n            title=\"Download\"\n          >\n            <DownloadIcon className=\"h-3 w-3\" />\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n}\n\n/** Simple text preview that fetches and displays text content */\nfunction TextPreview({\n  url,\n  onLoad,\n  onError,\n}: {\n  url: string;\n  onLoad?: () => void;\n  onError?: () => void;\n}) {\n  const [content, setContent] = useState<string | null>(null);\n  const [error, setError] = useState<string | null>(null);\n\n  useEffect(() => {\n    fetch(url)\n      .then((res) => {\n        if (!res.ok) throw new Error('Failed to fetch');\n        return res.text();\n      })\n      .then((text) => {\n        setContent(text);\n        onLoad?.();\n      })\n      .catch((err) => {\n        setError(err.message);\n        onError?.();\n      });\n  }, [url, onLoad, onError]);\n\n  if (error) return <span className=\"text-red-500\">Failed to load: {error}</span>;\n  if (content === null) return <span className=\"text-muted-foreground/50\">Loading...</span>;\n  return <>{content}</>;\n}\n",
      "type": "registry:component",
      "target": "components/infsh/task/file-preview.tsx"
    },
    {
      "path": "registry/blocks/task/time-since.tsx",
      "content": "'use client';\n\n/**\n * TimeSince Component\n *\n * Displays elapsed time between a start and optional end time.\n * Updates in real-time when no end time is provided.\n */\n\nimport React, { memo, useEffect, useRef, useState } from 'react';\nimport { cn } from '@/lib/utils';\n\n/** Format milliseconds to human-readable duration */\nfunction formatDuration(ms: number): string {\n  if (ms < 1000) return `${ms}ms`;\n\n  const totalSeconds = ms / 1000;\n  if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`;\n\n  const totalMinutes = totalSeconds / 60;\n  if (totalMinutes < 60) {\n    const minutes = Math.floor(totalMinutes);\n    const seconds = Math.round(totalSeconds % 60);\n    return `${minutes}m${seconds}s`;\n  }\n\n  const totalHours = totalMinutes / 60;\n  const hours = Math.floor(totalHours);\n  const minutes = Math.floor(totalMinutes % 60);\n  const seconds = Math.round(totalSeconds % 60);\n  return `${hours}h${minutes}m${seconds}s`;\n}\n\n/** Get smart update interval based on elapsed time */\nfunction getSmartInterval(ms: number): number {\n  if (ms < 1000) return 20; // Update every 20ms for ms display\n  if (ms < 60_000) return 100; // Update every 100ms for < 1m\n  if (ms < 3_600_000) return 1000; // Update every second for < 1h\n  return 60_000; // Update every minute for >= 1h\n}\n\nexport interface TimeSinceProps {\n  /** Start time (Date, timestamp, or ISO string) */\n  start: string | number | Date | undefined;\n  /** Optional end time - if not provided, shows live elapsed time */\n  end?: string | number | Date | undefined;\n  /** Additional CSS classes */\n  className?: string;\n  /** Wrap the time in parentheses */\n  parentheses?: boolean;\n}\n\nexport const TimeSince = memo(function TimeSince({\n  start,\n  end,\n  className,\n  parentheses = false,\n}: TimeSinceProps) {\n  const [now, setNow] = useState(Date.now());\n  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);\n\n  useEffect(() => {\n    // Don't update if end is provided (static display)\n    if (end) return;\n\n    function update() {\n      setNow(Date.now());\n    }\n\n    const startTime = start ? new Date(start).getTime() : 0;\n    const endTime = end ? new Date(end).getTime() : now;\n    const diff = Math.max(0, endTime - startTime);\n    const interval = getSmartInterval(diff);\n\n    if (timerRef.current) clearInterval(timerRef.current);\n    timerRef.current = setInterval(update, interval);\n\n    return () => {\n      if (timerRef.current) clearInterval(timerRef.current);\n    };\n  }, [start, end, now]);\n\n  if (!start) return <span className={className}>-</span>;\n\n  const startTime = new Date(start).getTime();\n  const endTime = end ? new Date(end).getTime() : now;\n\n  if (isNaN(startTime) || isNaN(endTime)) {\n    return <span className={className}>?</span>;\n  }\n\n  const diff = Math.max(0, endTime - startTime);\n\n  if (diff === 0) return <span className={className}></span>;\n\n  const formatted = formatDuration(diff);\n\n  return (\n    <span className={cn('text-xs', className)}>\n      {parentheses ? `(${formatted})` : formatted}\n    </span>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/infsh/task/time-since.tsx"
    },
    {
      "path": "registry/blocks/task/use-task.ts",
      "content": "/**\n * useTask Hook\n *\n * A standalone hook for fetching and streaming task updates from inference.sh.\n * Uses the SDK's StreamManager for real-time updates.\n */\n\nimport { useState, useEffect, useRef, useCallback } from 'react';\nimport type { TaskDTO as Task } from '@inferencesh/sdk';\nimport type { AgentClient } from '@inferencesh/sdk/agent';\nimport {\n  StreamableManager,\n  TaskStatusCompleted,\n  TaskStatusFailed,\n  TaskStatusCancelled\n} from '@inferencesh/sdk';\n\nexport interface UseTaskOptions {\n  /** The inference client instance (AgentClient compatible) */\n  client: AgentClient;\n  /** The task ID to fetch and stream */\n  taskId: string;\n  /** Called when task data updates */\n  onUpdate?: (task: Task) => void;\n  /** Called when task reaches terminal status */\n  onComplete?: (task: Task) => void;\n  /** Called when an error occurs */\n  onError?: (error: Error) => void;\n  /** Auto-reconnect on connection loss (default: true) */\n  autoReconnect?: boolean;\n  /** Maximum reconnection attempts (default: 5) */\n  maxReconnects?: number;\n}\n\nexport interface UseTaskResult {\n  /** The current task data */\n  task: Task | null;\n  /** Whether the initial fetch is loading */\n  isLoading: boolean;\n  /** Whether streaming is active */\n  isStreaming: boolean;\n  /** Any error that occurred */\n  error: Error | null;\n  /** Refetch the task */\n  refetch: () => Promise<void>;\n  /** Stop streaming */\n  stopStream: () => void;\n}\n\n/** Check if a task status is terminal (completed, failed, or cancelled) */\nexport function isTerminalStatus(status: number | undefined): boolean {\n  return status !== undefined && [\n    TaskStatusCompleted,\n    TaskStatusFailed,\n    TaskStatusCancelled\n  ].includes(status);\n}\n\n/**\n * Hook for fetching and streaming task updates\n *\n * @example\n * ```tsx\n * const client = new Inference({ apiKey: 'your-key' });\n * const { task, isLoading, isStreaming } = useTask({\n *   client,\n *   taskId: 'abc123'\n * });\n * ```\n */\nexport function useTask({\n  client,\n  taskId,\n  onUpdate,\n  onComplete,\n  onError,\n  autoReconnect = true,\n  maxReconnects = 5,\n}: UseTaskOptions): UseTaskResult {\n  const [task, setTask] = useState<Task | null>(null);\n  const [isLoading, setIsLoading] = useState(true);\n  const [isStreaming, setIsStreaming] = useState(false);\n  const [error, setError] = useState<Error | null>(null);\n\n  const streamManagerRef = useRef<StreamableManager<Task> | null>(null);\n  const taskRef = useRef<Task | null>(null);\n\n  // Keep task ref in sync\n  useEffect(() => {\n    taskRef.current = task;\n  }, [task]);\n\n  const stopStream = useCallback(() => {\n    if (streamManagerRef.current) {\n      streamManagerRef.current.stop();\n      streamManagerRef.current = null;\n    }\n    setIsStreaming(false);\n  }, []);\n\n  const startStream = useCallback(() => {\n    if (!taskId || !client) return;\n\n    // Stop any existing stream\n    stopStream();\n\n    const { url, headers } = client.http.getStreamableConfig(`/tasks/${taskId}/stream`);\n\n    const manager = new StreamableManager<Task>({\n      url,\n      headers,\n      onStart: () => setIsStreaming(true),\n      onEnd: () => setIsStreaming(false),\n      onError: (err) => {\n        setError(err);\n        onError?.(err);\n      },\n      onData: (taskData: Task) => {\n        setTask(taskData);\n        onUpdate?.(taskData);\n\n        if (isTerminalStatus(taskData.status)) {\n          onComplete?.(taskData);\n          manager.stop();\n        }\n      },\n      onPartialData: (partialData: Task, fields: string[]) => {\n        const currentTask = taskRef.current;\n        if (currentTask) {\n          // Merge partial updates\n          const updates = fields.reduce((acc, field) => {\n            const key = field as keyof Task;\n            (acc as Record<string, unknown>)[key] = partialData[key];\n            return acc;\n          }, {} as Partial<Task>);\n          const mergedTask = { ...currentTask, ...updates };\n          setTask(mergedTask);\n          onUpdate?.(mergedTask);\n\n          if (isTerminalStatus(mergedTask.status)) {\n            onComplete?.(mergedTask);\n            manager.stop();\n          }\n        } else {\n          setTask(partialData);\n          onUpdate?.(partialData);\n        }\n      },\n    });\n\n    streamManagerRef.current = manager;\n    manager.start();\n  }, [taskId, client, autoReconnect, maxReconnects, onUpdate, onComplete, onError, stopStream]);\n\n  const fetchTask = useCallback(async () => {\n    if (!taskId || !client) return;\n\n    setIsLoading(true);\n    setError(null);\n\n    try {\n      const taskData = await client.http.request<Task>('get', `/tasks/${taskId}`);\n\n      setTask(taskData);\n      onUpdate?.(taskData);\n\n      // Start streaming if task is not terminal\n      if (!isTerminalStatus(taskData.status)) {\n        startStream();\n      } else {\n        onComplete?.(taskData);\n      }\n    } catch (err) {\n      const error = err instanceof Error ? err : new Error('Failed to fetch task');\n      setError(error);\n      onError?.(error);\n    } finally {\n      setIsLoading(false);\n    }\n  }, [taskId, client, onUpdate, onComplete, onError, startStream]);\n\n  // Fetch task on mount and when taskId changes\n  useEffect(() => {\n    fetchTask();\n\n    return () => {\n      stopStream();\n    };\n  }, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  return {\n    task,\n    isLoading,\n    isStreaming,\n    error,\n    refetch: fetchTask,\n    stopStream,\n  };\n}\n",
      "type": "registry:hook",
      "target": "hooks/use-task.ts"
    },
    {
      "path": "registry/blocks/task/index.ts",
      "content": "/**\n * Task Output Components\n *\n * Standalone components for displaying task output with streaming support.\n */\n\n// Main component\nexport { TaskOutput } from './task-output';\nexport type { TaskOutputProps } from './task-output';\n\n// Hook\nexport { useTask, isTerminalStatus } from '@/hooks/use-task';\nexport type { UseTaskOptions, UseTaskResult } from '@/hooks/use-task';\n\n// Status components\nexport {\n  StatusPill,\n  StatusPillSimple,\n  getStatusColor,\n  getStatusText,\n  getStatusTextFull,\n  extractTaskEventTimes,\n} from './task-status';\nexport type { StatusPillProps, StatusPillSimpleProps, TaskEventTimes } from './task-status';\n\n// Logs components\nexport { TaskLogs, SimpleLogs, LogViewer } from './task-logs';\nexport type { TaskLogsProps, SimpleLogsProps } from './task-logs';\n\n// Output fields components\nexport { OutputField, OutputFields, isFile, isUrl } from './output-fields';\nexport type { OutputFieldProps, OutputFieldsProps, Field } from './output-fields';\n\n// File preview component\nexport { FilePreview } from './file-preview';\nexport type { FilePreviewProps, PartialFile } from './file-preview';\n\n// Time component\nexport { TimeSince } from './time-since';\nexport type { TimeSinceProps } from './time-since';\n\n// Wrapper component for easy integration\nexport { TaskOutputWrapper } from './task-output-wrapper';\nexport type { TaskOutputWrapperProps } from './task-output-wrapper';\n",
      "type": "registry:lib",
      "target": "components/infsh/task/index.ts"
    }
  ],
  "type": "registry:block"
}