oxios 1.12.0

Oxios Agent OS — Agent Operating System powered by oxi-sdk
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChatInput, type ContextAttachment } from '@/components/chat/chat-input'
import { EmptyChatState } from '@/components/chat/empty-chat-state'
import { InterviewWizard } from '@/components/chat/interview-wizard'
import { MessageBubble } from '@/components/chat/message-bubble'
import { ToolApprovalCard } from '@/components/chat/tool-approval-card'
import { TypingIndicator } from '@/components/chat/typing-indicator'
import { MountDetectionBadge } from '@/components/mount/mount-detection-badge'
import { AiDetectionBadge } from '@/components/project/ai-detection-badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useChatStore } from '@/stores/chat'

export const Route = createFileRoute('/chat')({ component: ChatPage })

// ---------------------------------------------------------------------------
// Chat UI — Claude-inspired centered layout
// ---------------------------------------------------------------------------

function ChatPage() {
  const { t } = useTranslation()
  const {
    messages,
    isStreaming,
    connected,
    activeSessionId,
    activeProjectId,
    detectedProject,
    activeInterview,
    interviewRound,
    interviewAmbiguity,
    sendMessage,
    setActiveProject,
    dismissDetection,
    submitInterviewResponse,
    activeToolApproval,
    resolveToolApproval,
    disconnect,
    connect,
    newSession,
  } = useChatStore()

  const [input, setInput] = useState('')
  const [userScrolledUp, setUserScrolledUp] = useState(false)
  const bottomRef = useRef<HTMLDivElement>(null)
  const scrollAreaRef = useRef<HTMLDivElement>(null)

  // Auto-scroll to bottom on new messages, but only if user hasn't scrolled up
  useEffect(() => {
    if (userScrolledUp) return
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages, isStreaming, userScrolledUp])

  // Auto-connect WebSocket on mount
  useEffect(() => {
    connect()
  }, [connect])

  // Keyboard shortcuts
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      const mod = e.metaKey || e.ctrlKey
      if (mod && e.shiftKey && e.key.toLowerCase() === 'n') {
        e.preventDefault()
        newSession()
      }
    }
    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [newSession])

  const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
    const el = e.currentTarget
    const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80
    setUserScrolledUp(!atBottom)
  }

  const handleSend = (content: string, contextItems: ContextAttachment[]) => {
    if (!content.trim() || isStreaming) return

    // Build message with context references
    let enrichedContent = content

    // If there are context attachments, append them as structured references
    if (contextItems.length > 0) {
      const contextRefs = contextItems
        .map((ctx) => {
          if (ctx.type === 'knowledge') {
            return `[context:knowledge:${ctx.id}]`
          }
          return `[context:memory:${ctx.id}]`
        })
        .join(' ')
      enrichedContent = `${content}\n${contextRefs}`
    }

    sendMessage(enrichedContent)
    setInput('')
    setUserScrolledUp(false)
  }

  const handleCancel = () => {
    disconnect()
    setTimeout(() => connect(), 100)
  }

  return (
    <div className="flex h-full">
      <div className="flex flex-1 flex-col min-w-0">
        {/* Reconnect warning banner */}
        {!connected && (
          <div className="flex items-center gap-2 px-4 py-2 bg-warning/10 text-warning text-xs border-b">
            <span className="h-2 w-2 rounded-full bg-warning animate-pulse shrink-0" />
            <span>{t('chat.reconnecting', 'Reconnecting...')}</span>
          </div>
        )}

        {/* AI Detection Badge */}
        {detectedProject && !activeProjectId && (
          <AiDetectionBadge
            project={detectedProject}
            onApply={() => setActiveProject(detectedProject.id)}
            onDismiss={() => dismissDetection(detectedProject.id)}
          />
        )}

        {/* RFC-025: Mount Detection Badge */}
        <MountDetectionBadge />

        {/* ── Messages area ── */}
        <ScrollArea
          ref={scrollAreaRef as any}
          className="flex-1 min-h-0"
          onScroll={handleScroll}
          role="log"
          aria-label={t('common.chatMessages')}
        >
          <div className="max-w-3xl mx-auto px-4 py-6">
            {messages.length === 0 && <EmptyChatState />}
            <div className="space-y-5">
              {messages.map((msg, _idx) => {
                // Compute assistant-only index for knowledge save tracking
                const assistantIndex =
                  msg.role === 'assistant'
                    ? messages.slice(0, _idx).filter((m) => m.role === 'assistant').length
                    : undefined
                return (
                  <MessageBubble
                    key={msg.id}
                    message={msg}
                    sessionId={activeSessionId ?? undefined}
                    assistantIndex={assistantIndex}
                  />
                )
              })}

              {/* Interview wizard */}
              {activeInterview && activeInterview.length > 0 && (
                <InterviewWizard
                  questions={activeInterview}
                  round={interviewRound}
                  ambiguity={interviewAmbiguity}
                  onSubmit={submitInterviewResponse}
                  disabled={isStreaming}
                />
              )}

              {/* Tool approval */}
              {activeToolApproval && (
                <ToolApprovalCard
                  toolName={activeToolApproval.toolName}
                  reason={activeToolApproval.reason}
                  onApprove={() => resolveToolApproval(activeToolApproval.id, true)}
                  onDeny={() => resolveToolApproval(activeToolApproval.id, false)}
                  disabled={isStreaming}
                />
              )}

              {/* Typing indicator */}
              {isStreaming && !activeInterview && !activeToolApproval && <TypingIndicator />}

              <div ref={bottomRef} />
            </div>
          </div>
        </ScrollArea>

        {/* ── Input (fixed at bottom) ── */}
        {!activeInterview && (
          <div className="border-t bg-background/95 backdrop-blur-sm shrink-0">
            <ChatInput
              value={input}
              onChange={setInput}
              onSend={handleSend}
              onCancel={handleCancel}
              disabled={isStreaming}
              isStreaming={isStreaming}
              connected={connected}
            />
          </div>
        )}
      </div>
    </div>
  )
}