1import React, { useState, useEffect } from "react";
2import { Conversation, ConversationWithState } from "../types";
3import { api } from "../services/api";
4
5interface ConversationDrawerProps {
6 isOpen: boolean;
7 isCollapsed: boolean;
8 onClose: () => void;
9 onToggleCollapse: () => void;
10 conversations: ConversationWithState[];
11 currentConversationId: string | null;
12 viewedConversation?: Conversation | null; // The currently viewed conversation (may be a subagent)
13 onSelectConversation: (conversation: Conversation) => void;
14 onNewConversation: () => void;
15 onConversationArchived?: (id: string) => void;
16 onConversationUnarchived?: (conversation: Conversation) => void;
17 onConversationRenamed?: (conversation: Conversation) => void;
18 subagentUpdate?: Conversation | null; // When a subagent is created/updated
19 subagentStateUpdate?: { conversation_id: string; working: boolean } | null; // When a subagent's working state changes
20}
21
22function ConversationDrawer({
23 isOpen,
24 isCollapsed,
25 onClose,
26 onToggleCollapse,
27 conversations,
28 currentConversationId,
29 viewedConversation,
30 onSelectConversation,
31 onNewConversation,
32 onConversationArchived,
33 onConversationUnarchived,
34 onConversationRenamed,
35 subagentUpdate,
36 subagentStateUpdate,
37}: ConversationDrawerProps) {
38 const [showArchived, setShowArchived] = useState(false);
39 const [archivedConversations, setArchivedConversations] = useState<Conversation[]>([]);
40 const [loadingArchived, setLoadingArchived] = useState(false);
41 const [editingId, setEditingId] = useState<string | null>(null);
42 const [editingSlug, setEditingSlug] = useState("");
43 const [subagents, setSubagents] = useState<Record<string, ConversationWithState[]>>({});
44 const [expandedSubagents, setExpandedSubagents] = useState<Set<string>>(new Set());
45 const renameInputRef = React.useRef<HTMLInputElement>(null);
46
47 useEffect(() => {
48 if (showArchived && archivedConversations.length === 0) {
49 loadArchivedConversations();
50 }
51 }, [showArchived]);
52
53 // Load subagents for the current conversation (or parent if viewing a subagent)
54 useEffect(() => {
55 if (!showArchived && currentConversationId) {
56 // If viewing a subagent, also load and expand the parent's subagents
57 const parentId = viewedConversation?.parent_conversation_id;
58 if (parentId) {
59 loadSubagents(parentId);
60 setExpandedSubagents((prev) => new Set([...prev, parentId]));
61 } else {
62 loadSubagents(currentConversationId);
63 setExpandedSubagents((prev) => new Set([...prev, currentConversationId]));
64 }
65 }
66 }, [currentConversationId, viewedConversation, showArchived]);
67
68 // Handle real-time subagent updates
69 useEffect(() => {
70 if (subagentUpdate && subagentUpdate.parent_conversation_id) {
71 const parentId = subagentUpdate.parent_conversation_id;
72 setSubagents((prev) => {
73 const existing = prev[parentId] || [];
74 // Check if this subagent already exists
75 const existingIndex = existing.findIndex(
76 (s) => s.conversation_id === subagentUpdate.conversation_id,
77 );
78 if (existingIndex >= 0) {
79 // Update existing, preserving working state
80 const updated = [...existing];
81 updated[existingIndex] = { ...subagentUpdate, working: existing[existingIndex].working };
82 return { ...prev, [parentId]: updated };
83 } else {
84 // Add new subagent (not working by default)
85 return { ...prev, [parentId]: [...existing, { ...subagentUpdate, working: false }] };
86 }
87 });
88 // Auto-expand parent to show the new subagent
89 setExpandedSubagents((prev) => new Set([...prev, parentId]));
90 }
91 }, [subagentUpdate]);
92
93 // Handle subagent working state updates
94 useEffect(() => {
95 if (subagentStateUpdate) {
96 setSubagents((prev) => {
97 // Find which parent contains this subagent
98 for (const [parentId, subs] of Object.entries(prev)) {
99 const subIndex = subs.findIndex(
100 (s) => s.conversation_id === subagentStateUpdate.conversation_id,
101 );
102 if (subIndex >= 0) {
103 const updated = [...subs];
104 updated[subIndex] = { ...updated[subIndex], working: subagentStateUpdate.working };
105 return { ...prev, [parentId]: updated };
106 }
107 }
108 return prev;
109 });
110 }
111 }, [subagentStateUpdate]);
112
113 const loadSubagents = async (conversationId: string) => {
114 // Skip if already loaded
115 if (subagents[conversationId]) return;
116 try {
117 const subs = await api.getSubagents(conversationId);
118 if (subs && subs.length > 0) {
119 // Add working: false to each subagent
120 const subsWithState = subs.map((s) => ({ ...s, working: false }));
121 setSubagents((prev) => ({ ...prev, [conversationId]: subsWithState }));
122 }
123 } catch (err) {
124 console.error("Failed to load subagents:", err);
125 }
126 };
127
128 const toggleSubagents = (e: React.MouseEvent, conversationId: string) => {
129 e.stopPropagation();
130 setExpandedSubagents((prev) => {
131 const next = new Set(prev);
132 if (next.has(conversationId)) {
133 next.delete(conversationId);
134 } else {
135 next.add(conversationId);
136 // Load subagents if not already loaded
137 loadSubagents(conversationId);
138 }
139 return next;
140 });
141 };
142
143 const loadArchivedConversations = async () => {
144 setLoadingArchived(true);
145 try {
146 const archived = await api.getArchivedConversations();
147 setArchivedConversations(archived);
148 } catch (err) {
149 console.error("Failed to load archived conversations:", err);
150 } finally {
151 setLoadingArchived(false);
152 }
153 };
154
155 const formatDate = (timestamp: string) => {
156 const date = new Date(timestamp);
157 const now = new Date();
158 const diffMs = now.getTime() - date.getTime();
159 const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
160
161 if (diffDays === 0) {
162 return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
163 } else if (diffDays === 1) {
164 return "Yesterday";
165 } else if (diffDays < 7) {
166 return `${diffDays} days ago`;
167 } else {
168 return date.toLocaleDateString();
169 }
170 };
171
172 // Format cwd with ~ for home directory (display only)
173 const formatCwdForDisplay = (cwd: string | null | undefined): string | null => {
174 if (!cwd) return null;
175 const homeDir = window.__SHELLEY_INIT__?.home_dir;
176 if (homeDir && cwd === homeDir) {
177 return "~";
178 }
179 if (homeDir && cwd.startsWith(homeDir + "/")) {
180 return "~" + cwd.slice(homeDir.length);
181 }
182 return cwd;
183 };
184
185 const getConversationPreview = (conversation: Conversation) => {
186 if (conversation.slug) {
187 return conversation.slug;
188 }
189 // Show full conversation ID
190 return conversation.conversation_id;
191 };
192
193 const handleArchive = async (e: React.MouseEvent, conversationId: string) => {
194 e.stopPropagation();
195 try {
196 await api.archiveConversation(conversationId);
197 onConversationArchived?.(conversationId);
198 // Refresh archived list if viewing
199 if (showArchived) {
200 loadArchivedConversations();
201 }
202 } catch (err) {
203 console.error("Failed to archive conversation:", err);
204 }
205 };
206
207 const handleUnarchive = async (e: React.MouseEvent, conversationId: string) => {
208 e.stopPropagation();
209 try {
210 const conversation = await api.unarchiveConversation(conversationId);
211 setArchivedConversations((prev) => prev.filter((c) => c.conversation_id !== conversationId));
212 onConversationUnarchived?.(conversation);
213 } catch (err) {
214 console.error("Failed to unarchive conversation:", err);
215 }
216 };
217
218 const handleDelete = async (e: React.MouseEvent, conversationId: string) => {
219 e.stopPropagation();
220 if (!confirm("Are you sure you want to permanently delete this conversation?")) {
221 return;
222 }
223 try {
224 await api.deleteConversation(conversationId);
225 setArchivedConversations((prev) => prev.filter((c) => c.conversation_id !== conversationId));
226 } catch (err) {
227 console.error("Failed to delete conversation:", err);
228 }
229 };
230
231 // Sanitize slug: lowercase, alphanumeric and hyphens only, max 60 chars
232 const sanitizeSlug = (input: string): string => {
233 return input
234 .toLowerCase()
235 .replace(/[\s_]+/g, "-")
236 .replace(/[^a-z0-9-]+/g, "")
237 .replace(/-+/g, "-")
238 .replace(/^-|-$/g, "")
239 .slice(0, 60)
240 .replace(/-$/g, "");
241 };
242
243 const handleStartRename = (e: React.MouseEvent, conversation: Conversation) => {
244 e.stopPropagation();
245 setEditingId(conversation.conversation_id);
246 setEditingSlug(conversation.slug || "");
247 // Select all text after render
248 setTimeout(() => renameInputRef.current?.select(), 0);
249 };
250
251 const handleRename = async (conversationId: string) => {
252 const sanitized = sanitizeSlug(editingSlug);
253 if (!sanitized) {
254 setEditingId(null);
255 return;
256 }
257
258 // Check for uniqueness against current conversations
259 const isDuplicate = [...conversations, ...archivedConversations].some(
260 (c) => c.slug === sanitized && c.conversation_id !== conversationId,
261 );
262 if (isDuplicate) {
263 alert("A conversation with this name already exists");
264 return;
265 }
266
267 try {
268 const updated = await api.renameConversation(conversationId, sanitized);
269 onConversationRenamed?.(updated);
270 setEditingId(null);
271 } catch (err) {
272 console.error("Failed to rename conversation:", err);
273 }
274 };
275
276 const handleRenameKeyDown = (e: React.KeyboardEvent, conversationId: string) => {
277 // Don't submit while IME is composing (e.g., converting Japanese hiragana to kanji)
278 if (e.nativeEvent.isComposing) {
279 return;
280 }
281 if (e.key === "Enter") {
282 e.preventDefault();
283 handleRename(conversationId);
284 } else if (e.key === "Escape") {
285 setEditingId(null);
286 }
287 };
288
289 const displayedConversations = showArchived ? archivedConversations : conversations;
290
291 return (
292 <>
293 {/* Drawer */}
294 <div className={`drawer ${isOpen ? "open" : ""} ${isCollapsed ? "collapsed" : ""}`}>
295 {/* Header */}
296 <div className="drawer-header">
297 <h2 className="drawer-title">{showArchived ? "Archived" : "Conversations"}</h2>
298 <div className="drawer-header-actions">
299 {/* New conversation button - mobile only */}
300 {!showArchived && (
301 <button
302 onClick={onNewConversation}
303 className="btn-icon hide-on-desktop"
304 aria-label="New conversation"
305 >
306 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
307 <path
308 strokeLinecap="round"
309 strokeLinejoin="round"
310 strokeWidth={2}
311 d="M12 4v16m8-8H4"
312 />
313 </svg>
314 </button>
315 )}
316 <button
317 onClick={onClose}
318 className="btn-icon hide-on-desktop"
319 aria-label="Close conversations"
320 >
321 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
322 <path
323 strokeLinecap="round"
324 strokeLinejoin="round"
325 strokeWidth={2}
326 d="M6 18L18 6M6 6l12 12"
327 />
328 </svg>
329 </button>
330 {/* Collapse button - desktop only */}
331 <button
332 onClick={onToggleCollapse}
333 className="btn-icon show-on-desktop-only"
334 aria-label="Collapse sidebar"
335 title="Collapse sidebar"
336 >
337 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
338 <path
339 strokeLinecap="round"
340 strokeLinejoin="round"
341 strokeWidth={2}
342 d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
343 />
344 </svg>
345 </button>
346 </div>
347 </div>
348
349 {/* Conversations list */}
350 <div className="drawer-body scrollable">
351 {loadingArchived && showArchived ? (
352 <div style={{ padding: "1rem", textAlign: "center" }} className="text-secondary">
353 <p>Loading...</p>
354 </div>
355 ) : displayedConversations.length === 0 ? (
356 <div style={{ padding: "1rem", textAlign: "center" }} className="text-secondary">
357 <p>{showArchived ? "No archived conversations" : "No conversations yet"}</p>
358 {!showArchived && (
359 <p className="text-sm" style={{ marginTop: "0.25rem" }}>
360 Start a new conversation to get started
361 </p>
362 )}
363 </div>
364 ) : (
365 <div className="conversation-list">
366 {displayedConversations.map((conversation) => {
367 const isActive = conversation.conversation_id === currentConversationId;
368 const hasSubagents = subagents[conversation.conversation_id]?.length > 0;
369 const isExpanded = expandedSubagents.has(conversation.conversation_id);
370 const conversationSubagents = subagents[conversation.conversation_id] || [];
371 return (
372 <React.Fragment key={conversation.conversation_id}>
373 <div
374 className={`conversation-item ${isActive ? "active" : ""}`}
375 onClick={() => {
376 if (!showArchived) {
377 onSelectConversation(conversation);
378 }
379 }}
380 style={{ cursor: showArchived ? "default" : "pointer" }}
381 >
382 <div style={{ flex: 1, minWidth: 0 }}>
383 <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
384 <div style={{ flex: 1, minWidth: 0 }}>
385 {editingId === conversation.conversation_id ? (
386 <input
387 ref={renameInputRef}
388 type="text"
389 value={editingSlug}
390 onChange={(e) => setEditingSlug(e.target.value)}
391 onBlur={() => handleRename(conversation.conversation_id)}
392 onKeyDown={(e) =>
393 handleRenameKeyDown(e, conversation.conversation_id)
394 }
395 onClick={(e) => e.stopPropagation()}
396 autoFocus
397 className="conversation-title"
398 style={{
399 width: "100%",
400 background: "transparent",
401 border: "none",
402 borderBottom: "1px solid var(--text-secondary)",
403 outline: "none",
404 padding: 0,
405 font: "inherit",
406 color: "inherit",
407 }}
408 />
409 ) : (
410 <div className="conversation-title">
411 {getConversationPreview(conversation)}
412 </div>
413 )}
414 </div>
415 {(conversation as ConversationWithState).working && (
416 <span
417 className="working-indicator"
418 title="Agent is working"
419 style={{
420 width: "8px",
421 height: "8px",
422 borderRadius: "50%",
423 backgroundColor: "var(--accent-color, #3b82f6)",
424 flexShrink: 0,
425 animation: "pulse 2s ease-in-out infinite",
426 }}
427 />
428 )}
429 </div>
430 <div className="conversation-meta">
431 <span className="conversation-date">
432 {formatDate(conversation.updated_at)}
433 </span>
434 {conversation.cwd && (
435 <span className="conversation-cwd" title={conversation.cwd}>
436 {formatCwdForDisplay(conversation.cwd)}
437 </span>
438 )}
439 {!showArchived && (
440 <div
441 className="conversation-actions"
442 style={{ display: "flex", gap: "0.25rem", marginLeft: "auto" }}
443 >
444 <button
445 onClick={(e) => handleStartRename(e, conversation)}
446 className="btn-icon-sm"
447 title="Rename"
448 aria-label="Rename conversation"
449 >
450 <svg
451 fill="none"
452 stroke="currentColor"
453 viewBox="0 0 24 24"
454 style={{ width: "1rem", height: "1rem" }}
455 >
456 <path
457 strokeLinecap="round"
458 strokeLinejoin="round"
459 strokeWidth={2}
460 d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
461 />
462 </svg>
463 </button>
464 <button
465 onClick={(e) => handleArchive(e, conversation.conversation_id)}
466 className="btn-icon-sm"
467 title="Archive"
468 aria-label="Archive conversation"
469 >
470 <svg
471 fill="none"
472 stroke="currentColor"
473 viewBox="0 0 24 24"
474 style={{ width: "1rem", height: "1rem" }}
475 >
476 <path
477 strokeLinecap="round"
478 strokeLinejoin="round"
479 strokeWidth={2}
480 d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
481 />
482 </svg>
483 </button>
484 {/* Subagent count indicator */}
485 {hasSubagents && (
486 <button
487 onClick={(e) => toggleSubagents(e, conversation.conversation_id)}
488 className="btn-icon-sm"
489 style={{
490 display: "flex",
491 alignItems: "center",
492 gap: "0.125rem",
493 fontSize: "0.75rem",
494 minWidth: "auto",
495 padding: "0.125rem 0.25rem",
496 }}
497 title={isExpanded ? "Hide subagents" : "Show subagents"}
498 aria-label={
499 isExpanded ? "Collapse subagents" : "Expand subagents"
500 }
501 >
502 <span style={{ fontWeight: 500 }}>
503 {conversationSubagents.length}
504 </span>
505 <svg
506 fill="none"
507 stroke="currentColor"
508 viewBox="0 0 24 24"
509 style={{
510 width: "0.625rem",
511 height: "0.625rem",
512 transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
513 transition: "transform 0.15s ease",
514 }}
515 >
516 <path
517 strokeLinecap="round"
518 strokeLinejoin="round"
519 strokeWidth={2}
520 d="M9 5l7 7-7 7"
521 />
522 </svg>
523 </button>
524 )}
525 </div>
526 )}
527 </div>
528 </div>
529 {showArchived && (
530 <div
531 className="conversation-actions"
532 style={{ display: "flex", gap: "0.25rem", marginLeft: "0.5rem" }}
533 >
534 <button
535 onClick={(e) => handleUnarchive(e, conversation.conversation_id)}
536 className="btn-icon-sm"
537 title="Restore"
538 aria-label="Restore conversation"
539 >
540 <svg
541 fill="none"
542 stroke="currentColor"
543 viewBox="0 0 24 24"
544 style={{ width: "1rem", height: "1rem" }}
545 >
546 <path
547 strokeLinecap="round"
548 strokeLinejoin="round"
549 strokeWidth={2}
550 d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
551 />
552 </svg>
553 </button>
554 <button
555 onClick={(e) => handleDelete(e, conversation.conversation_id)}
556 className="btn-icon-sm btn-danger"
557 title="Delete permanently"
558 aria-label="Delete conversation"
559 >
560 <svg
561 fill="none"
562 stroke="currentColor"
563 viewBox="0 0 24 24"
564 style={{ width: "1rem", height: "1rem" }}
565 >
566 <path
567 strokeLinecap="round"
568 strokeLinejoin="round"
569 strokeWidth={2}
570 d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
571 />
572 </svg>
573 </button>
574 </div>
575 )}
576 </div>
577 {/* Render subagents if expanded */}
578 {!showArchived && isExpanded && conversationSubagents.length > 0 && (
579 <div className="subagent-list" style={{ marginLeft: "1.5rem" }}>
580 {conversationSubagents.map((sub) => {
581 const isSubActive = sub.conversation_id === currentConversationId;
582 return (
583 <div
584 key={sub.conversation_id}
585 className={`conversation-item subagent-item ${isSubActive ? "active" : ""}`}
586 onClick={() => onSelectConversation(sub)}
587 style={{
588 cursor: "pointer",
589 fontSize: "0.9em",
590 paddingLeft: "0.5rem",
591 borderLeft: "2px solid var(--border-color)",
592 }}
593 >
594 <div style={{ flex: 1, minWidth: 0 }}>
595 <div
596 style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
597 >
598 <div style={{ flex: 1, minWidth: 0 }}>
599 <div className="conversation-title">
600 {sub.slug || sub.conversation_id}
601 </div>
602 </div>
603 {sub.working && (
604 <span
605 className="working-indicator"
606 title="Subagent is working"
607 style={{
608 width: "6px",
609 height: "6px",
610 borderRadius: "50%",
611 backgroundColor: "var(--accent-color, #3b82f6)",
612 flexShrink: 0,
613 animation: "pulse 2s ease-in-out infinite",
614 }}
615 />
616 )}
617 </div>
618 <div className="conversation-meta">
619 <span
620 className="conversation-date"
621 style={{ fontSize: "0.85em" }}
622 >
623 {formatDate(sub.updated_at)}
624 </span>
625 </div>
626 </div>
627 </div>
628 );
629 })}
630 </div>
631 )}
632 </React.Fragment>
633 );
634 })}
635 </div>
636 )}
637 </div>
638
639 {/* Footer with archived toggle */}
640 <div className="drawer-footer">
641 <button
642 onClick={() => setShowArchived(!showArchived)}
643 className="btn-secondary"
644 style={{
645 width: "100%",
646 display: "flex",
647 alignItems: "center",
648 justifyContent: "center",
649 gap: "0.5rem",
650 }}
651 >
652 <svg
653 fill="none"
654 stroke="currentColor"
655 viewBox="0 0 24 24"
656 style={{ width: "1rem", height: "1rem" }}
657 >
658 {showArchived ? (
659 <path
660 strokeLinecap="round"
661 strokeLinejoin="round"
662 strokeWidth={2}
663 d="M11 15l-3-3m0 0l3-3m-3 3h8M3 12a9 9 0 1118 0 9 9 0 01-18 0z"
664 />
665 ) : (
666 <path
667 strokeLinecap="round"
668 strokeLinejoin="round"
669 strokeWidth={2}
670 d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
671 />
672 )}
673 </svg>
674 <span>{showArchived ? "Back to Conversations" : "View Archived"}</span>
675 </button>
676 </div>
677 </div>
678 </>
679 );
680}
681
682export default ConversationDrawer;