[object Object]

← back to Exo

feat: add Recent tab to model picker (#1440)

7bed91c9c277d568c9ffcb24dd6b07d23285b3d0 · 2026-02-11 12:34:08 -0800 · Alex Cheema

## Motivation

When frequently switching between models, it's tedious to search through
the full model list to find ones you've used before. A "Recent" tab
provides quick access to previously launched models.

## Changes

- **New store** (`dashboard/src/lib/stores/recents.svelte.ts`):
`RecentsStore` class persisting recently launched model IDs with
timestamps to localStorage (key: `exo-recent-models`). Caps at 20
entries, deduplicates on re-launch (moves to top).
- **FamilySidebar**: Added "Recent" tab between Favorites and Hub,
conditionally shown when there are recent models.
- **FamilyLogos**: Added clock/history icon for the recents tab.
- **ModelPickerModal**: Added `recentModelIds`/`hasRecents` props.
Derives single-variant `ModelGroup[]` from recent IDs and renders them
using the same `ModelPickerGroup` component as all other tabs —
consistent styling, memory grey-out, favorites, info button, download
indicators.
- **+page.svelte**: Calls `recordRecentLaunch(modelId)` after successful
instance launch. Passes reactive recent state to the modal.

## Why It Works

Follows the exact same pattern as the existing Favorites feature
(localStorage persistence, conditional tab display, reactive Svelte 5
`$state`/`$derived`). Recent models are wrapped as single-variant
`ModelGroup` objects so they reuse `ModelPickerGroup` for identical row
rendering across all tabs.

## Test Plan

### Manual Testing
<!-- Hardware: MacBook Pro -->
- Launch a model instance → reopen model picker → "Recent" tab appears
with the launched model
- Launch a second model → it appears at top of the Recent list
- Re-launch the first model → it moves back to top
- Search within the Recent tab filters the list
- Models that don't fit in memory are greyed out (same as All tab)
- Close/reopen browser → recents persist from localStorage

### Automated Testing
- Dashboard builds successfully (`npm run build`)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: rltakashige <rl.takashige@gmail.com>

Files touched

Diff

commit 7bed91c9c277d568c9ffcb24dd6b07d23285b3d0
Author: Alex Cheema <41707476+AlexCheema@users.noreply.github.com>
Date:   Wed Feb 11 12:34:08 2026 -0800

    feat: add Recent tab to model picker (#1440)
    
    ## Motivation
    
    When frequently switching between models, it's tedious to search through
    the full model list to find ones you've used before. A "Recent" tab
    provides quick access to previously launched models.
    
    ## Changes
    
    - **New store** (`dashboard/src/lib/stores/recents.svelte.ts`):
    `RecentsStore` class persisting recently launched model IDs with
    timestamps to localStorage (key: `exo-recent-models`). Caps at 20
    entries, deduplicates on re-launch (moves to top).
    - **FamilySidebar**: Added "Recent" tab between Favorites and Hub,
    conditionally shown when there are recent models.
    - **FamilyLogos**: Added clock/history icon for the recents tab.
    - **ModelPickerModal**: Added `recentModelIds`/`hasRecents` props.
    Derives single-variant `ModelGroup[]` from recent IDs and renders them
    using the same `ModelPickerGroup` component as all other tabs —
    consistent styling, memory grey-out, favorites, info button, download
    indicators.
    - **+page.svelte**: Calls `recordRecentLaunch(modelId)` after successful
    instance launch. Passes reactive recent state to the modal.
    
    ## Why It Works
    
    Follows the exact same pattern as the existing Favorites feature
    (localStorage persistence, conditional tab display, reactive Svelte 5
    `$state`/`$derived`). Recent models are wrapped as single-variant
    `ModelGroup` objects so they reuse `ModelPickerGroup` for identical row
    rendering across all tabs.
    
    ## Test Plan
    
    ### Manual Testing
    <!-- Hardware: MacBook Pro -->
    - Launch a model instance → reopen model picker → "Recent" tab appears
    with the launched model
    - Launch a second model → it appears at top of the Recent list
    - Re-launch the first model → it moves back to top
    - Search within the Recent tab filters the list
    - Models that don't fit in memory are greyed out (same as All tab)
    - Close/reopen browser → recents persist from localStorage
    
    ### Automated Testing
    - Dashboard builds successfully (`npm run build`)
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
    Co-authored-by: rltakashige <rl.takashige@gmail.com>
---
 AGENTS.md                                          | 75 ++++++++++++++++++
 dashboard/src/lib/components/FamilyLogos.svelte    |  6 ++
 dashboard/src/lib/components/FamilySidebar.svelte  | 36 ++++++++-
 .../src/lib/components/ModelPickerGroup.svelte     | 20 +++++
 .../src/lib/components/ModelPickerModal.svelte     | 92 +++++++++++++++++++++-
 dashboard/src/lib/stores/recents.svelte.ts         | 75 ++++++++++++++++++
 dashboard/src/routes/+page.svelte                  | 14 ++++
 7 files changed, 315 insertions(+), 3 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index f9e16397..da0744d0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -119,3 +119,78 @@ From .cursorrules:
 ## Testing
 
 Tests use pytest-asyncio with `asyncio_mode = "auto"`. Tests are in `tests/` subdirectories alongside the code they test. The `EXO_TESTS=1` env var is set during tests.
+
+## Dashboard UI Testing & Screenshots
+
+### Building and Running the Dashboard
+```bash
+# Build the dashboard (must be done before running exo)
+cd dashboard && npm install && npm run build && cd ..
+
+# Start exo (serves the dashboard at http://localhost:52415)
+uv run exo &
+sleep 8  # Wait for server to start
+```
+
+### Taking Headless Screenshots with Playwright
+Use Playwright with headless Chromium for programmatic screenshots — no manual browser interaction needed.
+
+**Setup (one-time):**
+```bash
+npx --yes playwright install chromium
+cd /tmp && npm init -y && npm install playwright
+```
+
+**Taking screenshots:**
+```javascript
+// Run from /tmp where playwright is installed: cd /tmp && node -e "..."
+const { chromium } = require('playwright');
+(async () => {
+  const browser = await chromium.launch({ headless: true });
+  const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+  await page.goto('http://localhost:52415', { waitUntil: 'networkidle' });
+  await page.waitForTimeout(2000);
+
+  // Inject test data into localStorage if needed (e.g., recent models)
+  await page.evaluate(() => {
+    localStorage.setItem('exo-recent-models', JSON.stringify([
+      { modelId: 'mlx-community/Qwen3-30B-A3B-4bit', launchedAt: Date.now() },
+    ]));
+  });
+  await page.reload({ waitUntil: 'networkidle' });
+  await page.waitForTimeout(2000);
+
+  // Interact with UI elements
+  await page.locator('text=SELECT MODEL').click();
+  await page.waitForTimeout(1000);
+
+  // Take screenshot
+  await page.screenshot({ path: '/tmp/screenshot.png', fullPage: false });
+  await browser.close();
+})();
+```
+
+### Uploading Images to GitHub PRs
+GitHub's API doesn't support direct image upload for PR comments. Workaround:
+
+1. **Commit images to the branch** (temporarily):
+   ```bash
+   cp /tmp/screenshot.png .
+   git add screenshot.png
+   git commit -m "temp: add screenshots for PR"
+   git push origin <branch>
+   COMMIT_SHA=$(git rev-parse HEAD)
+   ```
+
+2. **Post PR comment** referencing the raw image URL (uses permanent commit SHA so images survive deletion):
+   ```bash
+   gh pr comment <PR_NUMBER> --body "![Screenshot](https://raw.githubusercontent.com/exo-explore/exo/${COMMIT_SHA}/screenshot.png)"
+   ```
+
+3. **Remove the images** from the branch:
+   ```bash
+   git rm screenshot.png
+   git commit -m "chore: remove temporary screenshot files"
+   git push origin <branch>
+   ```
+   The images still render in the PR comment because they reference the permanent commit SHA.
diff --git a/dashboard/src/lib/components/FamilyLogos.svelte b/dashboard/src/lib/components/FamilyLogos.svelte
index e4bd0458..498b12b7 100644
--- a/dashboard/src/lib/components/FamilyLogos.svelte
+++ b/dashboard/src/lib/components/FamilyLogos.svelte
@@ -13,6 +13,12 @@
       d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
     />
   </svg>
+{:else if family === "recents"}
+  <svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
+    <path
+      d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"
+    />
+  </svg>
 {:else if family === "llama" || family === "meta"}
   <svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
     <path
diff --git a/dashboard/src/lib/components/FamilySidebar.svelte b/dashboard/src/lib/components/FamilySidebar.svelte
index 598004f2..aeb7538d 100644
--- a/dashboard/src/lib/components/FamilySidebar.svelte
+++ b/dashboard/src/lib/components/FamilySidebar.svelte
@@ -5,15 +5,22 @@
     families: string[];
     selectedFamily: string | null;
     hasFavorites: boolean;
+    hasRecents: boolean;
     onSelect: (family: string | null) => void;
   };
 
-  let { families, selectedFamily, hasFavorites, onSelect }: FamilySidebarProps =
-    $props();
+  let {
+    families,
+    selectedFamily,
+    hasFavorites,
+    hasRecents,
+    onSelect,
+  }: FamilySidebarProps = $props();
 
   // Family display names
   const familyNames: Record<string, string> = {
     favorites: "Favorites",
+    recents: "Recent",
     huggingface: "Hub",
     llama: "Meta",
     qwen: "Qwen",
@@ -89,6 +96,31 @@
     </button>
   {/if}
 
+  <!-- Recent (only show if has recent models) -->
+  {#if hasRecents}
+    <button
+      type="button"
+      onclick={() => onSelect("recents")}
+      class="group flex flex-col items-center justify-center p-2 rounded transition-all duration-200 cursor-pointer {selectedFamily ===
+      'recents'
+        ? 'bg-exo-yellow/20 border-l-2 border-exo-yellow'
+        : 'hover:bg-white/5 border-l-2 border-transparent'}"
+      title="Recently launched models"
+    >
+      <FamilyLogos
+        family="recents"
+        class={selectedFamily === "recents"
+          ? "text-exo-yellow"
+          : "text-white/50 group-hover:text-white/70"}
+      />
+      <span
+        class="text-[9px] font-mono mt-0.5 {selectedFamily === 'recents'
+          ? 'text-exo-yellow'
+          : 'text-white/40 group-hover:text-white/60'}">Recent</span
+      >
+    </button>
+  {/if}
+
   <!-- HuggingFace Hub -->
   <button
     type="button"
diff --git a/dashboard/src/lib/components/ModelPickerGroup.svelte b/dashboard/src/lib/components/ModelPickerGroup.svelte
index 2b15a5d0..1dbd18d9 100644
--- a/dashboard/src/lib/components/ModelPickerGroup.svelte
+++ b/dashboard/src/lib/components/ModelPickerGroup.svelte
@@ -40,6 +40,7 @@
     onToggleFavorite: (baseModelId: string) => void;
     onShowInfo: (group: ModelGroup) => void;
     downloadStatusMap?: Map<string, DownloadAvailability>;
+    launchedAt?: number;
   };
 
   let {
@@ -54,6 +55,7 @@
     onToggleFavorite,
     onShowInfo,
     downloadStatusMap,
+    launchedAt,
   }: ModelPickerGroupProps = $props();
 
   // Group-level download status: show if any variant is downloaded
@@ -75,6 +77,17 @@
     return `${mb}MB`;
   }
 
+  function timeAgo(ts: number): string {
+    const seconds = Math.floor((Date.now() - ts) / 1000);
+    if (seconds < 60) return "just now";
+    const minutes = Math.floor(seconds / 60);
+    if (minutes < 60) return `${minutes}m ago`;
+    const hours = Math.floor(minutes / 60);
+    if (hours < 24) return `${hours}h ago`;
+    const days = Math.floor(hours / 24);
+    return `${days}d ago`;
+  }
+
   // Check if any variant can fit
   const anyVariantFits = $derived(
     group.variants.some((v) => canModelFit(v.id)),
@@ -300,6 +313,13 @@
       </span>
     {/if}
 
+    <!-- Time ago (for recent models) -->
+    {#if launchedAt}
+      <span class="text-xs font-mono text-white/20 flex-shrink-0">
+        {timeAgo(launchedAt)}
+      </span>
+    {/if}
+
     <!-- Download availability indicator -->
     {#if groupDownloadStatus && groupDownloadStatus.nodeIds.length > 0}
       <span
diff --git a/dashboard/src/lib/components/ModelPickerModal.svelte b/dashboard/src/lib/components/ModelPickerModal.svelte
index 617050c4..21d8228e 100644
--- a/dashboard/src/lib/components/ModelPickerModal.svelte
+++ b/dashboard/src/lib/components/ModelPickerModal.svelte
@@ -6,6 +6,7 @@
   import ModelFilterPopover from "./ModelFilterPopover.svelte";
   import HuggingFaceResultItem from "./HuggingFaceResultItem.svelte";
   import { getNodesWithModelDownloaded } from "$lib/utils/downloads";
+  import { getRecentEntries } from "$lib/stores/recents.svelte";
 
   interface ModelInfo {
     id: string;
@@ -53,6 +54,8 @@
     models: ModelInfo[];
     selectedModelId: string | null;
     favorites: Set<string>;
+    recentModelIds?: string[];
+    hasRecents?: boolean;
     existingModelIds: Set<string>;
     canModelFit: (modelId: string) => boolean;
     getModelFitStatus: (modelId: string) => ModelFitStatus;
@@ -79,6 +82,8 @@
     models,
     selectedModelId,
     favorites,
+    recentModelIds = [],
+    hasRecents: hasRecentsTab = false,
     existingModelIds,
     canModelFit,
     getModelFitStatus,
@@ -387,7 +392,11 @@
     // Filter by family
     if (selectedFamily === "favorites") {
       result = result.filter((g) => favorites.has(g.id));
-    } else if (selectedFamily && selectedFamily !== "huggingface") {
+    } else if (
+      selectedFamily &&
+      selectedFamily !== "huggingface" &&
+      selectedFamily !== "recents"
+    ) {
       result = result.filter((g) => g.family === selectedFamily);
     }
 
@@ -461,6 +470,48 @@
   // Check if any favorites exist
   const hasFavorites = $derived(favorites.size > 0);
 
+  // Timestamp lookup for recent models
+  const recentTimestamps = $derived(
+    new Map(getRecentEntries().map((e) => [e.modelId, e.launchedAt])),
+  );
+
+  // Recent models: single-variant ModelGroups in launch order
+  const recentGroups = $derived.by((): ModelGroup[] => {
+    if (!recentModelIds || recentModelIds.length === 0) return [];
+    const result: ModelGroup[] = [];
+    for (const id of recentModelIds) {
+      const model = models.find((m) => m.id === id);
+      if (model) {
+        result.push({
+          id: model.base_model || model.id,
+          name: model.name || model.id,
+          capabilities: model.capabilities || ["text"],
+          family: model.family || "",
+          variants: [model],
+          smallestVariant: model,
+          hasMultipleVariants: false,
+        });
+      }
+    }
+    return result;
+  });
+
+  // Filtered recent groups (apply search query)
+  const filteredRecentGroups = $derived.by((): ModelGroup[] => {
+    if (!searchQuery.trim()) return recentGroups;
+    const query = searchQuery.toLowerCase().trim();
+    return recentGroups.filter(
+      (g) =>
+        g.name.toLowerCase().includes(query) ||
+        g.variants.some(
+          (v) =>
+            v.id.toLowerCase().includes(query) ||
+            (v.name || "").toLowerCase().includes(query) ||
+            (v.quantization || "").toLowerCase().includes(query),
+        ),
+    );
+  });
+
   function toggleGroupExpanded(groupId: string) {
     const next = new Set(expandedGroups);
     if (next.has(groupId)) {
@@ -618,6 +669,7 @@
         families={uniqueFamilies}
         {selectedFamily}
         {hasFavorites}
+        hasRecents={hasRecentsTab}
         onSelect={(family) => (selectedFamily = family)}
       />
 
@@ -725,6 +777,44 @@
               </div>
             </div>
           </div>
+        {:else if selectedFamily === "recents"}
+          <!-- Recent models view -->
+          {#if filteredRecentGroups.length === 0}
+            <div
+              class="flex flex-col items-center justify-center h-full text-white/40 p-8"
+            >
+              <svg
+                class="w-12 h-12 mb-3"
+                viewBox="0 0 24 24"
+                fill="currentColor"
+              >
+                <path
+                  d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"
+                />
+              </svg>
+              <p class="font-mono text-sm">
+                {searchQuery
+                  ? "No matching recent models"
+                  : "No recently launched models"}
+              </p>
+            </div>
+          {:else}
+            {#each filteredRecentGroups as group}
+              <ModelPickerGroup
+                {group}
+                isExpanded={expandedGroups.has(group.id)}
+                isFavorite={favorites.has(group.id)}
+                {selectedModelId}
+                {canModelFit}
+                onToggleExpand={() => toggleGroupExpanded(group.id)}
+                onSelectModel={handleSelect}
+                {onToggleFavorite}
+                onShowInfo={(g) => (infoGroup = g)}
+                downloadStatusMap={getVariantDownloadMap(group)}
+                launchedAt={recentTimestamps.get(group.variants[0]?.id ?? "")}
+              />
+            {/each}
+          {/if}
         {:else if filteredGroups.length === 0}
           <div
             class="flex flex-col items-center justify-center h-full text-white/40 p-8"
diff --git a/dashboard/src/lib/stores/recents.svelte.ts b/dashboard/src/lib/stores/recents.svelte.ts
new file mode 100644
index 00000000..7ccdd791
--- /dev/null
+++ b/dashboard/src/lib/stores/recents.svelte.ts
@@ -0,0 +1,75 @@
+/**
+ * RecentsStore - Manages recently launched models with localStorage persistence
+ */
+
+import { browser } from "$app/environment";
+
+const RECENTS_KEY = "exo-recent-models";
+const MAX_RECENT_MODELS = 20;
+
+interface RecentEntry {
+  modelId: string;
+  launchedAt: number;
+}
+
+class RecentsStore {
+  recents = $state<RecentEntry[]>([]);
+
+  constructor() {
+    if (browser) {
+      this.loadFromStorage();
+    }
+  }
+
+  private loadFromStorage() {
+    try {
+      const stored = localStorage.getItem(RECENTS_KEY);
+      if (stored) {
+        const parsed = JSON.parse(stored) as RecentEntry[];
+        this.recents = parsed;
+      }
+    } catch (error) {
+      console.error("Failed to load recent models:", error);
+    }
+  }
+
+  private saveToStorage() {
+    try {
+      localStorage.setItem(RECENTS_KEY, JSON.stringify(this.recents));
+    } catch (error) {
+      console.error("Failed to save recent models:", error);
+    }
+  }
+
+  recordLaunch(modelId: string) {
+    // Remove existing entry for this model (if any) to move it to top
+    const filtered = this.recents.filter((r) => r.modelId !== modelId);
+    // Prepend new entry
+    const next = [{ modelId, launchedAt: Date.now() }, ...filtered];
+    // Cap at max
+    this.recents = next.slice(0, MAX_RECENT_MODELS);
+    this.saveToStorage();
+  }
+
+  getRecentModelIds(): string[] {
+    return this.recents.map((r) => r.modelId);
+  }
+
+  hasAny(): boolean {
+    return this.recents.length > 0;
+  }
+
+  clearAll() {
+    this.recents = [];
+    this.saveToStorage();
+  }
+}
+
+export const recentsStore = new RecentsStore();
+
+export const hasRecents = () => recentsStore.hasAny();
+export const getRecentModelIds = () => recentsStore.getRecentModelIds();
+export const getRecentEntries = () => recentsStore.recents;
+export const recordRecentLaunch = (modelId: string) =>
+  recentsStore.recordLaunch(modelId);
+export const clearRecents = () => recentsStore.clearAll();
diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte
index ecbc3c98..39637661 100644
--- a/dashboard/src/routes/+page.svelte
+++ b/dashboard/src/routes/+page.svelte
@@ -12,6 +12,11 @@
     toggleFavorite,
     getFavoritesSet,
   } from "$lib/stores/favorites.svelte";
+  import {
+    hasRecents,
+    getRecentModelIds,
+    recordRecentLaunch,
+  } from "$lib/stores/recents.svelte";
   import {
     hasStartedChat,
     isTopologyMinimized,
@@ -232,6 +237,10 @@
   // Favorites state (reactive)
   const favoritesSet = $derived(getFavoritesSet());
 
+  // Recent models state (reactive)
+  const recentModelIds = $derived(getRecentModelIds());
+  const showRecentsTab = $derived(hasRecents());
+
   // Slider dragging state
   let isDraggingSlider = $state(false);
   let sliderTrackElement: HTMLDivElement | null = $state(null);
@@ -661,6 +670,9 @@
         // Always auto-select the newly launched model so the user chats to what they just launched
         setSelectedChatModel(modelId);
 
+        // Record the launch in recent models history
+        recordRecentLaunch(modelId);
+
         // Scroll to the bottom of instances container to show the new instance
         // Use multiple attempts to ensure DOM has updated with the new instance
         const scrollToBottom = () => {
@@ -3283,6 +3295,8 @@
   {models}
   {selectedModelId}
   favorites={favoritesSet}
+  {recentModelIds}
+  hasRecents={showRecentsTab}
   existingModelIds={new Set(models.map((m) => m.id))}
   canModelFit={(modelId) => {
     const model = models.find((m) => m.id === modelId);

← 48caea4a feat: add intermediate model-fit state for cluster-capacity-  ·  back to Exo  ·  fix: RDMA debug labels, TB5 info box, and rdma_ctl status de 50e2bcf9 →