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