ConversationDrawer.tsx

  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;