[object Object]

← back to Exo

fix: prevent stale loading state and conversation loss when switching chats (#1613)

bea64fe85e7abd7c62d376fdd651cfa20af40854 · 2026-02-24 11:08:31 -0800 · Alex Cheema

## Motivation

When navigating back to a previous conversation that used a different
model than the one currently loading, the UI incorrectly displayed the
other model's loading/download progress bar instead of the conversation
messages. Additionally, attempting to continue that old conversation by
sending a message would create an entirely new chat rather than
continuing the existing one.

## Changes

All changes in `dashboard/src/routes/+page.svelte`:

1. **Added fallthrough reset in the `chatLaunchState` restore
`$effect`**: When switching to a conversation whose model has no active
instance (not running, not downloading, not loading), the effect now
resets `chatLaunchState` to `"idle"` instead of leaving stale state from
a different model.

2. **Added `skipCreate` parameter to `launchModelForChat()`**: When
continuing an existing conversation (has messages),
`createConversation()` is skipped so the old conversation is preserved
rather than replaced with a new empty one.

3. **Reordered view conditional**: Progress views
(downloading/loading/launching) take priority, then conversation
messages (when idle with messages or model ready), then model selector
(idle with no messages). This ensures:
   - Old conversations display normally when their model isn't running
- Download progress shows when relaunching a model for any conversation
   - Model selector only appears when there are no messages

## Why It Works

- The `$effect` fallthrough prevents `chatLaunchState` from retaining a
stale value (e.g., `"downloading"`) from Model A when the user switches
to a conversation using Model B that has no active state.
- The `skipCreate` flag ensures `launchModelForChat` can be reused for
both new conversations (from model picker) and continuing existing ones
(from chat input) without always creating a new conversation.
- The reordered view logic ensures each state maps to the correct UI:
active launch → progress, existing messages → chat view, nothing → model
selector.

## Test Plan

### Manual Testing
- Load an old conversation while a different model is downloading →
should see conversation messages, not the other model's loading bar
- Send a message from that old conversation → model should
launch/download with progress shown → conversation continues with the
response appended
- Select a new model from the picker → should still create a new
conversation as before
- New conversation with model download → progress bar shows correctly

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

Files touched

Diff

commit bea64fe85e7abd7c62d376fdd651cfa20af40854
Author: Alex Cheema <41707476+AlexCheema@users.noreply.github.com>
Date:   Tue Feb 24 11:08:31 2026 -0800

    fix: prevent stale loading state and conversation loss when switching chats (#1613)
    
    ## Motivation
    
    When navigating back to a previous conversation that used a different
    model than the one currently loading, the UI incorrectly displayed the
    other model's loading/download progress bar instead of the conversation
    messages. Additionally, attempting to continue that old conversation by
    sending a message would create an entirely new chat rather than
    continuing the existing one.
    
    ## Changes
    
    All changes in `dashboard/src/routes/+page.svelte`:
    
    1. **Added fallthrough reset in the `chatLaunchState` restore
    `$effect`**: When switching to a conversation whose model has no active
    instance (not running, not downloading, not loading), the effect now
    resets `chatLaunchState` to `"idle"` instead of leaving stale state from
    a different model.
    
    2. **Added `skipCreate` parameter to `launchModelForChat()`**: When
    continuing an existing conversation (has messages),
    `createConversation()` is skipped so the old conversation is preserved
    rather than replaced with a new empty one.
    
    3. **Reordered view conditional**: Progress views
    (downloading/loading/launching) take priority, then conversation
    messages (when idle with messages or model ready), then model selector
    (idle with no messages). This ensures:
       - Old conversations display normally when their model isn't running
    - Download progress shows when relaunching a model for any conversation
       - Model selector only appears when there are no messages
    
    ## Why It Works
    
    - The `$effect` fallthrough prevents `chatLaunchState` from retaining a
    stale value (e.g., `"downloading"`) from Model A when the user switches
    to a conversation using Model B that has no active state.
    - The `skipCreate` flag ensures `launchModelForChat` can be reused for
    both new conversations (from model picker) and continuing existing ones
    (from chat input) without always creating a new conversation.
    - The reordered view logic ensures each state maps to the correct UI:
    active launch → progress, existing messages → chat view, nothing → model
    selector.
    
    ## Test Plan
    
    ### Manual Testing
    - Load an old conversation while a different model is downloading →
    should see conversation messages, not the other model's loading bar
    - Send a message from that old conversation → model should
    launch/download with progress shown → conversation continues with the
    response appended
    - Select a new model from the picker → should still create a new
    conversation as before
    - New conversation with model download → progress bar shows correctly
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
---
 dashboard/src/routes/+page.svelte | 98 +++++++++++++++++++++------------------
 1 file changed, 54 insertions(+), 44 deletions(-)

diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte
index e02d3b88..43973cd6 100644
--- a/dashboard/src/routes/+page.svelte
+++ b/dashboard/src/routes/+page.svelte
@@ -42,6 +42,7 @@
     setSelectedChatModel,
     selectedChatModel,
     sendMessage,
+    messages,
     debugMode,
     toggleDebugMode,
     topologyOnlyMode,
@@ -2517,6 +2518,11 @@
         return;
       }
     }
+
+    // Fallthrough: model exists but has no active instance/download/loading state
+    chatLaunchState = "idle";
+    pendingChatModelId = null;
+    selectedChatCategory = null;
   });
 
   // Suggested prompts per category
@@ -2640,7 +2646,11 @@
   }
 
   // Launch a model for seamless chat
-  async function launchModelForChat(modelId: string, category: string) {
+  async function launchModelForChat(
+    modelId: string,
+    category: string,
+    skipCreate = false,
+  ) {
     userForcedIdle = false;
     pendingChatModelId = modelId;
     selectedChatCategory = category;
@@ -2648,7 +2658,7 @@
     // Check if already running — skip straight to chat
     if (hasRunningInstance(modelId)) {
       setSelectedChatModel(modelId);
-      createConversation();
+      if (!skipCreate) createConversation();
       chatLaunchState = "ready";
       return;
     }
@@ -2657,7 +2667,7 @@
     if (hasExistingInstance(modelId)) {
       setSelectedChatModel(modelId);
       pendingChatModelId = modelId;
-      createConversation();
+      if (!skipCreate) createConversation();
       const dlStatus = getModelDownloadStatus(modelId);
       if (dlStatus.isDownloading) {
         chatLaunchState = "downloading";
@@ -2710,7 +2720,7 @@
 
       setSelectedChatModel(modelId);
       recordRecentLaunch(modelId);
-      createConversation();
+      if (!skipCreate) createConversation();
       chatLaunchState = "downloading";
     } catch (error) {
       addToast({ type: "error", message: `Network error: ${error}` });
@@ -3025,7 +3035,7 @@
     if (model) {
       pendingAutoMessage = { content, files };
       userForcedIdle = false;
-      launchModelForChat(model, "picker");
+      launchModelForChat(model, "picker", messages().length > 0);
       return;
     }
 
@@ -5809,43 +5819,7 @@
           class="flex-1 flex flex-col min-w-0 overflow-hidden"
           in:fade={{ duration: 300, delay: 100 }}
         >
-          {#if chatLaunchState === "idle"}
-            <!-- No running instance: show model selector -->
-            <div
-              class="flex-1 overflow-y-auto flex items-center justify-center px-8 py-6"
-            >
-              <ChatModelSelector
-                models={models.map((m) => ({
-                  id: m.id,
-                  name: m.name ?? "",
-                  base_model: m.base_model ?? "",
-                  storage_size_megabytes: m.storage_size_megabytes ?? 0,
-                  capabilities: m.capabilities ?? [],
-                  family: m.family ?? "",
-                  quantization: m.quantization ?? "",
-                }))}
-                clusterLabel={chatClusterLabel}
-                totalMemoryGB={availableMemoryGB()}
-                onSelect={handleChatModelSelect}
-                onAddModel={handleChatAddModel}
-              />
-            </div>
-            <div
-              class="flex-shrink-0 px-8 pb-6 pt-4 bg-gradient-to-t from-exo-black via-exo-black to-transparent"
-            >
-              <div class="max-w-7xl mx-auto">
-                <ChatForm
-                  placeholder="Ask anything — we'll pick the best model automatically"
-                  showModelSelector={!!bestRunningModelId}
-                  modelDisplayOverride={bestRunningModelId ?? undefined}
-                  modelTasks={modelTasks()}
-                  modelCapabilities={modelCapabilities()}
-                  onAutoSend={handleAutoSend}
-                  onOpenModelPicker={openChatModelPicker}
-                />
-              </div>
-            </div>
-          {:else if chatLaunchState !== "idle" && chatLaunchState !== "ready"}
+          {#if chatLaunchState !== "idle" && chatLaunchState !== "ready"}
             <!-- Model launching/downloading/loading: show progress -->
             <div class="flex-1 flex items-center justify-center px-8 py-6">
               <div class="flex flex-col items-center gap-6 max-w-md w-full">
@@ -5952,8 +5926,8 @@
                 />
               </div>
             </div>
-          {:else}
-            <!-- Normal chat: model is running -->
+          {:else if messages().length > 0 || chatLaunchState === "ready"}
+            <!-- Normal chat: show messages -->
             <div
               class="flex-1 overflow-y-auto px-8 py-6"
               bind:this={chatScrollRef}
@@ -6009,6 +5983,42 @@
                 />
               </div>
             </div>
+          {:else}
+            <!-- No running instance, no messages: show model selector -->
+            <div
+              class="flex-1 overflow-y-auto flex items-center justify-center px-8 py-6"
+            >
+              <ChatModelSelector
+                models={models.map((m) => ({
+                  id: m.id,
+                  name: m.name ?? "",
+                  base_model: m.base_model ?? "",
+                  storage_size_megabytes: m.storage_size_megabytes ?? 0,
+                  capabilities: m.capabilities ?? [],
+                  family: m.family ?? "",
+                  quantization: m.quantization ?? "",
+                }))}
+                clusterLabel={chatClusterLabel}
+                totalMemoryGB={availableMemoryGB()}
+                onSelect={handleChatModelSelect}
+                onAddModel={handleChatAddModel}
+              />
+            </div>
+            <div
+              class="flex-shrink-0 px-8 pb-6 pt-4 bg-gradient-to-t from-exo-black via-exo-black to-transparent"
+            >
+              <div class="max-w-7xl mx-auto">
+                <ChatForm
+                  placeholder="Ask anything — we'll pick the best model automatically"
+                  showModelSelector={!!bestRunningModelId}
+                  modelDisplayOverride={bestRunningModelId ?? undefined}
+                  modelTasks={modelTasks()}
+                  modelCapabilities={modelCapabilities()}
+                  onAutoSend={handleAutoSend}
+                  onOpenModelPicker={openChatModelPicker}
+                />
+              </div>
+            </div>
           {/if}
         </div>
 

← 14526d28 update mlx 2 (#1611)  ·  back to Exo  ·  bump (#1608) 9a2d2a4a →