ui: persist playground state across route navigation (#525)

- Keep Playground component mounted when navigating away, preserving
streaming/generating state
- Add animated gradient effect on Playground nav link when activity is
in progress
This commit is contained in:
Benson Wong
2026-02-15 21:30:52 -08:00
committed by GitHub
parent 3e52144058
commit e3bf065574
11 changed files with 136 additions and 66 deletions
+30 -8
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { link, location } from "svelte-spa-router";
import { link } from "svelte-spa-router";
import { screenWidth, toggleTheme, isDarkMode, appTitle, isNarrow } from "../stores/theme";
import { currentRoute } from "../stores/route";
import { playgroundActivity } from "../stores/playgroundActivity";
import ConnectionStatus from "./ConnectionStatus.svelte";
function handleTitleChange(newTitle: string): void {
@@ -22,9 +24,10 @@
handleTitleChange(target.textContent || "(set title)");
}
function isActive(path: string, currentLocation: string): boolean {
return path === "/" ? currentLocation === "/" : currentLocation.startsWith(path);
function isActive(path: string, current: string): boolean {
return path === "/" ? current === "/" : current.startsWith(path);
}
</script>
<header
@@ -47,8 +50,7 @@
<a
href="/"
use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/", $location)}
class="p-1 whitespace-nowrap {isActive('/', $currentRoute) ? 'font-semibold' : ''} {$playgroundActivity ? 'activity-link' : 'text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100'}"
>
Playground
</a>
@@ -56,7 +58,7 @@
href="/models"
use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/models", $location)}
class:font-semibold={isActive("/models", $currentRoute)}
>
Models
</a>
@@ -64,7 +66,7 @@
href="/activity"
use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/activity", $location)}
class:font-semibold={isActive("/activity", $currentRoute)}
>
Activity
</a>
@@ -72,7 +74,7 @@
href="/logs"
use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/logs", $location)}
class:font-semibold={isActive("/logs", $currentRoute)}
>
Logs
</a>
@@ -96,3 +98,23 @@
<ConnectionStatus />
</menu>
</header>
<style>
.activity-link {
background: linear-gradient(90deg, #6366f1, #8b5cf6, #a855f7, #8b5cf6, #6366f1);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradient-shift 2s linear infinite;
}
@keyframes gradient-shift {
0% {
background-position: 0% 50%;
}
100% {
background-position: 200% 50%;
}
}
</style>
@@ -2,6 +2,7 @@
import { models } from "../../stores/api";
import { persistentStore } from "../../stores/persistent";
import { transcribeAudio } from "../../lib/audioApi";
import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte";
const selectedModelStore = persistentStore<string>("playground-audio-model", "");
@@ -22,6 +23,10 @@
let canTranscribe = $derived(selectedFile !== null && $selectedModelStore !== "" && !isTranscribing);
$effect(() => {
playgroundStores.audioTranscribing.set(isTranscribing);
});
function validateFile(file: File): { valid: boolean; error?: string } {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
@@ -2,6 +2,7 @@
import { models } from "../../stores/api";
import { persistentStore } from "../../stores/persistent";
import { streamChatCompletion } from "../../lib/chatApi";
import { playgroundStores } from "../../stores/playgroundActivity";
import type { ChatMessage, ContentPart } from "../../lib/types";
import ChatMessageComponent from "./ChatMessage.svelte";
import ModelSelector from "./ModelSelector.svelte";
@@ -35,6 +36,10 @@
let hasModels = $derived($models.some((m) => !m.unlisted));
let userScrolledUp = $state(false);
$effect(() => {
playgroundStores.chatStreaming.set(isStreaming);
});
function handleMessagesScroll() {
if (!messagesContainer) return;
const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
@@ -2,6 +2,7 @@
import { models } from "../../stores/api";
import { persistentStore } from "../../stores/persistent";
import { generateImage } from "../../lib/imageApi";
import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte";
import ExpandableTextarea from "./ExpandableTextarea.svelte";
@@ -17,6 +18,10 @@
let hasModels = $derived($models.some((m) => !m.unlisted));
$effect(() => {
playgroundStores.imageGenerating.set(isGenerating);
});
async function generate() {
const trimmedPrompt = prompt.trim();
if (!trimmedPrompt || !$selectedModelStore || isGenerating) return;
@@ -2,6 +2,7 @@
import { models } from "../../stores/api";
import { persistentStore } from "../../stores/persistent";
import { generateSpeech } from "../../lib/speechApi";
import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte";
import ExpandableTextarea from "./ExpandableTextarea.svelte";
@@ -20,11 +21,9 @@
let availableVoices = $state<string[]>(["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"]);
let isLoadingVoices = $state(false);
// Default voices to fall back to if API call fails
const defaultVoices = ["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"];
const CACHE_KEY = "playground-speech-voices-cache";
// Load voices cache from localStorage
function getVoicesCache(): Record<string, string[]> {
if (typeof window === "undefined") return {};
try {
@@ -35,7 +34,6 @@
}
}
// Save voices cache to localStorage
function saveVoicesCache(cache: Record<string, string[]>) {
if (typeof window === "undefined") return;
try {
@@ -47,9 +45,12 @@
let hasModels = $derived($models.some((m) => !m.unlisted));
// Track if this is the initial page load to avoid fetching on refresh
let isInitialLoad = $state(true);
$effect(() => {
playgroundStores.speechGenerating.set(isGenerating);
});
// On page load, restore cached voices for the selected model if available
$effect(() => {
const model = $selectedModelStore;