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:
@@ -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 ""
|
||||||
|
```
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user