oxios 1.23.2

Oxios Agent OS — Agent Operating System powered by oxi-sdk
import { useRouter } from '@tanstack/react-router'
import { Check, Copy, MessageSquarePlus, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ChatInput } from '@/components/chat/chat-input'
import { MessageBubble } from '@/components/chat/message-bubble'
import { ToolApprovalCard } from '@/components/chat/tool-approval-card'
import { Button } from '@/components/ui/button'
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useEngineConfig } from '@/hooks/use-engine'
import { api } from '@/lib/api-client'
import { useChatStore } from '@/stores/chat'
import { useQuickAskStore } from '@/stores/quick-ask'

/**
 * QuickAskDialog — global one-shot question overlay.
 *
 * Renders in AppLayout so it overlays every route. Sends `ephemeral: true`
 * over its own short-lived WS; nothing is persisted. Uses the same ChatInput
 * as the regular chat page so the UX (model picker, queue, stop, streaming)
 * is identical — the only difference is that no session is saved.
 */
export function QuickAskDialog() {
  const { t } = useTranslation()
  const router = useRouter()
  const open = useQuickAskStore((s) => s.open)
  const closeQuickAsk = useQuickAskStore((s) => s.closeQuickAsk)
  const messages = useQuickAskStore((s) => s.messages)
  const isStreaming = useQuickAskStore((s) => s.isStreaming)
  const send = useQuickAskStore((s) => s.send)
  const cancel = useQuickAskStore((s) => s.cancel)
  const quickAskModel = useQuickAskStore((s) => s.quickAskModel)
  const setQuickAskModel = useQuickAskStore((s) => s.setQuickAskModel)
  const queuedCount = useQuickAskStore((s) => s._pendingQueue.length)
  const lastExchange = useQuickAskStore((s) => s.lastExchange)
  const activeToolApproval = useQuickAskStore((s) => s.activeToolApproval)
  const resolveToolApproval = useQuickAskStore((s) => s.resolveToolApproval)
  const reset = useQuickAskStore((s) => s.reset)

  // Sync the engine-config one-shot model into the store (single source: Settings).
  const { data: engineConfig } = useEngineConfig()
  useEffect(() => {
    const configured = engineConfig?.quick_ask_model
    const fallback = engineConfig?.default_model || null
    setQuickAskModel(configured || fallback)
  }, [engineConfig?.quick_ask_model, engineConfig?.default_model, setQuickAskModel])

  const [input, setInput] = useState('')
  const [copied, setCopied] = useState(false)
  const scrollRef = useRef<HTMLDivElement>(null)

  // Auto-scroll on new content.
  useEffect(() => {
    scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
  }, [messages, isStreaming])

  // Reset input when the dialog closes.
  useEffect(() => {
    if (!open) {
      setInput('')
      setCopied(false)
    }
  }, [open])

  const handleCopy = async () => {
    const reply = lastExchange?.reply
    if (!reply) return
    try {
      await navigator.clipboard.writeText(reply)
      setCopied(true)
      setTimeout(() => setCopied(false), 2000)
    } catch {
      toast.error(t('quickAsk.copyFailed'))
    }
  }

  const handlePromote = async () => {
    if (!lastExchange) return
    try {
      const res = await api.post<{ session_id: string }>('/api/chat/seed', {
        user_message: lastExchange.prompt,
        agent_response: lastExchange.reply,
        trajectory_steps: lastExchange.activities
          .filter((a) => a.type === 'tool_call')
          .map((a) => ({
            tool: a.toolName,
            input: a.toolArgs,
            output: a.outputSummary,
            duration_ms: a.durationMs,
          })),
        reasoning_text: lastExchange.activities
          .filter((a) => a.type === 'reasoning')
          .map((a) => a.content)
          .join('\n'),
        project_id: undefined,
      })
      closeQuickAsk()
      reset()
      router.history.push('/chat')
      // Seed the chat store so /chat shows the promoted exchange immediately.
      await useChatStore.getState().loadSession(res.session_id)
      toast.success(t('quickAsk.promoted'))
    } catch {
      toast.error(t('quickAsk.promoteFailed'))
    }
  }

  const hasResult = !isStreaming && lastExchange !== null
  const empty = messages.length === 0

  return (
    <Dialog
      open={open}
      onOpenChange={(o) => {
        if (!o) closeQuickAsk()
      }}
    >
      <DialogContent
        showCloseButton={false}
        onOpenAutoFocus={(e) => e.preventDefault()}
        className="flex h-[80vh] max-w-2xl flex-col gap-0 p-0 sm:rounded-xl"
      >
        <DialogHeader className="flex-row items-center justify-between border-b px-5 py-3">
          <div className="flex items-center gap-2">
            <DialogTitle className="text-sm font-medium">{t('quickAsk.title')}</DialogTitle>
          </div>
          <DialogDescription className="sr-only">{t('quickAsk.placeholder')}</DialogDescription>
          <Button
            variant="ghost"
            size="icon"
            className="h-7 w-7"
            onClick={closeQuickAsk}
            aria-label={t('common.close')}
          >
            <X className="h-4 w-4" />
          </Button>
        </DialogHeader>

        <ScrollArea className="flex-1" ref={scrollRef}>
          <div className="space-y-4 px-5 py-4">
            {empty && !isStreaming && (
              <p className="py-12 text-center text-sm text-muted-foreground">
                {t('quickAsk.placeholder')}
              </p>
            )}
            {messages.map((m) => (
              <MessageBubble key={m.id} message={m} />
            ))}
            {activeToolApproval && (
              <ToolApprovalCard
                toolName={activeToolApproval.toolName}
                reason={activeToolApproval.reason}
                onApprove={() => resolveToolApproval(activeToolApproval.id, true)}
                onDeny={() => resolveToolApproval(activeToolApproval.id, false)}
              />
            )}
          </div>
        </ScrollArea>

        {/* Result actions (copy / promote) — shown only when a turn completed. */}
        {hasResult && (
          <div className="flex items-center justify-between border-t px-5 py-1.5">
            <div className="flex items-center gap-2">
              <Button
                variant="ghost"
                size="sm"
                onClick={handleCopy}
                className="h-7 gap-1.5 px-2 text-xs"
              >
                {copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
                {t('quickAsk.copy')}
              </Button>
              <Button
                variant="ghost"
                size="sm"
                onClick={handlePromote}
                className="h-7 gap-1.5 px-2 text-xs"
              >
                <MessageSquarePlus className="h-3 w-3" />
                {t('quickAsk.promote')}
              </Button>
            </div>
            <span className="text-[10px] text-muted-foreground">{t('quickAsk.notSaved')}</span>
          </div>
        )}

        {/* Input — same ChatInput as the chat page for UX parity. */}
        <div className="border-t bg-background/95 backdrop-blur-sm shrink-0">
          <ChatInput
            value={input}
            onChange={setInput}
            onSend={(content) => send(content)}
            onCancel={cancel}
            isStreaming={isStreaming}
            connected={true}
            queuedCount={queuedCount}
            activeModelId={quickAskModel}
            setActiveModelId={setQuickAskModel}
            placeholder={t('quickAsk.placeholder')}
            showNewChatHint={false}
          />
        </div>
      </DialogContent>
    </Dialog>
  )
}