oxios 1.5.2

Oxios Agent OS — Agent Operating System powered by oxi-sdk
import {
  Brain,
  ChevronDown,
  ChevronRight,
  Clock,
  Cpu,
  Loader2,
  Sparkles,
  Wrench,
} from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import type { ChatActivity } from '@/types'
import { BrowseContextBadge } from './browse-context-badge'
import { BrowseContextDetail } from './browse-context-detail'

interface ActivityCardProps {
  activity: ChatActivity
  className?: string
}

/**
 * RFC-015: a single transparency activity entry. Collapsed by default
 * (header only), expanded on click. Mirrors the visual rhythm of the
 * Claude Code / Gemini tool-call cards.
 */
export function ActivityCard({ activity, className }: ActivityCardProps) {
  const { t } = useTranslation()
  const [expanded, setExpanded] = useState(false)
  const { icon, label, badge } = getActivityMeta(activity, t)
  const durationStr = formatDuration(activity.durationMs)

  return (
    <div className={cn('rounded-lg border bg-muted/30 text-xs', className)}>
      <button
        type="button"
        onClick={() => setExpanded((v) => !v)}
        className="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-muted/50"
        aria-expanded={expanded}
      >
        {expanded ? (
          <ChevronDown className="h-3 w-3 shrink-0" />
        ) : (
          <ChevronRight className="h-3 w-3 shrink-0" />
        )}
        <span className="shrink-0 text-muted-foreground">{icon}</span>
        <span className="font-medium truncate">{label}</span>
        {activity.type === 'tool_call' && activity.isRunning && (
          <Loader2 className="h-3 w-3 animate-spin text-muted-foreground shrink-0" />
        )}
        {activity.type === 'tool_call' && activity.tabId && (
          <span
            className="text-2xs text-muted-foreground/70 font-mono shrink-0"
            title={`Browser tab ${activity.tabId}`}
          >
            {activity.tabId.slice(0, 8)}
          </span>
        )}
        {activity.type === 'tool_call' && activity.progress && !activity.context && (
          <span className="text-2xs text-muted-foreground truncate max-w-[40ch]">
            {activity.progress}
          </span>
        )}
        {activity.type === 'tool_call' && activity.context && (
          <BrowseContextBadge context={activity.context} />
        )}
        {badge}
        {durationStr && (
          <span className="ml-auto flex items-center gap-1 text-muted-foreground">
            <Clock className="h-3 w-3" /> {durationStr}
          </span>
        )}
      </button>
      {expanded && (
        <div className="border-t px-3 py-2 space-y-2">
          <ActivityDetail activity={activity} t={t} />
        </div>
      )}
    </div>
  )
}

type Translator = (key: string, opts?: Record<string, unknown>) => string

function ActivityDetail({ activity, t }: { activity: ChatActivity; t: Translator }) {
  switch (activity.type) {
    case 'tool_call':
      return (
        <>
          {activity.context && <BrowseContextDetail context={activity.context} />}
          {activity.toolArgs !== undefined && (
            <div>
              <p className="text-2xs font-medium text-muted-foreground mb-1 uppercase tracking-wider">
                {t('chat.transparency.input')}
              </p>
              <pre className="text-xs bg-background rounded p-2 overflow-x-auto whitespace-pre-wrap max-h-40 overflow-y-auto">
                {typeof activity.toolArgs === 'string'
                  ? activity.toolArgs
                  : JSON.stringify(activity.toolArgs, null, 2)}
              </pre>
            </div>
          )}
          {activity.outputSummary !== undefined && (
            <div>
              <p className="text-2xs font-medium text-muted-foreground mb-1 uppercase tracking-wider">
                {t('chat.transparency.output')}
              </p>
              <pre className="text-xs bg-background rounded p-2 overflow-x-auto whitespace-pre-wrap max-h-40 overflow-y-auto">
                {activity.outputSummary}
              </pre>
            </div>
          )}
        </>
      )
    case 'memory': {
      const y = activity.count === 1 ? 'y' : 'ies'
      const q = activity.query
      const src = activity.memorySource
      const base = t('chat.transparency.memoryRecall', { count: activity.count ?? 0, y })
      const recallFor = q
        ? t('chat.transparency.memoryRecallFor', {
            count: activity.count ?? 0,
            y,
            query: truncate(q, 60),
          })
        : null
      const srcSuffix = src ? t('chat.transparency.memorySource', { source: src }) : ''
      return (
        <p className="text-xs text-muted-foreground">
          {recallFor ?? base}
          {srcSuffix}
        </p>
      )
    }
    case 'reasoning':
      return (
        <p className="text-xs italic text-muted-foreground whitespace-pre-wrap">
          {activity.content}
        </p>
      )
    case 'usage':
      return (
        <p className="text-xs text-muted-foreground">
          {t('chat.transparency.inputOutputTokens', {
            in: activity.inputTokens ?? 0,
            out: activity.outputTokens ?? 0,
          })}
        </p>
      )
    case 'phase':
      return null
    default:
      return null
  }
}

function getActivityMeta(
  activity: ChatActivity,
  t: Translator,
): {
  icon: React.ReactNode
  label: string
  badge: React.ReactNode
} {
  switch (activity.type) {
    case 'tool_call':
      return {
        icon: <Wrench className="h-3 w-3" />,
        label: activity.toolName ?? 'tool',
        badge: activity.isError ? (
          <span className="text-2xs px-1.5 py-0.5 rounded bg-error/10 text-error font-medium">
            {t('chat.transparency.error')}
          </span>
        ) : null,
      }
    case 'memory':
      return {
        icon: <Brain className="h-3 w-3" />,
        label: t('chat.transparency.memoryRecall', {
          count: activity.count ?? 0,
          y: activity.count === 1 ? 'y' : 'ies',
        }),
        badge: (
          <span className="text-2xs px-1.5 py-0.5 rounded bg-info/10 text-info font-medium">
            {activity.memoryAction ?? 'recall'}
          </span>
        ),
      }
    case 'reasoning':
      return {
        icon: <Sparkles className="h-3 w-3" />,
        label: activity.reasoningSource ?? t('chat.transparency.reasoning'),
        badge: null,
      }
    case 'usage':
      return {
        icon: <Cpu className="h-3 w-3" />,
        label: t('chat.transparency.tokenCount', {
          count: (activity.inputTokens ?? 0) + (activity.outputTokens ?? 0),
        }),
        badge: null,
      }
    case 'phase':
      return {
        icon: <Sparkles className="h-3 w-3" />,
        label: t('chat.transparency.phaseLabel', {
          phase: activity.phase ?? 'unknown',
        }),
        badge: (
          <span className="text-2xs px-1.5 py-0.5 rounded bg-info/10 text-info font-medium">
            {activity.status ?? 'started'}
          </span>
        ),
      }
  }
}

function formatDuration(ms?: number): string | null {
  if (!ms) return null
  if (ms < 1000) return `${ms}ms`
  return `${(ms / 1000).toFixed(1)}s`
}

function truncate(s: string, n: number): string {
  return s.length > n ? `${s.slice(0, n)}…` : s
}