import React, { useState, useEffect } from "react"; import { Conversation, ConversationWithState } from "../types"; import { api } from "../services/api"; interface ConversationDrawerProps { isOpen: boolean; isCollapsed: boolean; onClose: () => void; onToggleCollapse: () => void; conversations: ConversationWithState[]; currentConversationId: string | null; viewedConversation?: Conversation | null; // The currently viewed conversation (may be a subagent) onSelectConversation: (conversation: Conversation) => void; onNewConversation: () => void; onConversationArchived?: (id: string) => void; onConversationUnarchived?: (conversation: Conversation) => void; onConversationRenamed?: (conversation: Conversation) => void; subagentUpdate?: Conversation | null; // When a subagent is created/updated subagentStateUpdate?: { conversation_id: string; working: boolean } | null; // When a subagent's working state changes } function ConversationDrawer({ isOpen, isCollapsed, onClose, onToggleCollapse, conversations, currentConversationId, viewedConversation, onSelectConversation, onNewConversation, onConversationArchived, onConversationUnarchived, onConversationRenamed, subagentUpdate, subagentStateUpdate, }: ConversationDrawerProps) { const [showArchived, setShowArchived] = useState(false); const [archivedConversations, setArchivedConversations] = useState([]); const [loadingArchived, setLoadingArchived] = useState(false); const [editingId, setEditingId] = useState(null); const [editingSlug, setEditingSlug] = useState(""); const [subagents, setSubagents] = useState>({}); const [expandedSubagents, setExpandedSubagents] = useState>(new Set()); const renameInputRef = React.useRef(null); useEffect(() => { if (showArchived && archivedConversations.length === 0) { loadArchivedConversations(); } }, [showArchived]); // Load subagents for the current conversation (or parent if viewing a subagent) useEffect(() => { if (!showArchived && currentConversationId) { // If viewing a subagent, also load and expand the parent's subagents const parentId = viewedConversation?.parent_conversation_id; if (parentId) { loadSubagents(parentId); setExpandedSubagents((prev) => new Set([...prev, parentId])); } else { loadSubagents(currentConversationId); setExpandedSubagents((prev) => new Set([...prev, currentConversationId])); } } }, [currentConversationId, viewedConversation, showArchived]); // Handle real-time subagent updates useEffect(() => { if (subagentUpdate && subagentUpdate.parent_conversation_id) { const parentId = subagentUpdate.parent_conversation_id; setSubagents((prev) => { const existing = prev[parentId] || []; // Check if this subagent already exists const existingIndex = existing.findIndex( (s) => s.conversation_id === subagentUpdate.conversation_id, ); if (existingIndex >= 0) { // Update existing, preserving working state const updated = [...existing]; updated[existingIndex] = { ...subagentUpdate, working: existing[existingIndex].working }; return { ...prev, [parentId]: updated }; } else { // Add new subagent (not working by default) return { ...prev, [parentId]: [...existing, { ...subagentUpdate, working: false }] }; } }); // Auto-expand parent to show the new subagent setExpandedSubagents((prev) => new Set([...prev, parentId])); } }, [subagentUpdate]); // Handle subagent working state updates useEffect(() => { if (subagentStateUpdate) { setSubagents((prev) => { // Find which parent contains this subagent for (const [parentId, subs] of Object.entries(prev)) { const subIndex = subs.findIndex( (s) => s.conversation_id === subagentStateUpdate.conversation_id, ); if (subIndex >= 0) { const updated = [...subs]; updated[subIndex] = { ...updated[subIndex], working: subagentStateUpdate.working }; return { ...prev, [parentId]: updated }; } } return prev; }); } }, [subagentStateUpdate]); const loadSubagents = async (conversationId: string) => { // Skip if already loaded if (subagents[conversationId]) return; try { const subs = await api.getSubagents(conversationId); if (subs && subs.length > 0) { // Add working: false to each subagent const subsWithState = subs.map((s) => ({ ...s, working: false })); setSubagents((prev) => ({ ...prev, [conversationId]: subsWithState })); } } catch (err) { console.error("Failed to load subagents:", err); } }; const toggleSubagents = (e: React.MouseEvent, conversationId: string) => { e.stopPropagation(); setExpandedSubagents((prev) => { const next = new Set(prev); if (next.has(conversationId)) { next.delete(conversationId); } else { next.add(conversationId); // Load subagents if not already loaded loadSubagents(conversationId); } return next; }); }; const loadArchivedConversations = async () => { setLoadingArchived(true); try { const archived = await api.getArchivedConversations(); setArchivedConversations(archived); } catch (err) { console.error("Failed to load archived conversations:", err); } finally { setLoadingArchived(false); } }; const formatDate = (timestamp: string) => { const date = new Date(timestamp); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) { return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } else if (diffDays === 1) { return "Yesterday"; } else if (diffDays < 7) { return `${diffDays} days ago`; } else { return date.toLocaleDateString(); } }; // Format cwd with ~ for home directory (display only) const formatCwdForDisplay = (cwd: string | null | undefined): string | null => { if (!cwd) return null; const homeDir = window.__SHELLEY_INIT__?.home_dir; if (homeDir && cwd === homeDir) { return "~"; } if (homeDir && cwd.startsWith(homeDir + "/")) { return "~" + cwd.slice(homeDir.length); } return cwd; }; const getConversationPreview = (conversation: Conversation) => { if (conversation.slug) { return conversation.slug; } // Show full conversation ID return conversation.conversation_id; }; const handleArchive = async (e: React.MouseEvent, conversationId: string) => { e.stopPropagation(); try { await api.archiveConversation(conversationId); onConversationArchived?.(conversationId); // Refresh archived list if viewing if (showArchived) { loadArchivedConversations(); } } catch (err) { console.error("Failed to archive conversation:", err); } }; const handleUnarchive = async (e: React.MouseEvent, conversationId: string) => { e.stopPropagation(); try { const conversation = await api.unarchiveConversation(conversationId); setArchivedConversations((prev) => prev.filter((c) => c.conversation_id !== conversationId)); onConversationUnarchived?.(conversation); } catch (err) { console.error("Failed to unarchive conversation:", err); } }; const handleDelete = async (e: React.MouseEvent, conversationId: string) => { e.stopPropagation(); if (!confirm("Are you sure you want to permanently delete this conversation?")) { return; } try { await api.deleteConversation(conversationId); setArchivedConversations((prev) => prev.filter((c) => c.conversation_id !== conversationId)); } catch (err) { console.error("Failed to delete conversation:", err); } }; // Sanitize slug: lowercase, alphanumeric and hyphens only, max 60 chars const sanitizeSlug = (input: string): string => { return input .toLowerCase() .replace(/[\s_]+/g, "-") .replace(/[^a-z0-9-]+/g, "") .replace(/-+/g, "-") .replace(/^-|-$/g, "") .slice(0, 60) .replace(/-$/g, ""); }; const handleStartRename = (e: React.MouseEvent, conversation: Conversation) => { e.stopPropagation(); setEditingId(conversation.conversation_id); setEditingSlug(conversation.slug || ""); // Select all text after render setTimeout(() => renameInputRef.current?.select(), 0); }; const handleRename = async (conversationId: string) => { const sanitized = sanitizeSlug(editingSlug); if (!sanitized) { setEditingId(null); return; } // Check for uniqueness against current conversations const isDuplicate = [...conversations, ...archivedConversations].some( (c) => c.slug === sanitized && c.conversation_id !== conversationId, ); if (isDuplicate) { alert("A conversation with this name already exists"); return; } try { const updated = await api.renameConversation(conversationId, sanitized); onConversationRenamed?.(updated); setEditingId(null); } catch (err) { console.error("Failed to rename conversation:", err); } }; const handleRenameKeyDown = (e: React.KeyboardEvent, conversationId: string) => { // Don't submit while IME is composing (e.g., converting Japanese hiragana to kanji) if (e.nativeEvent.isComposing) { return; } if (e.key === "Enter") { e.preventDefault(); handleRename(conversationId); } else if (e.key === "Escape") { setEditingId(null); } }; const displayedConversations = showArchived ? archivedConversations : conversations; return ( <> {/* Drawer */}
{/* Header */}

{showArchived ? "Archived" : "Conversations"}

{/* New conversation button - mobile only */} {!showArchived && ( )} {/* Collapse button - desktop only */}
{/* Conversations list */}
{loadingArchived && showArchived ? (

Loading...

) : displayedConversations.length === 0 ? (

{showArchived ? "No archived conversations" : "No conversations yet"}

{!showArchived && (

Start a new conversation to get started

)}
) : (
{displayedConversations.map((conversation) => { const isActive = conversation.conversation_id === currentConversationId; const hasSubagents = subagents[conversation.conversation_id]?.length > 0; const isExpanded = expandedSubagents.has(conversation.conversation_id); const conversationSubagents = subagents[conversation.conversation_id] || []; return (
{ if (!showArchived) { onSelectConversation(conversation); } }} style={{ cursor: showArchived ? "default" : "pointer" }} >
{editingId === conversation.conversation_id ? ( setEditingSlug(e.target.value)} onBlur={() => handleRename(conversation.conversation_id)} onKeyDown={(e) => handleRenameKeyDown(e, conversation.conversation_id) } onClick={(e) => e.stopPropagation()} autoFocus className="conversation-title" style={{ width: "100%", background: "transparent", border: "none", borderBottom: "1px solid var(--text-secondary)", outline: "none", padding: 0, font: "inherit", color: "inherit", }} /> ) : (
{getConversationPreview(conversation)}
)}
{(conversation as ConversationWithState).working && ( )}
{formatDate(conversation.updated_at)} {conversation.cwd && ( {formatCwdForDisplay(conversation.cwd)} )} {!showArchived && (
{/* Subagent count indicator */} {hasSubagents && ( )}
)}
{showArchived && (
)}
{/* Render subagents if expanded */} {!showArchived && isExpanded && conversationSubagents.length > 0 && (
{conversationSubagents.map((sub) => { const isSubActive = sub.conversation_id === currentConversationId; return (
onSelectConversation(sub)} style={{ cursor: "pointer", fontSize: "0.9em", paddingLeft: "0.5rem", borderLeft: "2px solid var(--border-color)", }} >
{sub.slug || sub.conversation_id}
{sub.working && ( )}
{formatDate(sub.updated_at)}
); })}
)}
); })}
)}
{/* Footer with archived toggle */}
); } export default ConversationDrawer;