[object Object]

← back to Exo

feat(dashboard): add mobile drawer support for sidebars (#1677)

a6aa07ed834d22bfcfb1b475c8e5e28dc09cb410 · 2026-03-10 05:18:11 +0100 · ArvidSU

## Summary

- Adds mobile-friendly drawer UI for the chat sidebar and model family
sidebar
- Introduces responsive header navigation with mobile-specific toggle
buttons
- Uses slide-in drawer overlay pattern on small screens instead of
collapsible sidebars
- Stores mobile drawer state in app store for consistent state
management

## Testing

Tested on desktop (responsive mode) and mobile viewport widths. Sidebars
now slide in as drawers on small screens with proper z-index layering
and close-on-outside-click behavior.

Files touched

Diff

commit a6aa07ed834d22bfcfb1b475c8e5e28dc09cb410
Author: ArvidSU <45108382+ArvidSU@users.noreply.github.com>
Date:   Tue Mar 10 05:18:11 2026 +0100

    feat(dashboard): add mobile drawer support for sidebars (#1677)
    
    ## Summary
    
    - Adds mobile-friendly drawer UI for the chat sidebar and model family
    sidebar
    - Introduces responsive header navigation with mobile-specific toggle
    buttons
    - Uses slide-in drawer overlay pattern on small screens instead of
    collapsible sidebars
    - Stores mobile drawer state in app store for consistent state
    management
    
    ## Testing
    
    Tested on desktop (responsive mode) and mobile viewport widths. Sidebars
    now slide in as drawers on small screens with proper z-index layering
    and close-on-outside-click behavior.
---
 dashboard/src/lib/components/ChatSidebar.svelte   |  42 ++++-
 dashboard/src/lib/components/FamilySidebar.svelte |   4 +-
 dashboard/src/lib/components/HeaderNav.svelte     | 179 ++++++++++++++++------
 dashboard/src/lib/stores/app.svelte.ts            |  38 +++++
 dashboard/src/routes/+page.svelte                 |  63 +++++++-
 5 files changed, 268 insertions(+), 58 deletions(-)

diff --git a/dashboard/src/lib/components/ChatSidebar.svelte b/dashboard/src/lib/components/ChatSidebar.svelte
index 1f216498..1e8c975f 100644
--- a/dashboard/src/lib/components/ChatSidebar.svelte
+++ b/dashboard/src/lib/components/ChatSidebar.svelte
@@ -18,12 +18,18 @@
     class?: string;
     onNewChat?: () => void;
     onSelectConversation?: () => void;
+    isMobileDrawer?: boolean;
+    isOpen?: boolean;
+    onClose?: () => void;
   }
 
   let {
     class: className = "",
     onNewChat,
     onSelectConversation,
+    isMobileDrawer = false,
+    isOpen = false,
+    onClose,
   }: Props = $props();
 
   const conversationList = $derived(conversations());
@@ -53,6 +59,10 @@
   function handleSelectConversation(id: string) {
     onSelectConversation?.();
     loadConversation(id);
+    // Close mobile drawer when selecting a conversation
+    if (isMobileDrawer && isOpen) {
+      onClose?.();
+    }
   }
 
   function handleStartEdit(id: string, name: string, event: MouseEvent) {
@@ -252,9 +262,7 @@
   }
 </script>
 
-<aside
-  class="flex flex-col h-full bg-exo-dark-gray border-r border-exo-yellow/10 {className}"
->
+{#snippet sidebarContent()}
   <!-- Header -->
   <div class="p-4">
     <button
@@ -591,4 +599,30 @@
       </button>
     </div>
   </div>
-</aside>
+{/snippet}
+
+{#if isMobileDrawer}
+  <!-- Mobile drawer with overlay -->
+  {#if isOpen}
+    <!-- Overlay backdrop -->
+    <button
+      type="button"
+      class="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden"
+      onclick={() => onClose?.()}
+      aria-label="Close sidebar"
+    ></button>
+    <!-- Drawer panel -->
+    <aside
+      class="fixed left-0 top-0 bottom-0 w-72 bg-exo-dark-gray border-r border-exo-yellow/10 z-50 flex flex-col md:hidden"
+    >
+      {@render sidebarContent()}
+    </aside>
+  {/if}
+{:else}
+  <!-- Desktop sidebar -->
+  <aside
+    class="flex flex-col h-full bg-exo-dark-gray border-r border-exo-yellow/10 {className}"
+  >
+    {@render sidebarContent()}
+  </aside>
+{/if}
diff --git a/dashboard/src/lib/components/FamilySidebar.svelte b/dashboard/src/lib/components/FamilySidebar.svelte
index aeb7538d..de6a6679 100644
--- a/dashboard/src/lib/components/FamilySidebar.svelte
+++ b/dashboard/src/lib/components/FamilySidebar.svelte
@@ -41,13 +41,13 @@
 </script>
 
 <div
-  class="flex flex-col gap-1 py-2 px-1 border-r border-exo-yellow/10 bg-exo-medium-gray/30 min-w-[64px] overflow-y-auto scrollbar-hide"
+  class="flex flex-col gap-1 py-2 px-1 border-r border-exo-yellow/10 bg-exo-medium-gray/30 min-w-[72px] sm:min-w-[64px] overflow-y-auto scrollbar-hide"
 >
   <!-- All models (no filter) -->
   <button
     type="button"
     onclick={() => onSelect(null)}
-    class="group flex flex-col items-center justify-center p-2 rounded transition-all duration-200 cursor-pointer {selectedFamily ===
+    class="group flex flex-col items-center justify-center p-2 sm:p-2 rounded transition-all duration-200 cursor-pointer min-h-[44px] sm:min-h-0 {selectedFamily ===
     null
       ? 'bg-exo-yellow/20 border-l-2 border-exo-yellow'
       : 'hover:bg-white/5 border-l-2 border-transparent'}"
diff --git a/dashboard/src/lib/components/HeaderNav.svelte b/dashboard/src/lib/components/HeaderNav.svelte
index 99f57b14..16582a64 100644
--- a/dashboard/src/lib/components/HeaderNav.svelte
+++ b/dashboard/src/lib/components/HeaderNav.svelte
@@ -6,6 +6,12 @@
   export let showSidebarToggle = false;
   export let sidebarVisible = true;
   export let onToggleSidebar: (() => void) | null = null;
+  export let showMobileMenuToggle = false;
+  export let mobileMenuOpen = false;
+  export let onToggleMobileMenu: (() => void) | null = null;
+  export let showMobileRightToggle = false;
+  export let mobileRightOpen = false;
+  export let onToggleMobileRight: (() => void) | null = null;
   export let downloadProgress: {
     count: number;
     percentage: number;
@@ -27,49 +33,96 @@
       onToggleSidebar();
     }
   }
+
+  function handleToggleMobileMenu(): void {
+    if (onToggleMobileMenu) {
+      onToggleMobileMenu();
+    }
+  }
+
+  function handleToggleMobileRight(): void {
+    if (onToggleMobileRight) {
+      onToggleMobileRight();
+    }
+  }
 </script>
 
 <header
-  class="relative z-20 flex items-center justify-center px-6 pt-8 pb-4 bg-exo-dark-gray"
+  class="relative z-20 flex items-center justify-center px-4 md:px-6 pt-4 md:pt-8 pb-3 md:pb-4 bg-exo-dark-gray"
 >
-  <!-- Left: Sidebar Toggle -->
-  {#if showSidebarToggle}
-    <div class="absolute left-6 top-1/2 -translate-y-1/2">
-      <button
-        onclick={handleToggleSidebar}
-        class="p-2 rounded border border-exo-light-gray/30 hover:border-exo-yellow/50 hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
-        title={sidebarVisible ? "Hide sidebar" : "Show sidebar"}
-        aria-label={sidebarVisible
-          ? "Hide conversation sidebar"
-          : "Show conversation sidebar"}
-        aria-pressed={sidebarVisible}
+  <!-- Left: Sidebar Toggle (desktop) or Mobile Sidebar Toggle (mobile) -->
+  <div
+    class="absolute left-4 md:left-6 top-1/2 -translate-y-1/2 flex items-center gap-2"
+  >
+    <!-- Mobile sidebar toggle -->
+    <button
+      onclick={handleToggleMobileMenu}
+      class="p-2 rounded border border-exo-light-gray/30 hover:border-exo-yellow/50 hover:bg-exo-medium-gray/30 transition-colors cursor-pointer md:hidden"
+      title={mobileMenuOpen ? "Hide sidebar" : "Show sidebar"}
+      aria-label={mobileMenuOpen
+        ? "Hide conversation sidebar"
+        : "Show conversation sidebar"}
+      aria-pressed={mobileMenuOpen}
+    >
+      <svg
+        fill="none"
+        viewBox="0 0 24 24"
+        stroke="currentColor"
+        stroke-width="2"
+        class="w-5 h-5 {mobileMenuOpen
+          ? 'text-exo-yellow'
+          : 'text-exo-light-gray'}"
       >
-        <svg
-          class="w-5 h-5 {sidebarVisible
-            ? 'text-exo-yellow'
-            : 'text-exo-light-gray'}"
-          fill="none"
-          viewBox="0 0 24 24"
-          stroke="currentColor"
-          stroke-width="2"
-        >
-          {#if sidebarVisible}
-            <path
-              stroke-linecap="round"
-              stroke-linejoin="round"
-              d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
-            />
-          {:else}
-            <path
-              stroke-linecap="round"
-              stroke-linejoin="round"
-              d="M13 5l7 7-7 7M5 5l7 7-7 7"
-            />
-          {/if}
-        </svg>
-      </button>
-    </div>
-  {/if}
+        {#if mobileMenuOpen}
+          <path
+            stroke-linecap="round"
+            stroke-linejoin="round"
+            d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
+          ></path>
+        {:else}
+          <path
+            stroke-linecap="round"
+            stroke-linejoin="round"
+            d="M13 5l7 7-7 7M5 5l7 7-7 7"
+          ></path>
+        {/if}
+      </svg>
+    </button>
+    <!-- Desktop sidebar toggle -->
+    <button
+      onclick={handleToggleSidebar}
+      class="p-2 rounded border border-exo-light-gray/30 hover:border-exo-yellow/50 hover:bg-exo-medium-gray/30 transition-colors cursor-pointer hidden md:block"
+      title={sidebarVisible ? "Hide sidebar" : "Show sidebar"}
+      aria-label={sidebarVisible
+        ? "Hide conversation sidebar"
+        : "Show conversation sidebar"}
+      aria-pressed={sidebarVisible}
+    >
+      <svg
+        fill="none"
+        viewBox="0 0 24 24"
+        stroke="currentColor"
+        stroke-width="2"
+        class="w-5 h-5 {sidebarVisible
+          ? 'text-exo-yellow'
+          : 'text-exo-light-gray'}"
+      >
+        {#if sidebarVisible}
+          <path
+            stroke-linecap="round"
+            stroke-linejoin="round"
+            d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
+          ></path>
+        {:else}
+          <path
+            stroke-linecap="round"
+            stroke-linejoin="round"
+            d="M13 5l7 7-7 7M5 5l7 7-7 7"
+          ></path>
+        {/if}
+      </svg>
+    </button>
+  </div>
 
   <!-- Center: Logo (clickable to go home) -->
   <button
@@ -83,19 +136,55 @@
     <img
       src="/exo-logo.png"
       alt="EXO"
-      class="h-18 drop-shadow-[0_0_4px_rgba(255,215,0,0.3)]"
+      class="h-12 md:h-18 drop-shadow-[0_0_4px_rgba(255,215,0,0.3)]"
     />
   </button>
 
-  <!-- Right: Home + Downloads -->
+  <!-- Right: Home + Downloads + Mobile Right Toggle -->
   <nav
-    class="absolute right-6 top-1/2 -translate-y-1/2 flex items-center gap-4"
+    class="absolute right-4 md:right-6 top-1/2 -translate-y-1/2 flex items-center gap-2 md:gap-4"
     aria-label="Main navigation"
   >
+    <!-- Mobile right sidebar toggle (instances/models) - only show when not in chat mode -->
+    {#if showMobileRightToggle}
+      <button
+        onclick={handleToggleMobileRight}
+        class="p-2 rounded border border-exo-light-gray/30 hover:border-exo-yellow/50 hover:bg-exo-medium-gray/30 transition-colors cursor-pointer md:hidden"
+        title={mobileRightOpen ? "Hide instances" : "Show instances"}
+        aria-label={mobileRightOpen
+          ? "Hide instances panel"
+          : "Show instances panel"}
+        aria-pressed={mobileRightOpen}
+      >
+        <svg
+          fill="none"
+          viewBox="0 0 24 24"
+          stroke="currentColor"
+          stroke-width="2"
+          class="w-5 h-5 {mobileRightOpen
+            ? 'text-exo-yellow'
+            : 'text-exo-light-gray'}"
+        >
+          {#if mobileRightOpen}
+            <path
+              stroke-linecap="round"
+              stroke-linejoin="round"
+              d="M13 5l7 7-7 7M5 5l7 7-7 7"
+            ></path>
+          {:else}
+            <path
+              stroke-linecap="round"
+              stroke-linejoin="round"
+              d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
+            ></path>
+          {/if}
+        </svg>
+      </button>
+    {/if}
     {#if showHome}
       <button
         onclick={handleHome}
-        class="text-sm text-white/70 hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
+        class="flex text-sm text-white/70 hover:text-exo-yellow transition-colors tracking-wider uppercase items-center gap-2 cursor-pointer"
         title="Back to topology view"
       >
         <svg
@@ -111,12 +200,12 @@
             d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
           />
         </svg>
-        Home
+        <span class="hidden sm:inline">Home</span>
       </button>
     {/if}
     <a
       href="/#/downloads"
-      class="text-sm text-white/70 hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
+      class="text-xs md:text-sm text-white/70 hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-1.5 md:gap-2 cursor-pointer"
       title="View downloads overview"
     >
       {#if downloadProgress}
@@ -168,7 +257,7 @@
           <path d="M5 21h14" />
         </svg>
       {/if}
-      Downloads
+      <span class="hidden sm:inline">Downloads</span>
     </a>
   </nav>
 </header>
diff --git a/dashboard/src/lib/stores/app.svelte.ts b/dashboard/src/lib/stores/app.svelte.ts
index ed7719b2..c31da1a7 100644
--- a/dashboard/src/lib/stores/app.svelte.ts
+++ b/dashboard/src/lib/stores/app.svelte.ts
@@ -580,6 +580,8 @@ class AppStore {
   debugMode = $state(false);
   topologyOnlyMode = $state(false);
   chatSidebarVisible = $state(true); // Shown by default
+  mobileChatSidebarOpen = $state(false); // Mobile drawer state
+  mobileRightSidebarOpen = $state(false); // Mobile right drawer state
 
   // Image generation params
   imageGenerationParams = $state<ImageGenerationParams>({
@@ -1245,6 +1247,30 @@ class AppStore {
     this.saveChatSidebarVisibleToStorage();
   }
 
+  getMobileChatSidebarOpen(): boolean {
+    return this.mobileChatSidebarOpen;
+  }
+
+  setMobileChatSidebarOpen(open: boolean) {
+    this.mobileChatSidebarOpen = open;
+  }
+
+  toggleMobileChatSidebar() {
+    this.mobileChatSidebarOpen = !this.mobileChatSidebarOpen;
+  }
+
+  getMobileRightSidebarOpen(): boolean {
+    return this.mobileRightSidebarOpen;
+  }
+
+  setMobileRightSidebarOpen(open: boolean) {
+    this.mobileRightSidebarOpen = open;
+  }
+
+  toggleMobileRightSidebar() {
+    this.mobileRightSidebarOpen = !this.mobileRightSidebarOpen;
+  }
+
   startPolling() {
     this.fetchState();
     this.fetchInterval = setInterval(() => this.fetchState(), 1000);
@@ -3282,6 +3308,18 @@ export const toggleChatSidebarVisible = () =>
   appStore.toggleChatSidebarVisible();
 export const setChatSidebarVisible = (visible: boolean) =>
   appStore.setChatSidebarVisible(visible);
+
+// Mobile sidebar state
+export const mobileChatSidebarOpen = () => appStore.mobileChatSidebarOpen;
+export const toggleMobileChatSidebar = () => appStore.toggleMobileChatSidebar();
+export const setMobileChatSidebarOpen = (open: boolean) =>
+  appStore.setMobileChatSidebarOpen(open);
+export const mobileRightSidebarOpen = () => appStore.mobileRightSidebarOpen;
+export const toggleMobileRightSidebar = () =>
+  appStore.toggleMobileRightSidebar();
+export const setMobileRightSidebarOpen = (open: boolean) =>
+  appStore.setMobileRightSidebarOpen(open);
+
 export const refreshState = () => appStore.fetchState();
 
 // Connection status
diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte
index c96cb63b..ad106b61 100644
--- a/dashboard/src/routes/+page.svelte
+++ b/dashboard/src/routes/+page.svelte
@@ -49,6 +49,12 @@
     toggleTopologyOnlyMode,
     chatSidebarVisible,
     toggleChatSidebarVisible,
+    mobileChatSidebarOpen,
+    toggleMobileChatSidebar,
+    setMobileChatSidebarOpen,
+    mobileRightSidebarOpen,
+    toggleMobileRightSidebar,
+    setMobileRightSidebarOpen,
     nodeThunderbolt,
     nodeRdmaCtl,
     thunderboltBridgeCycles,
@@ -79,6 +85,8 @@
   const debugEnabled = $derived(debugMode());
   const topologyOnlyEnabled = $derived(topologyOnlyMode());
   const sidebarVisible = $derived(chatSidebarVisible());
+  const mobileChatOpen = $derived(mobileChatSidebarOpen());
+  const mobileRightOpen = $derived(mobileRightSidebarOpen());
   const tbBridgeCycles = $derived(thunderboltBridgeCycles());
   const tbBridgeData = $derived(nodeThunderboltBridge());
   const identitiesData = $derived(nodeIdentities());
@@ -4604,16 +4612,35 @@
       showSidebarToggle={true}
       {sidebarVisible}
       onToggleSidebar={toggleChatSidebarVisible}
+      showMobileMenuToggle={true}
+      mobileMenuOpen={mobileChatOpen}
+      onToggleMobileMenu={toggleMobileChatSidebar}
+      showMobileRightToggle={!chatStarted && !topologyOnlyEnabled}
+      {mobileRightOpen}
+      onToggleMobileRight={toggleMobileRightSidebar}
       downloadProgress={activeDownloadSummary}
     />
   {/if}
 
+  <!-- Mobile Chat Sidebar Drawer -->
+  {#if !topologyOnlyEnabled}
+    <ChatSidebar
+      isMobileDrawer={true}
+      isOpen={mobileChatOpen}
+      onClose={() => setMobileChatSidebarOpen(false)}
+      onNewChat={handleNewChat}
+      onSelectConversation={() => {
+        userForcedIdle = false;
+      }}
+    />
+  {/if}
+
   <!-- Main Content -->
   <main class="flex-1 flex overflow-hidden relative">
-    <!-- Left: Conversation History Sidebar (hidden in topology-only mode, welcome state, or when toggled off) -->
+    <!-- Left: Conversation History Sidebar (hidden in topology-only mode, welcome state, or when toggled off) - Desktop only -->
     {#if !topologyOnlyEnabled && sidebarVisible}
       <div
-        class="w-80 flex-shrink-0 border-r border-exo-yellow/10"
+        class="hidden md:block w-80 flex-shrink-0 border-r border-exo-yellow/10"
         role="complementary"
         aria-label="Conversation history"
       >
@@ -4923,11 +4950,33 @@
           </div>
         </div>
 
-        <!-- Right Sidebar: Instance Controls (wider on welcome page for better visibility) -->
+        <!-- Mobile Right Sidebar Drawer (Instances) -->
+        {#if mobileRightOpen}
+          <!-- Overlay backdrop -->
+          <button
+            type="button"
+            class="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden"
+            onclick={() => setMobileRightSidebarOpen(false)}
+            aria-label="Close instances panel"
+          ></button>
+          <!-- Drawer panel -->
+          <aside
+            class="fixed right-0 top-0 bottom-0 w-80 bg-exo-dark-gray border-l border-exo-yellow/10 z-50 flex flex-col md:hidden overflow-y-auto"
+            aria-label="Instance controls mobile"
+          >
+            {@render rightSidebarContent()}
+          </aside>
+        {/if}
+
+        <!-- Right Sidebar: Instance Controls (wider on welcome page for better visibility) - Desktop only -->
         <aside
-          class="w-80 border-l border-exo-yellow/10 bg-exo-dark-gray flex flex-col flex-shrink-0"
+          class="hidden md:flex w-80 border-l border-exo-yellow/10 bg-exo-dark-gray flex-col flex-shrink-0"
           aria-label="Instance controls"
         >
+          {@render rightSidebarContent()}
+        </aside>
+
+        {#snippet rightSidebarContent()}
           <!-- Running Instances Panel (only shown when instances exist) - Scrollable -->
           {#if instanceCount > 0}
             <div class="p-4 flex-shrink-0">
@@ -5809,7 +5858,7 @@
               {/if}
             </div>
           </div>
-        </aside>
+        {/snippet}
       </div>
     {:else}
       <!-- CHAT STATE: Chat + Mini-Map -->
@@ -6022,10 +6071,10 @@
           {/if}
         </div>
 
-        <!-- Right: Mini-Map Sidebar -->
+        <!-- Right: Mini-Map Sidebar - Desktop only -->
         {#if minimized}
           <aside
-            class="w-80 border-l border-exo-yellow/20 bg-exo-dark-gray flex flex-col flex-shrink-0 overflow-y-auto"
+            class="hidden md:flex w-80 border-l border-exo-yellow/20 bg-exo-dark-gray flex-col flex-shrink-0 overflow-y-auto"
             in:fly={{ x: 100, duration: 400, easing: cubicInOut }}
             aria-label="Cluster topology"
           >

← 131ad0ff Implement continuous batching (#1642)  ·  back to Exo  ·  Fix stale state.runners (#1684) 3536161f →