oxios 1.23.2

Oxios Agent OS — Agent Operating System powered by oxi-sdk
import { createFileRoute } from '@tanstack/react-router'
import { RefreshCw } from 'lucide-react'
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 { LiveActivityBar } from '@/components/chat/live-activity-bar'
import { MessageBubble } from '@/components/chat/message-bubble'
import { ToolApprovalCard } from '@/components/chat/tool-approval-card'
import { MountDetectionBadge } from '@/components/mount/mount-detection-badge'
import { AiDetectionBadge } from '@/components/project/ai-detection-badge'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useRoles } from '@/hooks/use-engine'
import { useMounts } from '@/hooks/use-mounts'
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,
    activeRole,
    activeModelId,
    activeMountIds,
    setActiveMountIds,
    sendMessage,
    setActiveProject,
    setActiveRole,
    setActiveModelId,
    dismissDetection,
    submitInterviewResponse,
    activeToolApproval,
    resolveToolApproval,
    disconnect,
    connect,
    newSession,
  } = useChatStore()
  const queuedCount = useChatStore((s) => s._pendingQueue.length)
  const { data: rolesData } = useRoles()
  const roles = Object.entries(rolesData?.roles ?? {}).map(([name, model]) => ({ name, model }))
  const { data: mountsData } = useMounts()
  const activeMountIdsArr = activeMountIds ? activeMountIds.split(',').filter(Boolean) : []
  const activeMounts = activeMountIdsArr
    .map((id) => {
      const m = mountsData?.items?.find((x) => x.id === id)
      return m ? { id: m.id, label: m.name } : null
    })
    .filter((x): x is { id: string; label: string } => x !== null)

  const handleAttachMount = (id: string) => {
    const cur = activeMountIds ? activeMountIds.split(',').filter(Boolean) : []
    if (cur.includes(id)) return
    setActiveMountIds([...cur, id])
  }
  const handleRemoveMount = (id: string) => {
    const cur = activeMountIds ? activeMountIds.split(',').filter(Boolean) : []
    setActiveMountIds(cur.filter((x) => x !== id))
  }

  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(() => {
    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()) 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)
  }

  // RFC-032: retry the message that produced an error card. Pop the error
  // bubble AND the user message that preceded it (the store will append a
  // fresh user message when we resend, so leaving the original in place
  // would duplicate it on screen). After removal, scroll the user back to
  // the bottom and re-fire the same send pipeline as their original tap.
  const handleRetry = (errorMessageId: string) => {
    const errIdx = messages.findIndex((m) => m.id === errorMessageId)
    if (errIdx < 0) return
    const precedingUser = [...messages.slice(0, errIdx)].reverse().find((m) => m.role === 'user')
    if (!precedingUser) return
    const { removeMessage } = useChatStore.getState()
    removeMessage?.(errorMessageId)
    removeMessage?.(precedingUser.id)
    handleSend(precedingUser.content, [])
    setUserScrolledUp(false)
  }

  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 className="flex-1">{t('chat.reconnecting')}</span>
            <Button
              size="sm"
              variant="ghost"
              className="h-6 px-2 text-warning hover:text-warning"
              onClick={() => {
                disconnect()
                connect()
              }}
            >
              <RefreshCw className="h-3 w-3 mr-1" />
              {t('chat.retry')}
            </Button>
          </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-1">
              {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}
                    onRetry={msg.metadata?.isError ? () => handleRetry(msg.id) : undefined}
                  />
                )
              })}

              {/* 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}
                />
              )}

              {/* Live activity header (replaces legacy 3-dot typing indicator) */}
              {isStreaming && !activeInterview && !activeToolApproval && <LiveActivityBar />}

              <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}
              roles={roles}
              activeRole={activeRole}
              setActiveRole={setActiveRole}
              activeModelId={activeModelId}
              setActiveModelId={setActiveModelId}
              onCancel={handleCancel}
              isStreaming={isStreaming}
              connected={connected}
              activeMounts={activeMounts}
              queuedCount={queuedCount}
              onAttachMount={handleAttachMount}
              onRemoveMount={handleRemoveMount}
            />
          </div>
        )}
      </div>
    </div>
  )
}