import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react"
import { StreamProvider, useStreamContext } from "@langchain/react"
import { overrideFetchImplementation } from "@langchain/langgraph-sdk"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
  ArrowClockwiseIcon,
  ArrowUpIcon,
  CheckIcon,
  CodeIcon,
  PlusIcon,
  SparkleIcon,
  TrashIcon,
  XIcon,
} from "@phosphor-icons/react"
import { Menu } from "@base-ui/react/menu"
import type { BaseMessage } from "@langchain/core/messages"

import type { ReviewChatThread } from "@/lib/api"
import { Markdown } from "@/components/agents/ported"
import { IconButton } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Skeleton } from "@/components/ui/skeleton"
import { api, reviewChatApiBase } from "@/lib/api"
import { cn } from "@/lib/utils"

// --- Composer bridge ---------------------------------------------------------
//
// Lets the diff column (a separate subtree) attach a code reference to the
// active chat composer. The reference shows as a removable pill; the code is
// only serialized into the message text on send. The chat tab may not be
// mounted when "add to chat" fires, so attachments are stashed until ChatBody
// registers its sink.

export interface ChatAttachment {
  id: string
  path: string
  // e.g. "R35-37" / "L12" — the side+line range shown after the filename.
  lineLabel: string
  language: string
  snippet: string
}

interface ReviewChatComposer {
  addAttachment: (attachment: ChatAttachment) => void
  registerSink: (fn: ((attachment: ChatAttachment) => void) | null) => void
}

const ReviewChatComposerContext = createContext<ReviewChatComposer | null>(null)

export function ReviewChatComposerProvider({
  children,
}: {
  children: React.ReactNode
}) {
  const sinkRef = useRef<((attachment: ChatAttachment) => void) | null>(null)
  const pendingRef = useRef<Array<ChatAttachment>>([])
  const value = useMemo<ReviewChatComposer>(
    () => ({
      addAttachment: (attachment) => {
        if (sinkRef.current) sinkRef.current(attachment)
        else pendingRef.current.push(attachment)
      },
      registerSink: (fn) => {
        sinkRef.current = fn
        if (fn && pendingRef.current.length > 0) {
          for (const attachment of pendingRef.current) fn(attachment)
          pendingRef.current = []
        }
      },
    }),
    []
  )
  return (
    <ReviewChatComposerContext.Provider value={value}>
      {children}
    </ReviewChatComposerContext.Provider>
  )
}

export function useReviewChatComposer(): ReviewChatComposer | null {
  return useContext(ReviewChatComposerContext)
}

function attachmentBasename(path: string): string {
  const idx = path.lastIndexOf("/")
  return idx === -1 ? path : path.slice(idx + 1)
}

function attachmentPillLabel(attachment: ChatAttachment): string {
  return `${attachmentBasename(attachment.path)}:${attachment.lineLabel}`
}

// Serialize attachments as fenced code blocks ahead of the prose so the model
// receives the code as context. The UI renders pills instead (see parse below).
function serializeMessage(text: string, attachments: Array<ChatAttachment>): string {
  const blocks = attachments.map(
    (a) => `\`${a.path}:${a.lineLabel}\`\n\`\`\`${a.language}\n${a.snippet}\n\`\`\``
  )
  return [...blocks, text.trim()].filter(Boolean).join("\n\n")
}

interface ParsedAttachment {
  label: string
  language: string
  code: string
}

// Pull the leading attachment blocks (which serializeMessage always writes
// first) back out of a sent message so the bubble can render them as pills.
function parseUserMessage(content: string): {
  attachments: Array<ParsedAttachment>
  text: string
} {
  const attachments: Array<ParsedAttachment> = []
  let rest = content
  const re = /^`([^`\n]+)`\n```([\w.-]*)\n([\s\S]*?)\n```\n*/
  let match = re.exec(rest)
  while (match && match.index === 0) {
    const loc = match[1] ?? ""
    const slash = loc.lastIndexOf("/")
    attachments.push({
      label: slash === -1 ? loc : loc.slice(slash + 1),
      language: match[2] ?? "",
      code: match[3] ?? "",
    })
    rest = rest.slice(match[0].length)
    match = re.exec(rest)
  }
  return { attachments, text: rest.trim() }
}

function AttachmentPill({
  label,
  onRemove,
}: {
  label: string
  onRemove?: () => void
}) {
  return (
    <span className="inline-flex max-w-[200px] items-center gap-1 rounded-md border border-border bg-muted/60 py-0.5 pl-1.5 pr-1 text-[11px] text-foreground">
      <CodeIcon className="size-3 shrink-0 text-muted-foreground" />
      <span className="truncate font-mono">{label}</span>
      {onRemove && (
        <IconButton
          type="button"
          variant="ghost"
          size="icon-xs"
          aria-label="Remove attachment"
          onClick={onRemove}
        >
          <XIcon />
        </IconButton>
      )}
    </span>
  )
}

const dashboardFetch: typeof fetch = (input, init) =>
  fetch(input, { ...init, credentials: "include" })

// The SDK's internal Client issues some reads (getState, history) outside the
// transport's fetch; without this they drop the session cookie cross-origin.
overrideFetchImplementation(dashboardFetch)

const SUGGESTED_PROMPTS = [
  "Summarize the changes in this PR",
  "Walk me through the review findings",
  "What are the riskiest parts of this change?",
]

// Kept in sync with the backend's `_derive_title` so the optimistic tab label
// matches the title the server persists for the thread.
const DEFAULT_TITLE = "New chat"
const TITLE_MAX_CHARS = 60

function deriveTitle(text: string): string {
  const flattened = text.trim().split(/\s+/).join(" ")
  return flattened ? flattened.slice(0, TITLE_MAX_CHARS) : DEFAULT_TITLE
}

function messageType(message: BaseMessage): string {
  const candidate = message as unknown as {
    getType?: () => string
    type?: string
    role?: string
  }
  return candidate.getType?.() ?? candidate.type ?? candidate.role ?? "ai"
}

function messageText(content: BaseMessage["content"]): string {
  if (typeof content === "string") return content
  if (!Array.isArray(content)) return ""
  return content
    .map((block) => {
      if (typeof block === "string") return block
      if (typeof block === "object" && "text" in block) {
        const text = (block as { text?: unknown }).text
        return typeof text === "string" ? text : ""
      }
      return ""
    })
    .filter(Boolean)
    .join("\n")
}

// --- Conversation store ------------------------------------------------------
//
// The client owns the conversation list. Each conversation is a client-minted
// thread id; the thread is created server-side lazily on its first message.
// The server thread list is only used to recover conversations on a fresh
// browser and to reconcile titles — it is never the sole source for the tabs,
// so an empty/lagging server response can no longer make open chats vanish.

interface Conversation {
  id: string
  title: string
  createdAt: number
}

interface ChatState {
  conversations: Array<Conversation>
  activeId: string
}

function storageKey(owner: string, repo: string, number: number): string {
  return `osw:review-chat:${owner}/${repo}/${number}`
}

function newDraft(): Conversation {
  return { id: crypto.randomUUID(), title: DEFAULT_TITLE, createdAt: Date.now() }
}

function isConversation(value: unknown): value is Conversation {
  if (!value || typeof value !== "object") return false
  const candidate = value as Record<string, unknown>
  return (
    typeof candidate.id === "string" &&
    typeof candidate.title === "string" &&
    typeof candidate.createdAt === "number"
  )
}

function loadState(key: string): ChatState {
  if (typeof window !== "undefined") {
    try {
      const raw = window.localStorage.getItem(key)
      if (raw) {
        const parsed: unknown = JSON.parse(raw)
        const source = (parsed ?? {}) as Record<string, unknown>
        const conversations = Array.isArray(source.conversations)
          ? source.conversations.filter(isConversation)
          : []
        const first = conversations[0]
        if (first) {
          const activeId =
            typeof source.activeId === "string" &&
            conversations.some((c) => c.id === source.activeId)
              ? source.activeId
              : first.id
          return { conversations, activeId }
        }
      }
    } catch {
      /* fall through to a fresh draft */
    }
  }
  const draft = newDraft()
  return { conversations: [draft], activeId: draft.id }
}

function saveState(key: string, state: ChatState): void {
  if (typeof window === "undefined") return
  try {
    window.localStorage.setItem(key, JSON.stringify(state))
  } catch {
    /* ignore quota / availability errors */
  }
}

function useConversations(key: string) {
  const [state, setState] = useState<ChatState>(() => loadState(key))

  const update = useCallback(
    (fn: (prev: ChatState) => ChatState) => {
      setState((prev) => {
        const next = fn(prev)
        if (next === prev) return prev
        saveState(key, next)
        return next
      })
    },
    [key],
  )

  const select = useCallback(
    (conversation: Conversation) => {
      update((prev) => ({
        conversations: prev.conversations.some((c) => c.id === conversation.id)
          ? prev.conversations
          : [...prev.conversations, conversation],
        activeId: conversation.id,
      }))
    },
    [update],
  )

  const newChat = useCallback(() => {
    update((prev) => {
      const active = prev.conversations.find((c) => c.id === prev.activeId)
      // Reuse a pristine, never-sent draft instead of stacking empty tabs.
      if (active && active.title === DEFAULT_TITLE) return prev
      const draft = newDraft()
      return { conversations: [...prev.conversations, draft], activeId: draft.id }
    })
  }, [update])

  // Names a conversation from its first message; later messages don't rename it.
  const nameConversation = useCallback(
    (id: string, title: string) => {
      update((prev) => {
        const current = prev.conversations.find((c) => c.id === id)
        if (!current || current.title !== DEFAULT_TITLE) return prev
        return {
          ...prev,
          conversations: prev.conversations.map((c) =>
            c.id === id ? { ...c, title } : c,
          ),
        }
      })
    },
    [update],
  )

  const close = useCallback(
    (id: string) => {
      update((prev) => {
        const index = prev.conversations.findIndex((c) => c.id === id)
        if (index === -1) return prev
        const remaining = prev.conversations.filter((c) => c.id !== id)
        const fallback = remaining[Math.max(0, index - 1)]
        if (!fallback) {
          const draft = newDraft()
          return { conversations: [draft], activeId: draft.id }
        }
        return {
          conversations: remaining,
          activeId: prev.activeId === id ? fallback.id : prev.activeId,
        }
      })
    },
    [update],
  )

  return { ...state, select, newChat, nameConversation, close }
}

// --- View --------------------------------------------------------------------

function EmptyState({ onPick }: { onPick: (prompt: string) => void }) {
  return (
    <div className="flex flex-1 flex-col gap-4 p-4">
      <p className="text-[13px] text-foreground">
        I've reviewed this PR. Ask me about the diff, the findings, or the
        surrounding code — I have read-only access to the repository.
      </p>
      <div className="flex flex-col gap-1.5">
        <span className="text-xs font-medium text-muted-foreground">
          Suggested prompts
        </span>
        {SUGGESTED_PROMPTS.map((prompt) => (
          <button
            key={prompt}
            type="button"
            onClick={() => onPick(prompt)}
            className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] text-foreground hover:bg-muted/60"
          >
            <SparkleIcon className="size-4 shrink-0 text-muted-foreground" />
            {prompt}
          </button>
        ))}
      </div>
    </div>
  )
}

function LoadingState() {
  return (
    <div className="flex flex-1 flex-col gap-4 p-4">
      <div className="flex justify-end">
        <Skeleton className="h-12 w-2/5 rounded-lg" />
      </div>
      <div className="flex flex-col gap-2">
        <Skeleton className="h-4 w-4/5" />
        <Skeleton className="h-4 w-3/5" />
      </div>
    </div>
  )
}

function ChatBody({
  onUserSend,
  expectsHistory,
}: {
  onUserSend: (text: string) => void
  expectsHistory: boolean
}) {
  const composer = useReviewChatComposer()
  const stream = useStreamContext()
  const [value, setValue] = useState("")
  const [attachments, setAttachments] = useState<Array<ChatAttachment>>([])
  const scrollRef = useRef<HTMLDivElement>(null)
  const autoScrollRef = useRef(true)
  const prevTopRef = useRef(0)
  const messages = stream.messages
  const busy = stream.isLoading
  // True during the one-time getState hydration when switching to / loading an
  // existing thread, before its messages have arrived.
  const hydrating = stream.isThreadLoading

  // Receive "add to chat" attachments from the diff column as composer pills.
  useEffect(() => {
    if (!composer) return
    composer.registerSink((attachment) =>
      setAttachments((prev) =>
        prev.some((a) => a.id === attachment.id) ? prev : [...prev, attachment]
      )
    )
    return () => composer.registerSink(null)
  }, [composer])

  const removeAttachment = useCallback((id: string) => {
    setAttachments((prev) => prev.filter((a) => a.id !== id))
  }, [])

  const send = useCallback(
    (text: string, atts: Array<ChatAttachment>) => {
      const trimmed = text.trim()
      const first = atts[0]
      if ((!trimmed && !first) || busy) return
      const content = serializeMessage(trimmed, atts)
      onUserSend(trimmed || (first ? attachmentPillLabel(first) : ""))
      void stream.submit({ messages: [{ type: "human", content }] })
    },
    [busy, stream, onUserSend],
  )

  const visible = messages.filter((message) => {
    const type = messageType(message)
    if (type !== "human" && type !== "ai") return false
    return messageText(message.content).trim().length > 0
  })

  const submitComposer = () => {
    send(value, attachments)
    setValue("")
    setAttachments([])
  }

  // Show the loading placeholder (not the empty/intro state) while an existing
  // conversation hydrates, so a chat with messages never flashes its greeting.
  const showEmpty = visible.length === 0 && !busy
  const showLoading = showEmpty && hydrating && expectsHistory
  const showMessages = !showEmpty && !showLoading

  // Auto-scroll is fully contained to the messages list (scrollTop assignment,
  // never scrollIntoView) so it can't bubble up and move the diff column. We
  // pause auto-scroll when the user scrolls up and resume when near the bottom.
  useLayoutEffect(() => {
    if (!showMessages) return
    const el = scrollRef.current
    if (!el || !autoScrollRef.current) return
    el.scrollTop = el.scrollHeight
    prevTopRef.current = el.scrollTop
  }, [showMessages, messages, busy])

  useEffect(() => {
    if (!showMessages) return
    const el = scrollRef.current
    if (!el) return
    const onScroll = () => {
      const top = el.scrollTop
      const nearBottom = el.scrollHeight - top - el.clientHeight <= 24
      if (top < prevTopRef.current - 1) autoScrollRef.current = false
      else if (nearBottom) autoScrollRef.current = true
      prevTopRef.current = top
    }
    el.addEventListener("scroll", onScroll, { passive: true })
    return () => el.removeEventListener("scroll", onScroll)
  }, [showMessages])

  return (
    <div className="flex flex-1 flex-col overflow-hidden">
      {showLoading ? (
        <LoadingState />
      ) : showEmpty ? (
        <EmptyState
          onPick={(prompt) => {
            send(prompt, attachments)
            setAttachments([])
          }}
        />
      ) : (
        <div
          ref={scrollRef}
          className="flex flex-1 flex-col gap-4 overflow-y-auto p-4"
        >
          {visible.map((message, index) => {
            const isUser = messageType(message) === "human"
            if (!isUser) {
              return (
                <div key={message.id ?? index} className="flex justify-start">
                  <div className="w-full text-[13px] text-foreground">
                    <Markdown content={messageText(message.content)} />
                  </div>
                </div>
              )
            }
            const parsed = parseUserMessage(messageText(message.content))
            return (
              <div key={message.id ?? index} className="flex justify-end">
                <div className="flex max-w-[85%] flex-col items-end gap-1.5">
                  {parsed.attachments.length > 0 && (
                    <div className="flex flex-wrap justify-end gap-1">
                      {parsed.attachments.map((attachment, i) => (
                        <AttachmentPill key={i} label={attachment.label} />
                      ))}
                    </div>
                  )}
                  {parsed.text && (
                    <span className="rounded-lg bg-muted px-3 py-2 text-[13px] whitespace-pre-wrap text-foreground">
                      {parsed.text}
                    </span>
                  )}
                </div>
              </div>
            )
          })}
          {busy && (
            <div className="flex justify-start">
              <div className="px-3 py-2 text-xs text-muted-foreground">
                Thinking…
              </div>
            </div>
          )}
        </div>
      )}

      <div className="p-3">
        <div className="flex flex-col gap-1.5 rounded-2xl border border-border bg-background px-1.5 py-1.5 transition-colors focus-within:border-ring/60">
          {attachments.length > 0 && (
            <div className="flex flex-wrap gap-1 pl-2 pt-0.5">
              {attachments.map((attachment) => (
                <AttachmentPill
                  key={attachment.id}
                  label={attachmentPillLabel(attachment)}
                  onRemove={() => removeAttachment(attachment.id)}
                />
              ))}
            </div>
          )}
          <div className="flex items-end gap-2 pl-2">
            <Textarea
              value={value}
              onChange={(event) => setValue(event.target.value)}
              onKeyDown={(event) => {
                if (event.key === "Enter" && !event.shiftKey) {
                  event.preventDefault()
                  submitComposer()
                }
              }}
              placeholder="Ask anything about this PR…"
              rows={1}
              className="max-h-40 min-h-7 flex-1 resize-none rounded-none border-0 bg-transparent px-0 py-1 shadow-none focus-visible:border-transparent focus-visible:ring-0 dark:bg-transparent"
            />
            <IconButton
              type="button"
              onClick={submitComposer}
              disabled={(!value.trim() && attachments.length === 0) || busy}
              aria-label="Send message"
              className="rounded-full"
            >
              <ArrowUpIcon className="size-4" />
            </IconButton>
          </div>
        </div>
      </div>
    </div>
  )
}

function ChatPanel({
  owner,
  repo,
  number,
  assistantId,
}: {
  owner: string
  repo: string
  number: number
  assistantId: string
}) {
  const qc = useQueryClient()
  const threadsKey = useMemo(
    () => ["review-chat-threads", owner, repo, number] as const,
    [owner, repo, number],
  )
  const { conversations, activeId, select, newChat, nameConversation, close } =
    useConversations(storageKey(owner, repo, number))

  const threadsQuery = useQuery({
    queryKey: threadsKey,
    queryFn: () => api.listReviewChatThreads(owner, repo, number),
  })
  const serverThreads = useMemo(
    () => threadsQuery.data?.threads ?? [],
    [threadsQuery.data],
  )

  const invalidateThreads = useCallback(() => {
    void qc.invalidateQueries({ queryKey: threadsKey })
  }, [qc, threadsKey])

  const deleteThread = useMutation({
    mutationFn: (id: string) =>
      api.deleteReviewChatThread(owner, repo, number, id),
    onMutate: (id: string) => {
      qc.setQueryData<{ threads: Array<ReviewChatThread> }>(threadsKey, (old) =>
        old ? { threads: old.threads.filter((t) => t.thread_id !== id) } : old,
      )
    },
    onSettled: invalidateThreads,
  })

  // Open tabs are the client's conversations (titles reconciled from the
  // server). The history dropdown is the full set — open tabs plus every other
  // conversation the server knows about (e.g. recovered on a fresh browser) —
  // newest first, so past chats are reachable without cluttering the tab strip.
  const { openTabs, historyItems } = useMemo(() => {
    const byId = new Map(conversations.map((c) => [c.id, { ...c }]))
    for (const thread of serverThreads) {
      const existing = byId.get(thread.thread_id)
      if (existing) {
        if (thread.title && thread.title !== DEFAULT_TITLE) {
          existing.title = thread.title
        }
      } else {
        byId.set(thread.thread_id, {
          id: thread.thread_id,
          title: thread.title || DEFAULT_TITLE,
          createdAt: thread.updated_at ? Date.parse(thread.updated_at) || 0 : 0,
        })
      }
    }
    return {
      openTabs: conversations.map((c) => byId.get(c.id) ?? c),
      historyItems: [...byId.values()].sort((a, b) => b.createdAt - a.createdAt),
    }
  }, [conversations, serverThreads])

  const handleClose = useCallback(
    (id: string) => {
      if (serverThreads.some((t) => t.thread_id === id)) {
        deleteThread.mutate(id)
      }
      close(id)
    },
    [serverThreads, deleteThread, close],
  )

  const handleUserSend = useCallback(
    (text: string) => {
      nameConversation(activeId, deriveTitle(text))
    },
    [nameConversation, activeId],
  )

  // Whether the active conversation should already have messages: it exists
  // server-side, or it's been named by a sent message. Drives the hydration
  // placeholder so an existing chat never flashes the intro state on load.
  const expectsHistory = useMemo(() => {
    if (serverThreads.some((t) => t.thread_id === activeId)) return true
    const active = conversations.find((c) => c.id === activeId)
    return active !== undefined && active.title !== DEFAULT_TITLE
  }, [serverThreads, conversations, activeId])

  return (
    <div className="flex h-full flex-1 flex-col overflow-hidden">
      <div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
        <div className="flex flex-1 items-center gap-1 overflow-x-auto">
          {openTabs.map((tab) => (
            <div
              key={tab.id}
              className={cn(
                "flex shrink-0 items-center gap-1 rounded-md py-1 pl-2 pr-1 text-xs",
                tab.id === activeId
                  ? "bg-muted text-foreground"
                  : "text-muted-foreground hover:bg-muted/50",
              )}
            >
              <button
                type="button"
                onClick={() => select(tab)}
                className="max-w-[140px] truncate"
              >
                {tab.title}
              </button>
              <IconButton
                type="button"
                variant="ghost"
                size="icon-xs"
                aria-label="Close chat"
                onClick={() => handleClose(tab.id)}
              >
                <XIcon />
              </IconButton>
            </div>
          ))}
        </div>
        <IconButton
          type="button"
          variant="ghost"
          size="icon-sm"
          aria-label="New chat"
          onClick={newChat}
        >
          <PlusIcon />
        </IconButton>
        <Menu.Root
          onOpenChange={(open) => {
            if (open) void threadsQuery.refetch()
          }}
        >
          <Menu.Trigger
            render={
              <IconButton
                type="button"
                variant="ghost"
                size="icon-sm"
                aria-label="Chat history"
              >
                <ArrowClockwiseIcon />
              </IconButton>
            }
          />
          <Menu.Portal>
            <Menu.Positioner align="end" sideOffset={6} className="z-50">
              <Menu.Popup className="origin-(--transform-origin) max-h-80 w-64 overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
                {historyItems.length === 0 ? (
                  <div className="px-2 py-1.5 text-xs text-muted-foreground">
                    No conversations yet
                  </div>
                ) : (
                  historyItems.map((item) => (
                    <div
                      key={item.id}
                      className="group/hist relative flex items-stretch"
                    >
                      <Menu.Item
                        onClick={() => select(item)}
                        className="flex flex-1 cursor-default items-center gap-2 rounded-md py-1.5 pl-2 pr-8 text-xs outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
                      >
                        {item.id === activeId ? (
                          <CheckIcon className="size-3.5 shrink-0" />
                        ) : (
                          <span className="size-3.5 shrink-0" />
                        )}
                        <span className="flex-1 truncate text-left">
                          {item.title}
                        </span>
                      </Menu.Item>
                      <IconButton
                        type="button"
                        variant="ghost"
                        size="icon-xs"
                        aria-label="Delete chat"
                        className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/hist:opacity-100"
                        onClick={() => handleClose(item.id)}
                      >
                        <TrashIcon />
                      </IconButton>
                    </div>
                  ))
                )}
              </Menu.Popup>
            </Menu.Positioner>
          </Menu.Portal>
        </Menu.Root>
      </div>

      <StreamProvider
        key={activeId}
        apiUrl={reviewChatApiBase(owner, repo, number)}
        assistantId={assistantId}
        fetch={dashboardFetch}
        threadId={activeId}
        onCompleted={invalidateThreads}
      >
        <ChatBody onUserSend={handleUserSend} expectsHistory={expectsHistory} />
      </StreamProvider>
    </div>
  )
}

export function ReviewChat({
  owner,
  repo,
  number,
}: {
  owner: string
  repo: string
  number: number
}) {
  const meta = useQuery({
    queryKey: ["review-chat", owner, repo, number],
    queryFn: () => api.getReviewChat(owner, repo, number),
  })

  if (meta.isPending) {
    return (
      <div className="flex flex-1 flex-col gap-3 p-4">
        <Skeleton className="h-4 w-2/3" />
        <Skeleton className="h-4 w-1/2" />
      </div>
    )
  }

  if (meta.isError || !meta.data.available) {
    return (
      <div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-muted-foreground">
        Chat becomes available once the review has finished running.
      </div>
    )
  }

  return (
    <ChatPanel
      key={`${owner}/${repo}/${number}`}
      owner={owner}
      repo={repo}
      number={number}
      assistantId={meta.data.assistant_id}
    />
  )
}
