import { useRef, useState } from 'react' import { IconArchive, IconChevronDown, IconEdit } from '@tabler/icons-react' import { useArchiveWorkspaceSession, useWorkspaceSessions } from './api' import { useWorkspaceAgent } from '@/client/features/workspace/api' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import { useSelectedSession } from '@/client/features/chat/useSelectedSession' import { cn } from '@/client/lib/cn' import { hasRunningBackgroundSession, isSessionRunning, liveStore, useLive } from '@/client/features/chat/chat-store' import type { SessionInfo } from '@/lib/types' import { Button } from '@/client/components/ui/button' import { toast } from '@/client/components/ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@/client/components/ui/tooltip' import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/client/components/ui/dropdown-menu' import { Spinner } from '@/client/components/ui/spinner' type ChatSelectorProps = { isViewBuilder?: boolean } type ChatHeaderLabelProps = { label: string } type ChatSessionGroup = { key: string label: string sessions: SessionInfo[] } function ChatHeaderLabel({ label }: ChatHeaderLabelProps) { return
{label}
} // One tiny text badge per row at most: a cron/subagent flavor wins; otherwise // an external origin shows its provider lowercase (e.g. "irc", "telegram"). // Plain app chats get none. export function sessionBadge(session: SessionInfo): string | null { if (session.flavor === 'cron' || session.flavor === 'subagent') return session.flavor const provider = session.origin?.provider.trim().toLowerCase() return provider ? provider : null } type ChatSessionItemProps = { active: boolean canArchive: boolean confirmingArchive: boolean onArchive: (sessionId: string) => Promise onRequestArchive: (sessionId: string) => void onSelect: (sessionId: string) => void session: SessionInfo workspaceId: string } // One chat row inside the selector menu (summary, flavor/origin badge, running // spinner, archive affordance). Exported for the /dev/chat-states catalog. export function ChatSessionItem({ active, canArchive, confirmingArchive, onArchive, onRequestArchive, onSelect, session, workspaceId }: ChatSessionItemProps) { const pendingRef = useRef(false) const [pending, setPending] = useState(false) const running = useLive(state => isSessionRunning(state.activity, workspaceId, session.sessionId)) const badge = sessionBadge(session) function handleRequestArchive() { onRequestArchive(session.sessionId) } async function handleConfirmArchive() { if (pendingRef.current) return pendingRef.current = true setPending(true) try { await onArchive(session.sessionId) } catch { toast.add({ title: 'Couldn’t archive chat', type: 'error' }) } finally { pendingRef.current = false setPending(false) } } return (
onSelect(session.sessionId)} >
{session.summary} {badge && {badge}}
{running && ( )} {canArchive && (
{confirmingArchive ? ( ) : ( event.stopPropagation()} > } /> Archive chat )}
)}
) } function localDateKey(date: Date): string { return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` } function calendarDayNumber(date: Date): number { return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000 } function formatGroupLabel(date: Date, now: Date): string { const daysAgo = calendarDayNumber(now) - calendarDayNumber(date) if (daysAgo === 0) return 'Today' if (daysAgo === 1) return 'Yesterday' if (daysAgo >= 2 && daysAgo <= 5) return `${daysAgo} days ago` return date.toLocaleDateString([], { month: 'short', day: 'numeric', ...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }) }) } export function groupSessionsByDate(sessions: SessionInfo[], now = new Date()): ChatSessionGroup[] { const groups = new Map() for (const session of sessions.slice().sort((a, b) => b.lastModified - a.lastModified)) { const date = new Date(session.lastModified) const key = localDateKey(date) const group = groups.get(key) if (group) { group.sessions.push(session) continue } groups.set(key, { key, label: formatGroupLabel(date, now), sessions: [session] }) } return [...groups.values()] } export function ChatSelector({ isViewBuilder = false }: ChatSelectorProps) { if (isViewBuilder) { return } return } function SessionSelector() { const workspaceId = useWorkspaceId() const [selectedSession, selectSession] = useSelectedSession() const selectedSessionId = selectedSession ?? null const [confirmingSessionId, setConfirmingSessionId] = useState(null) const { data: sessions = [] } = useWorkspaceSessions(workspaceId) const canArchive = useWorkspaceAgent(workspaceId).data?.supportsArchiving === true const archiveSession = useArchiveWorkspaceSession(workspaceId) const hasRunningBackgroundChat = useLive(state => hasRunningBackgroundSession(state.activity, workspaceId, selectedSessionId) ) if (sessions.length === 0) { return } const active = sessions.find(s => s.sessionId === selectedSessionId) const label = active?.summary ?? 'New chat' const sessionGroups = groupSessionsByDate(sessions) function handleMenuOpenChange(open: boolean) { if (!open) setConfirmingSessionId(null) } async function handleArchive(sessionId: string) { setConfirmingSessionId(null) await archiveSession.mutateAsync(sessionId) const store = liveStore.getState() store.clearAttachments(workspaceId, sessionId) store.clearPreviewsForSession(workspaceId, sessionId) if (selectedSessionId === sessionId) { selectSession(null) } } return ( {label} {hasRunningBackgroundChat ? ( ) : ( )} } /> selectSession(null)} > New chat {sessionGroups.length > 0 && ( <>
{sessionGroups.map(group => ( {group.label} {group.sessions.map(session => ( ))} ))}
)}
) }