shelley/ui: add collapsible conversations drawer

Philip Zeyliger created

Prompt: make the conversations drawer collapsible. Currently, it works
differently on narrow and wide screens. Continue having it work
differently, but have an affordance for collapsibility. Maybe it's the
same button, but it does slightly different things on a wider screen.

Fixes https://github.com/boldsoftware/shelley/issues/4

On desktop (โ‰ฅ768px):
- Add collapse button (double chevron ยซ) in drawer header
- When collapsed, drawer is hidden and expand button (ยป) appears in main header before title
- Drawer can be expanded/collapsed by clicking the respective buttons

On mobile (<768px):
- Behavior unchanged - drawer slides in/out as overlay
- Collapse/expand buttons are hidden on mobile (uses existing hamburger menu pattern)

The collapsed state is persisted in React state and is independent of
the mobile drawer open/close state.

Change summary

ui/src/App.tsx                           |  9 +++++++++
ui/src/components/ChatInterface.tsx      | 23 +++++++++++++++++++++++
ui/src/components/ConversationDrawer.tsx | 22 +++++++++++++++++++++-
ui/src/styles.css                        | 15 +++++++++++++++
4 files changed, 68 insertions(+), 1 deletion(-)

Detailed changes

ui/src/App.tsx ๐Ÿ”—

@@ -61,6 +61,7 @@ function App() {
   const [conversations, setConversations] = useState<Conversation[]>([]);
   const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
   const [drawerOpen, setDrawerOpen] = useState(false);
+  const [drawerCollapsed, setDrawerCollapsed] = useState(false);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
   const initialSlugResolved = useRef(false);
@@ -146,6 +147,10 @@ function App() {
     setDrawerOpen(false);
   };
 
+  const toggleDrawerCollapsed = () => {
+    setDrawerCollapsed((prev) => !prev);
+  };
+
   const updateConversation = (updatedConversation: Conversation) => {
     setConversations((prev) =>
       prev.map((conv) =>
@@ -229,7 +234,9 @@ function App() {
       {/* Conversations drawer */}
       <ConversationDrawer
         isOpen={drawerOpen}
+        isCollapsed={drawerCollapsed}
         onClose={() => setDrawerOpen(false)}
+        onToggleCollapse={toggleDrawerCollapsed}
         conversations={conversations}
         currentConversationId={currentConversationId}
         onSelectConversation={selectConversation}
@@ -249,6 +256,8 @@ function App() {
           onConversationUpdate={updateConversation}
           onFirstMessage={handleFirstMessage}
           mostRecentCwd={mostRecentCwd}
+          isDrawerCollapsed={drawerCollapsed}
+          onToggleDrawerCollapse={toggleDrawerCollapsed}
         />
       </div>
 

ui/src/components/ChatInterface.tsx ๐Ÿ”—

@@ -354,6 +354,8 @@ interface ChatInterfaceProps {
   onConversationUpdate?: (conversation: Conversation) => void;
   onFirstMessage?: (message: string, model: string, cwd?: string) => Promise<void>;
   mostRecentCwd?: string | null;
+  isDrawerCollapsed?: boolean;
+  onToggleDrawerCollapse?: () => void;
 }
 
 function ChatInterface({
@@ -364,6 +366,8 @@ function ChatInterface({
   onConversationUpdate,
   onFirstMessage,
   mostRecentCwd,
+  isDrawerCollapsed,
+  onToggleDrawerCollapse,
 }: ChatInterfaceProps) {
   const [messages, setMessages] = useState<Message[]>([]);
   const [loading, setLoading] = useState(true);
@@ -1041,6 +1045,25 @@ function ChatInterface({
             </svg>
           </button>
 
+          {/* Expand drawer button - desktop only when collapsed */}
+          {isDrawerCollapsed && onToggleDrawerCollapse && (
+            <button
+              onClick={onToggleDrawerCollapse}
+              className="btn-icon show-on-desktop-only"
+              aria-label="Expand sidebar"
+              title="Expand sidebar"
+            >
+              <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M13 5l7 7-7 7M5 5l7 7-7 7"
+                />
+              </svg>
+            </button>
+          )}
+
           <h1 className="header-title" title={currentConversation?.slug || "Shelley"}>
             {getDisplayTitle()}
           </h1>

ui/src/components/ConversationDrawer.tsx ๐Ÿ”—

@@ -4,7 +4,9 @@ import { api } from "../services/api";
 
 interface ConversationDrawerProps {
   isOpen: boolean;
+  isCollapsed: boolean;
   onClose: () => void;
+  onToggleCollapse: () => void;
   conversations: Conversation[];
   currentConversationId: string | null;
   onSelectConversation: (id: string) => void;
@@ -16,7 +18,9 @@ interface ConversationDrawerProps {
 
 function ConversationDrawer({
   isOpen,
+  isCollapsed,
   onClose,
+  onToggleCollapse,
   conversations,
   currentConversationId,
   onSelectConversation,
@@ -189,7 +193,7 @@ function ConversationDrawer({
   return (
     <>
       {/* Drawer */}
-      <div className={`drawer ${isOpen ? "open" : ""}`}>
+      <div className={`drawer ${isOpen ? "open" : ""} ${isCollapsed ? "collapsed" : ""}`}>
         {/* Header */}
         <div className="drawer-header">
           <h2 className="drawer-title">{showArchived ? "Archived" : "Conversations"}</h2>
@@ -225,6 +229,22 @@ function ConversationDrawer({
                 />
               </svg>
             </button>
+            {/* Collapse button - desktop only */}
+            <button
+              onClick={onToggleCollapse}
+              className="btn-icon show-on-desktop-only"
+              aria-label="Collapse sidebar"
+              title="Collapse sidebar"
+            >
+              <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
+                />
+              </svg>
+            </button>
           </div>
         </div>
 

ui/src/styles.css ๐Ÿ”—

@@ -2226,15 +2226,30 @@ svg {
     transform: translateX(0) !important;
   }
 
+  .drawer.collapsed {
+    display: none;
+  }
+
   .hide-on-desktop {
     display: none !important;
   }
 
+  .show-on-desktop-only {
+    display: flex !important;
+  }
+
   .backdrop {
     display: none !important;
   }
 }
 
+/* Hide desktop-only elements on mobile */
+@media (max-width: 767px) {
+  .show-on-desktop-only {
+    display: none !important;
+  }
+}
+
 /* Rotation animation for running tools */
 @keyframes rotate {
   from {