From 7bed91c9c277d568c9ffcb24dd6b07d23285b3d0 Mon Sep 17 00:00:00 2001 From: Alex Cheema <41707476+AlexCheema@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:34:08 -0800 Subject: [PATCH] feat: add Recent tab to model picker (#1440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 - 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 Co-authored-by: rltakashige --- AGENTS.md | 75 +++++++++++++++ .../src/lib/components/FamilyLogos.svelte | 6 ++ .../src/lib/components/FamilySidebar.svelte | 36 +++++++- .../lib/components/ModelPickerGroup.svelte | 20 ++++ .../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(-) create mode 100644 dashboard/src/lib/stores/recents.svelte.ts 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 + 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 --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 + ``` + 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" /> +{:else if family === "recents"} + + + {:else if family === "llama" || family === "meta"} void; }; - let { families, selectedFamily, hasFavorites, onSelect }: FamilySidebarProps = - $props(); + let { + families, + selectedFamily, + hasFavorites, + hasRecents, + onSelect, + }: FamilySidebarProps = $props(); // Family display names const familyNames: Record = { favorites: "Favorites", + recents: "Recent", huggingface: "Hub", llama: "Meta", qwen: "Qwen", @@ -89,6 +96,31 @@ {/if} + + {#if hasRecents} + Recent + + {/if} +