'use client' import { useState, useCallback, useRef, useEffect } from 'react' import { useMissionControl, Conversation } from '@/store' import { useSmartPoll } from '@/lib/use-smart-poll' import { createClientLogger } from '@/lib/client-logger' import { Button } from '@/components/ui/button' import { SessionKindAvatar, SessionKindPill } from './session-kind-brand' const log = createClientLogger('ConversationList') type SessionKind = 'claude-code' | 'codex-cli' | 'hermes' | 'gateway' type SessionRecord = { id: string key?: string agent?: string kind?: string source?: string model?: string tokens?: string age?: string active?: boolean startTime?: number lastActivity?: number workingDir?: string | null lastUserPrompt?: string | null } type SessionPrefs = Record function asRecord(value: unknown): Record | null { return value && typeof value === 'object' ? (value as Record) : null } function readString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined } function readNumber(value: unknown): number | undefined { return typeof value === 'number' ? value : undefined } function readSessionPrefs(payload: unknown): SessionPrefs { const record = asRecord(payload) const prefsRecord = asRecord(record?.prefs) if (!prefsRecord) return {} return Object.fromEntries( Object.entries(prefsRecord).map(([key, value]) => { const pref = asRecord(value) return [key, { name: readString(pref?.name), color: readString(pref?.color), }] }) ) } function readSessions(payload: unknown): SessionRecord[] { const record = asRecord(payload) const sessions = Array.isArray(record?.sessions) ? record.sessions : [] return sessions.flatMap((value) => { const session = asRecord(value) const id = readString(session?.id) if (!id) return [] return [{ id, key: readString(session?.key), agent: readString(session?.agent), kind: readString(session?.kind), source: readString(session?.source), model: readString(session?.model), tokens: readString(session?.tokens), age: readString(session?.age), active: typeof session?.active === 'boolean' ? session.active : undefined, startTime: readNumber(session?.startTime), lastActivity: readNumber(session?.lastActivity), workingDir: typeof session?.workingDir === 'string' || session?.workingDir === null ? session.workingDir : undefined, lastUserPrompt: typeof session?.lastUserPrompt === 'string' || session?.lastUserPrompt === null ? session.lastUserPrompt : undefined, }] }) } const COLOR_OPTIONS = [ { value: '', label: 'None' }, { value: 'slate', label: 'Slate' }, { value: 'blue', label: 'Blue' }, { value: 'green', label: 'Green' }, { value: 'amber', label: 'Amber' }, { value: 'red', label: 'Red' }, { value: 'purple', label: 'Purple' }, { value: 'pink', label: 'Pink' }, { value: 'teal', label: 'Teal' }, ] as const function timeAgo(timestamp: number): string { const diff = Math.floor(Date.now() / 1000) - timestamp if (diff <= 0) return 'now' if (diff < 60) return 'now' if (diff < 3600) return `${Math.floor(diff / 60)}m` if (diff < 86400) return `${Math.floor(diff / 3600)}h` return `${Math.floor(diff / 86400)}d` } const STATUS_COLORS: Record = { busy: 'bg-green-500', idle: 'bg-yellow-500', error: 'bg-red-500', offline: 'bg-muted-foreground/30', } const TAG_COLORS: Record = { slate: 'bg-slate-500', blue: 'bg-blue-500', green: 'bg-green-500', amber: 'bg-amber-500', red: 'bg-red-500', purple: 'bg-purple-500', pink: 'bg-pink-500', teal: 'bg-teal-500', } interface ConversationListProps { onNewConversation: (agentName: string) => void } export function ConversationList({ onNewConversation: _onNewConversation }: ConversationListProps) { const { conversations, setConversations, activeConversation, setActiveConversation, markConversationRead, } = useMissionControl() const [search, setSearch] = useState('') // Context menu state const [ctxMenu, setCtxMenu] = useState<{ convId: string; x: number; y: number } | null>(null) const [editingId, setEditingId] = useState(null) const [editName, setEditName] = useState('') const ctxMenuRef = useRef(null) const editInputRef = useRef(null) // Close context menu on outside click / Escape useEffect(() => { if (!ctxMenu) return const handleClick = (e: MouseEvent) => { if (ctxMenuRef.current && !ctxMenuRef.current.contains(e.target as Node)) { setCtxMenu(null) } } const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setCtxMenu(null) } document.addEventListener('mousedown', handleClick) document.addEventListener('keydown', handleKey) return () => { document.removeEventListener('mousedown', handleClick) document.removeEventListener('keydown', handleKey) } }, [ctxMenu]) // Focus rename input when entering edit mode useEffect(() => { if (editingId && editInputRef.current) { editInputRef.current.focus() editInputRef.current.select() } }, [editingId]) const saveSessionPref = useCallback(async (conv: Conversation, name?: string, color?: string) => { const prefKey = conv.session?.prefKey if (!prefKey) return // Optimistic update immediately setConversations( conversations.map((c) => { if (c.id !== conv.id || !c.session) return c const newName = name !== undefined ? name : c.name return { ...c, name: newName, session: { ...c.session, displayName: newName, colorTag: color !== undefined ? (color || undefined) : c.session.colorTag, }, } }) ) try { const body: Record = { key: prefKey } if (name !== undefined) body.name = name || null if (color !== undefined) body.color = color || null const res = await fetch('/api/chat/session-prefs', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (!res.ok) { log.error('Failed to save session pref, server returned', res.status) } } catch (err) { log.error('Failed to save session pref:', err) } }, [conversations, setConversations]) const handleContextMenu = (e: React.MouseEvent, conv: Conversation) => { if (!conv.session?.prefKey) return e.preventDefault() // Clamp to viewport so menu doesn't overflow const x = Math.min(e.clientX, window.innerWidth - 200) const y = Math.min(e.clientY, window.innerHeight - 160) setCtxMenu({ convId: conv.id, x, y }) } const startRename = (conv: Conversation) => { setEditingId(conv.id) setEditName(conv.session?.displayName || conv.name || '') setCtxMenu(null) } const commitRenameRef = useRef(false) const commitRename = (conv: Conversation) => { if (commitRenameRef.current) return commitRenameRef.current = true const trimmed = editName.trim() setEditingId(null) // Always save if non-empty — let the API dedupe unchanged names if (trimmed) { void saveSessionPref(conv, trimmed) } // Reset guard after microtask so onBlur after Enter doesn't double-fire queueMicrotask(() => { commitRenameRef.current = false }) } const setColor = (conv: Conversation, color: string) => { setCtxMenu(null) void saveSessionPref(conv, undefined, color) } const loadConversations = useCallback(async () => { try { const sessionsUrl = '/api/sessions' const requests: Promise[] = [ fetch(sessionsUrl), fetch('/api/chat/session-prefs'), ] const [sessionsRes, prefsRes] = await Promise.all(requests) const sessionsData = sessionsRes.ok ? readSessions(await sessionsRes.json()) : [] const prefs = prefsRes.ok ? readSessionPrefs(await prefsRes.json().catch(() => null)) : {} const providerSessions = sessionsData .map((s, idx: number) => { const lastActivityMs = Number(s.lastActivity || s.startTime || 0) const updatedAt = lastActivityMs > 1_000_000_000_000 ? Math.floor(lastActivityMs / 1000) : lastActivityMs const sessionKind: SessionKind = s.kind === 'claude-code' || s.kind === 'codex-cli' || s.kind === 'hermes' ? s.kind : 'gateway' const kindLabel = sessionKind === 'codex-cli' ? 'Codex' : sessionKind === 'claude-code' ? 'Claude' : sessionKind === 'hermes' ? 'Hermes' : 'Gateway' const prefKey = `${sessionKind}:${s.id}` const pref = prefs[prefKey] || {} const defaultName = s.source === 'local' ? `${kindLabel} • ${s.key || s.id}` : `${s.agent || 'Gateway'} • ${s.key || s.id}` const sessionName = pref.name || defaultName return { id: `session:${sessionKind}:${s.id}`, name: sessionName, kind: sessionKind, source: 'session' as const, session: { prefKey, sessionId: String(s.id), sessionKey: s.key || undefined, sessionKind, agent: s.agent || undefined, displayName: sessionName, colorTag: typeof pref.color === 'string' ? pref.color : undefined, model: s.model, tokens: s.tokens, workingDir: s.workingDir || null, lastUserPrompt: s.lastUserPrompt || null, active: !!s.active, age: s.age, }, participants: [], lastMessage: { id: Date.now() + idx, conversation_id: `session:${sessionKind}:${s.id}`, from_agent: 'system', to_agent: null, content: `${s.model || kindLabel} • ${s.tokens || ''}`.trim(), message_type: 'system' as const, created_at: updatedAt || Math.floor(Date.now() / 1000), }, unreadCount: 0, updatedAt, } }) setConversations( providerSessions.sort((a: Conversation, b: Conversation) => b.updatedAt - a.updatedAt) ) } catch (err) { log.error('Failed to load conversations:', err) } }, [setConversations]) useSmartPoll(loadConversations, 30000, { pauseWhenSseConnected: true }) const handleSelect = (convId: string) => { setActiveConversation(convId) markConversationRead(convId) } const filteredConversations = conversations.filter((c) => { if (!search) return true const s = search.toLowerCase() return ( c.id.toLowerCase().includes(s) || (c.name || '').toLowerCase().includes(s) || c.lastMessage?.from_agent.toLowerCase().includes(s) || c.lastMessage?.content.toLowerCase().includes(s) ) }) const gatewayRows = filteredConversations.filter((c) => c.source === 'session' && c.session?.sessionKind === 'gateway') const activeGatewayRows = gatewayRows.filter((c) => c.session?.active) const inactiveGatewayRows = gatewayRows.filter((c) => !c.session?.active) const localRows = filteredConversations.filter((c) => c.source === 'session' && (c.session?.sessionKind === 'claude-code' || c.session?.sessionKind === 'codex-cli' || c.session?.sessionKind === 'hermes')) const activeLocalRows = localRows.filter((c) => c.session?.active) const inactiveLocalRows = localRows.filter((c) => !c.session?.active) function renderConversationItem(conv: Conversation) { const displayName = conv.name || conv.id.replace('agent_', '') const isSessionRow = conv.id.startsWith('session:') const isSelected = activeConversation === conv.id const isEditing = editingId === conv.id return ( ) } return (
{/* Header */}
Sessions
setSearch(e.target.value)} placeholder="Search..." className="w-full bg-surface-1 rounded-md pl-7 pr-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-primary/30" />
{/* Conversation list */}
{filteredConversations.length === 0 ? (
No conversations yet
) : ( <> {activeGatewayRows.length > 0 && (
Active
{activeGatewayRows.map(renderConversationItem)}
)} {activeLocalRows.length > 0 && (
Active Local
{activeLocalRows.map(renderConversationItem)}
)} {inactiveGatewayRows.length > 0 && (
Recent
{inactiveGatewayRows.map(renderConversationItem)}
)} {inactiveLocalRows.length > 0 && (
Recent Local
{inactiveLocalRows.map(renderConversationItem)}
)} )}
{/* Context menu */} {ctxMenu && (() => { const conv = conversations.find((c) => c.id === ctxMenu.convId) if (!conv?.session?.prefKey) return null return (
Color
{COLOR_OPTIONS.map((opt) => { const isCurrentColor = (conv.session?.colorTag || '') === opt.value return ( ) })}
) })()}
) }