oxios 1.10.1

Oxios Agent OS — Agent Operating System powered by oxi-sdk
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { ChevronRight, FolderKanban, Inbox, Plus, RefreshCw } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useMoveSession } from '@/hooks/use-sessions'
import { api } from '@/lib/api-client'
import { cn } from '@/lib/utils'
import { useChatStore } from '@/stores/chat'
import { useSidebarStore } from '@/stores/sidebar'
import type { Project, Session } from '@/types'
import {
  itemActive,
  itemBase,
  itemCollapsedBase,
  itemDense,
  itemInactive,
  sectionGap,
  sectionHeader,
  sectionSeparator,
} from './sidebar'

// ---------------------------------------------------------------------------
// ChatSessionNav — renders inside the main sidebar when chat mode is active.
// RFC-025: Project-tree layout (Project folders → sessions).
// ---------------------------------------------------------------------------

export function ChatSessionNav() {
  const { collapsed } = useSidebarStore()

  if (collapsed) {
    return <CollapsedChatNav />
  }

  return <ExpandedChatNav />
}

// ---------------------------------------------------------------------------
// Expanded — Project-tree
// ---------------------------------------------------------------------------

function ExpandedChatNav() {
  const { t } = useTranslation()
  const activeSessionId = useChatStore((s) => s.activeSessionId)
  const loadSession = useChatStore((s) => s.loadSession)
  const newSession = useChatStore((s) => s.newSession)
  const moveSession = useMoveSession()

  const [collapsedProjects, setCollapsedProjects] = useState<Set<string>>(new Set())
  const [dragOverProject, setDragOverProject] = useState<string | null>(null)

  const { data: projectsData } = useQuery({
    queryKey: ['projects'],
    queryFn: () => api.get<{ items: Project[]; total: number }>('/api/projects'),
    refetchInterval: 30_000,
  })

  const { data: sessionsData, refetch: refetchSessions } = useQuery({
    queryKey: ['sessions'],
    queryFn: () => api.get<{ items: Session[]; total: number }>('/api/sessions'),
    refetchInterval: 10_000,
  })

  const projects: Project[] = Array.isArray(projectsData?.items) ? projectsData.items : []
  const allSessions: Session[] = Array.isArray(sessionsData?.items) ? sessionsData.items : []

  // Group sessions by project_id.
  const sessionsByProject = new Map<string, Session[]>()
  const unfiledSessions: Session[] = []
  for (const s of allSessions) {
    if (s.project_id) {
      const arr = sessionsByProject.get(s.project_id) ?? []
      arr.push(s)
      sessionsByProject.set(s.project_id, arr)
    } else {
      unfiledSessions.push(s)
    }
  }

  const toggleProject = (id: string) => {
    setCollapsedProjects((prev) => {
      const next = new Set(prev)
      if (next.has(id)) next.delete(id)
      else next.add(id)
      return next
    })
  }

  const handleDropToProject = async (projectId: string | null) => {
    const draggedId = window.__draggedSessionId
    setDragOverProject(null)
    if (!draggedId) return
    delete window.__draggedSessionId
    try {
      await moveSession.mutateAsync({ sessionId: draggedId, project_id: projectId })
      // Web-M2: if the moved session is the active session, sync the
      // chat-store's activeProjectId so subsequent messages route to the
      // new project.
      if (useChatStore.getState().activeSessionId === draggedId) {
        useChatStore.setState({ activeProjectId: projectId })
      }
      toast.success(t('chat.sessionMoved', '세션이 이동되었습니다'))
    } catch (err) {
      toast.error(err instanceof Error ? err.message : t('chat.sessionMoveFailed', '이동 실패'))
    }
  }

  return (
    <>
      {/* New session */}
      <div className={sectionGap}>
        <Button
          variant={activeSessionId ? 'outline' : 'default'}
          size="sm"
          className="w-full"
          onClick={newSession}
        >
          <Plus className="h-3 w-3 mr-1" />
          {t('chat.newConversationButton')}
        </Button>
      </div>

      {/* Sessions tree */}
      <div className="flex-1 overflow-y-auto">
        <div className="flex items-center justify-between px-2 mb-1">
          <span className={sectionHeader.replace('mb-1 ', '')}>
            {t('chat.sessionsLabel', 'Sessions')}
          </span>
          <Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => refetchSessions()}>
            <RefreshCw className="h-3 w-3" />
          </Button>
        </div>

        {/* ── Project folders ── */}
        {projects.map((p) => {
          const sessions = sessionsByProject.get(p.id) ?? []
          const isCollapsed = collapsedProjects.has(p.id)
          const isDragOver = dragOverProject === p.id
          return (
            <div key={p.id} className="mb-0.5">
              {/* Project header (drop target) */}
              <div className="flex items-center">
                <button
                  type="button"
                  onClick={() => toggleProject(p.id)}
                  className="flex flex-1 items-center gap-1 px-2 py-1 text-sm hover:bg-sidebar-accent rounded-sm"
                >
                  <ChevronRight
                    className={cn(
                      'h-3 w-3 shrink-0 transition-transform',
                      !isCollapsed && 'rotate-90',
                    )}
                  />
                  <FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
                  <span className="truncate font-medium">{p.name}</span>
                  {sessions.length > 0 && (
                    <span className="ml-auto text-2xs text-muted-foreground/60">
                      {sessions.length}
                    </span>
                  )}
                </button>
              </div>
              {/* Project as drop target */}
              <div
                onDragOver={(e) => {
                  e.preventDefault()
                  setDragOverProject(p.id)
                }}
                onDragLeave={() => setDragOverProject((cur) => (cur === p.id ? null : cur))}
                onDrop={(e) => {
                  e.preventDefault()
                  handleDropToProject(p.id)
                }}
                className={cn(
                  'rounded-sm transition-colors',
                  isDragOver && 'bg-primary/10 ring-1 ring-primary/30',
                )}
              >
                {/* Sessions under this project */}
                {!isCollapsed &&
                  sessions.map((s) => (
                    <SessionItem
                      key={s.id}
                      session={s}
                      active={activeSessionId === s.id}
                      indented
                      onClick={() => loadSession(s.id)}
                    />
                  ))}
                {!isCollapsed && sessions.length === 0 && (
                  <p className="px-7 py-0.5 text-2xs text-muted-foreground/40">
                    {t('chat.noSessionsInProject', '대화 없음')}
                  </p>
                )}
              </div>
            </div>
          )
        })}

        {/* ── Unfiled sessions ── */}
        {unfiledSessions.length > 0 && (
          <div className={sectionGap}>
            <div className="flex items-center gap-1 px-2 py-1 text-sm text-muted-foreground">
              <Inbox className="h-3.5 w-3.5" />
              <span className="font-medium">{t('chat.unfiled', '분류 안 됨')}</span>
              <span className="ml-auto text-2xs text-muted-foreground/60">
                {unfiledSessions.length}
              </span>
            </div>
            <div
              onDragOver={(e) => {
                e.preventDefault()
                setDragOverProject('__unfiled__')
              }}
              onDragLeave={() => setDragOverProject((cur) => (cur === '__unfiled__' ? null : cur))}
              onDrop={(e) => {
                e.preventDefault()
                handleDropToProject(null)
              }}
              className={cn(
                'rounded-sm transition-colors',
                dragOverProject === '__unfiled__' && 'bg-primary/10 ring-1 ring-primary/30',
              )}
            >
              {unfiledSessions.map((s) => (
                <SessionItem
                  key={s.id}
                  session={s}
                  active={activeSessionId === s.id}
                  indented
                  onClick={() => loadSession(s.id)}
                />
              ))}
            </div>
          </div>
        )}

        {/* Quick project switcher (sets the active project for new sessions) */}
        {projects.length > 0 && <div className={sectionSeparator} />}
      </div>

      {/* Footer links */}
      <div className={sectionSeparator.replace('my-2', 'mt-2 mb-0')} />
      <div className="space-y-0.5">
        <Link to="/sessions" className={cn(itemBase, itemInactive)}>
          {t('chat.manageSessions')}
        </Link>
        <Link to="/projects" className={cn(itemBase, itemInactive)}>
          {t('chat.manageProjects', 'Manage Projects')}
        </Link>
        <Link to="/mounts" className={cn(itemBase, itemInactive)}>
          {t('common.mounts', 'Mounts')}
        </Link>
      </div>
    </>
  )
}

// ---------------------------------------------------------------------------
// Session item (draggable)
// ---------------------------------------------------------------------------

function SessionItem({
  session,
  active,
  indented,
  onClick,
}: {
  session: Session
  active: boolean
  indented?: boolean
  onClick: () => void
}) {
  return (
    <button
      type="button"
      draggable
      onDragStart={(e) => {
        window.__draggedSessionId = session.id
        e.dataTransfer.effectAllowed = 'move'
      }}
      onDragEnd={() => {
        delete window.__draggedSessionId
      }}
      onClick={onClick}
      className={cn(
        itemDense,
        indented && 'pl-7',
        active ? itemActive : itemInactive,
        'cursor-grab active:cursor-grabbing',
      )}
    >
      <span className="block truncate">{session.title ?? `${session.id.slice(0, 8)}...`}</span>
      <span className="block text-2xs text-muted-foreground/60">
        {new Date(session.created_at).toLocaleString(undefined, {
          month: 'short',
          day: 'numeric',
          hour: '2-digit',
          minute: '2-digit',
        })}
      </span>
    </button>
  )
}

// ---------------------------------------------------------------------------
// Collapsed
// ---------------------------------------------------------------------------

function CollapsedChatNav() {
  const { t } = useTranslation()
  const newSession = useChatStore((s) => s.newSession)

  return (
    <div className="flex flex-col items-center gap-1 py-1">
      <Tooltip>
        <TooltipTrigger asChild>
          <button
            type="button"
            onClick={newSession}
            className={cn(itemCollapsedBase, itemInactive)}
          >
            <Plus className="h-4 w-4" />
          </button>
        </TooltipTrigger>
        <TooltipContent side="right">{t('chat.newConversationButton')}</TooltipContent>
      </Tooltip>
    </div>
  )
}