← 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
M AGENTS.mdM dashboard/src/lib/components/FamilyLogos.svelteM dashboard/src/lib/components/FamilySidebar.svelteM dashboard/src/lib/components/ModelPickerGroup.svelteM dashboard/src/lib/components/ModelPickerModal.svelteA dashboard/src/lib/stores/recents.svelte.tsM dashboard/src/routes/+page.svelte
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 ""
+ ```
+
+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 →