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>
This commit is contained in:
Alex Cheema
2026-02-11 12:34:08 -08:00
committed by GitHub
parent 48caea4a23
commit 7bed91c9c2
7 changed files with 315 additions and 3 deletions
+75
View File
@@ -119,3 +119,78 @@ From .cursorrules:
## Testing ## 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. 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.
@@ -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" 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> </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"} {:else if family === "llama" || family === "meta"}
<svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor"> <svg class="w-6 h-6 {className}" viewBox="0 0 24 24" fill="currentColor">
<path <path
@@ -5,15 +5,22 @@
families: string[]; families: string[];
selectedFamily: string | null; selectedFamily: string | null;
hasFavorites: boolean; hasFavorites: boolean;
hasRecents: boolean;
onSelect: (family: string | null) => void; onSelect: (family: string | null) => void;
}; };
let { families, selectedFamily, hasFavorites, onSelect }: FamilySidebarProps = let {
$props(); families,
selectedFamily,
hasFavorites,
hasRecents,
onSelect,
}: FamilySidebarProps = $props();
// Family display names // Family display names
const familyNames: Record<string, string> = { const familyNames: Record<string, string> = {
favorites: "Favorites", favorites: "Favorites",
recents: "Recent",
huggingface: "Hub", huggingface: "Hub",
llama: "Meta", llama: "Meta",
qwen: "Qwen", qwen: "Qwen",
@@ -89,6 +96,31 @@
</button> </button>
{/if} {/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 --> <!-- HuggingFace Hub -->
<button <button
type="button" type="button"
@@ -40,6 +40,7 @@
onToggleFavorite: (baseModelId: string) => void; onToggleFavorite: (baseModelId: string) => void;
onShowInfo: (group: ModelGroup) => void; onShowInfo: (group: ModelGroup) => void;
downloadStatusMap?: Map<string, DownloadAvailability>; downloadStatusMap?: Map<string, DownloadAvailability>;
launchedAt?: number;
}; };
let { let {
@@ -54,6 +55,7 @@
onToggleFavorite, onToggleFavorite,
onShowInfo, onShowInfo,
downloadStatusMap, downloadStatusMap,
launchedAt,
}: ModelPickerGroupProps = $props(); }: ModelPickerGroupProps = $props();
// Group-level download status: show if any variant is downloaded // Group-level download status: show if any variant is downloaded
@@ -75,6 +77,17 @@
return `${mb}MB`; 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 // Check if any variant can fit
const anyVariantFits = $derived( const anyVariantFits = $derived(
group.variants.some((v) => canModelFit(v.id)), group.variants.some((v) => canModelFit(v.id)),
@@ -300,6 +313,13 @@
</span> </span>
{/if} {/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 --> <!-- Download availability indicator -->
{#if groupDownloadStatus && groupDownloadStatus.nodeIds.length > 0} {#if groupDownloadStatus && groupDownloadStatus.nodeIds.length > 0}
<span <span
@@ -6,6 +6,7 @@
import ModelFilterPopover from "./ModelFilterPopover.svelte"; import ModelFilterPopover from "./ModelFilterPopover.svelte";
import HuggingFaceResultItem from "./HuggingFaceResultItem.svelte"; import HuggingFaceResultItem from "./HuggingFaceResultItem.svelte";
import { getNodesWithModelDownloaded } from "$lib/utils/downloads"; import { getNodesWithModelDownloaded } from "$lib/utils/downloads";
import { getRecentEntries } from "$lib/stores/recents.svelte";
interface ModelInfo { interface ModelInfo {
id: string; id: string;
@@ -53,6 +54,8 @@
models: ModelInfo[]; models: ModelInfo[];
selectedModelId: string | null; selectedModelId: string | null;
favorites: Set<string>; favorites: Set<string>;
recentModelIds?: string[];
hasRecents?: boolean;
existingModelIds: Set<string>; existingModelIds: Set<string>;
canModelFit: (modelId: string) => boolean; canModelFit: (modelId: string) => boolean;
getModelFitStatus: (modelId: string) => ModelFitStatus; getModelFitStatus: (modelId: string) => ModelFitStatus;
@@ -79,6 +82,8 @@
models, models,
selectedModelId, selectedModelId,
favorites, favorites,
recentModelIds = [],
hasRecents: hasRecentsTab = false,
existingModelIds, existingModelIds,
canModelFit, canModelFit,
getModelFitStatus, getModelFitStatus,
@@ -387,7 +392,11 @@
// Filter by family // Filter by family
if (selectedFamily === "favorites") { if (selectedFamily === "favorites") {
result = result.filter((g) => favorites.has(g.id)); 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); result = result.filter((g) => g.family === selectedFamily);
} }
@@ -461,6 +470,48 @@
// Check if any favorites exist // Check if any favorites exist
const hasFavorites = $derived(favorites.size > 0); 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) { function toggleGroupExpanded(groupId: string) {
const next = new Set(expandedGroups); const next = new Set(expandedGroups);
if (next.has(groupId)) { if (next.has(groupId)) {
@@ -618,6 +669,7 @@
families={uniqueFamilies} families={uniqueFamilies}
{selectedFamily} {selectedFamily}
{hasFavorites} {hasFavorites}
hasRecents={hasRecentsTab}
onSelect={(family) => (selectedFamily = family)} onSelect={(family) => (selectedFamily = family)}
/> />
@@ -725,6 +777,44 @@
</div> </div>
</div> </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} {:else if filteredGroups.length === 0}
<div <div
class="flex flex-col items-center justify-center h-full text-white/40 p-8" class="flex flex-col items-center justify-center h-full text-white/40 p-8"
@@ -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();
+14
View File
@@ -12,6 +12,11 @@
toggleFavorite, toggleFavorite,
getFavoritesSet, getFavoritesSet,
} from "$lib/stores/favorites.svelte"; } from "$lib/stores/favorites.svelte";
import {
hasRecents,
getRecentModelIds,
recordRecentLaunch,
} from "$lib/stores/recents.svelte";
import { import {
hasStartedChat, hasStartedChat,
isTopologyMinimized, isTopologyMinimized,
@@ -232,6 +237,10 @@
// Favorites state (reactive) // Favorites state (reactive)
const favoritesSet = $derived(getFavoritesSet()); const favoritesSet = $derived(getFavoritesSet());
// Recent models state (reactive)
const recentModelIds = $derived(getRecentModelIds());
const showRecentsTab = $derived(hasRecents());
// Slider dragging state // Slider dragging state
let isDraggingSlider = $state(false); let isDraggingSlider = $state(false);
let sliderTrackElement: HTMLDivElement | null = $state(null); 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 // Always auto-select the newly launched model so the user chats to what they just launched
setSelectedChatModel(modelId); setSelectedChatModel(modelId);
// Record the launch in recent models history
recordRecentLaunch(modelId);
// Scroll to the bottom of instances container to show the new instance // Scroll to the bottom of instances container to show the new instance
// Use multiple attempts to ensure DOM has updated with the new instance // Use multiple attempts to ensure DOM has updated with the new instance
const scrollToBottom = () => { const scrollToBottom = () => {
@@ -3283,6 +3295,8 @@
{models} {models}
{selectedModelId} {selectedModelId}
favorites={favoritesSet} favorites={favoritesSet}
{recentModelIds}
hasRecents={showRecentsTab}
existingModelIds={new Set(models.map((m) => m.id))} existingModelIds={new Set(models.map((m) => m.id))}
canModelFit={(modelId) => { canModelFit={(modelId) => {
const model = models.find((m) => m.id === modelId); const model = models.find((m) => m.id === modelId);