oxios 1.5.2

Oxios Agent OS — Agent Operating System powered by oxi-sdk
import { Bot, ClipboardList, User, Wrench } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
import rehypeHighlight from 'rehype-highlight'
import remarkGfm from 'remark-gfm'
import type { ChatMessage } from '@/types'
import { ActivityTimeline } from './activity-timeline'
import { ChatMetadata } from './chat-metadata'
import { KnowledgeSaveIndicator } from './knowledge-save-indicator'
import { ToolCallCard } from './tool-call-card'

interface MessageBubbleProps {
  message: ChatMessage
  /** Session ID for knowledge save tracking (RFC-016). */
  sessionId?: string
  /** Index of this message among assistant messages only (RFC-016). */
  assistantIndex?: number
}

export function MessageBubble({ message, sessionId, assistantIndex }: MessageBubbleProps) {
  const { t } = useTranslation()
  const isUser = message.role === 'user'
  const isTool = message.role === 'tool'

  // Relative timestamp — i18n aware
  const relTime = (() => {
    if (!message.timestamp) return ''
    const diff = Date.now() - new Date(message.timestamp).getTime()
    if (diff < 60000) return t('common.justNow', 'just now')
    if (diff < 3600000) return t('common.minutesAgo', { count: Math.floor(diff / 60000) })
    if (diff < 86400000) return t('common.hoursAgo', { count: Math.floor(diff / 3600000) })
    return new Date(message.timestamp).toLocaleDateString()
  })()

  if (isTool) {
    return (
      <div className="flex gap-3 my-1">
        <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
          <span className="text-xs">
            <Wrench className="h-3.5 w-3.5" />
          </span>
        </div>
        <div className="flex-1">
          {message.toolName && (
            <ToolCallCard
              call={{
                tool_name: message.toolName,
                input:
                  typeof message.toolArgs === 'string'
                    ? message.toolArgs
                    : JSON.stringify(message.toolArgs ?? '', null, 2),
                output:
                  typeof message.toolResult === 'string'
                    ? message.toolResult
                    : JSON.stringify(message.toolResult ?? '', null, 2),
                duration_ms: message.toolDurationMs ?? 0,
              }}
            />
          )}
        </div>
      </div>
    )
  }

  return (
    <div className={`flex gap-3 my-1.5 ${isUser ? 'justify-end' : ''}`}>
      {!isUser && (
        <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
          <Bot className="h-4 w-4" />
        </div>
      )}
      <div className="max-w-[80%]">
        <div
          className={`rounded-lg px-4 py-2 ${
            isUser ? 'bg-primary text-primary-foreground' : 'bg-muted'
          }`}
        >
          {/* Interview questions summary (persisted after submit) */}
          {!isUser && message._interviewQuestions && message._interviewQuestions.length > 0 && (
            <div className="mb-2">
              <div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground mb-1.5">
                <ClipboardList className="h-3 w-3" />
                <span>
                  Interview{message._interviewRound ? ` R${message._interviewRound}` : ''}
                </span>
              </div>
              <div className="space-y-1">
                {message._interviewQuestions.map((q, i) => (
                  <p key={q.id} className="text-xs text-muted-foreground">
                    {i + 1}. {q.text}
                  </p>
                ))}
              </div>
            </div>
          )}
          {isUser ? (
            <p className="text-sm whitespace-pre-wrap">{message.content}</p>
          ) : message.content ? (
            <div className="text-sm prose prose-sm dark:prose-invert max-w-none [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
              <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
                {message.content}
              </ReactMarkdown>
            </div>
          ) : null}
        </div>
        {/* RFC-015: real-time activity timeline (tool calls, memory,
            reasoning, token usage). Hidden for user messages. */}
        {!isUser && message.activities && message.activities.length > 0 && (
          <ActivityTimeline activities={message.activities} />
        )}
        <div className="flex items-center gap-2 mt-1.5">
          {relTime && <span className="text-2xs text-muted-foreground">{relTime}</span>}
          {!isUser && <ChatMetadata message={message} />}
        </div>
        {/* RFC-016: Knowledge save toggle — assistant messages only */}
        {!isUser && sessionId && assistantIndex !== undefined && (
          <KnowledgeSaveIndicator sessionId={sessionId} messageIndex={assistantIndex} />
        )}
      </div>
      {isUser && (
        <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
          <User className="h-4 w-4" />
        </div>
      )}
    </div>
  )
}