← 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
M dashboard/src/lib/components/ChatSidebar.svelteM dashboard/src/lib/components/FamilySidebar.svelteM dashboard/src/lib/components/HeaderNav.svelteM dashboard/src/lib/stores/app.svelte.tsM dashboard/src/routes/+page.svelte
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 →