From 2c82e9df5fb4934888f6bc0cd047b9dc388f4a23 Mon Sep 17 00:00:00 2001
From: Sean Hatfield
Date: Fri, 1 May 2026 17:09:40 -0700
Subject: [PATCH] fix: SPA nav for thread/workspace switching (#5528)
* fix: white flash when switching between threads
* nest thread route under workspace
* fix: white flash when switching between workspaces
* simplify Link usage in workspace/thread sidebar items
* smooth workspace and thread switching
* fix race condition on send during thread/workspace switch
---------
Co-authored-by: Timothy Carambat
---
.../ThreadContainer/ThreadItem/index.jsx | 10 +++---
.../Sidebar/ActiveWorkspaces/index.jsx | 12 +++----
.../components/Sidebar/SearchBox/index.jsx | 1 -
.../WorkspaceChat/ChatContainer/index.jsx | 9 +++--
.../src/components/WorkspaceChat/index.jsx | 34 +++++++++++++------
frontend/src/main.jsx | 10 +-----
frontend/src/pages/WorkspaceChat/index.jsx | 32 +++++++++++------
7 files changed, 60 insertions(+), 48 deletions(-)
diff --git a/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx b/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx
index c6699dbb..a5706950 100644
--- a/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx
+++ b/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx
@@ -10,7 +10,7 @@ import {
X,
} from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
-import { useParams } from "react-router-dom";
+import { Link, useParams } from "react-router-dom";
const THREAD_CALLOUT_DETAIL_WIDTH = 26;
export default function ThreadItem({
@@ -96,11 +96,9 @@ export default function ThreadItem({
)}
) : (
-
{thread.name}
-
+
)}
{!!thread.slug && !thread.deleted && !thread.virtual && (
diff --git a/frontend/src/components/Sidebar/ActiveWorkspaces/index.jsx b/frontend/src/components/Sidebar/ActiveWorkspaces/index.jsx
index a19ad063..2bdd0478 100644
--- a/frontend/src/components/Sidebar/ActiveWorkspaces/index.jsx
+++ b/frontend/src/components/Sidebar/ActiveWorkspaces/index.jsx
@@ -6,7 +6,7 @@ import ManageWorkspace, {
useManageWorkspaceModal,
} from "../../Modals/ManageWorkspace";
import paths from "@/utils/paths";
-import { useParams, useNavigate, useMatch } from "react-router-dom";
+import { Link, useParams, useNavigate, useMatch } from "react-router-dom";
import { GearSix, UploadSimple, DotsSixVertical } from "@phosphor-icons/react";
import useUser from "@/hooks/useUser";
import ThreadContainer from "./ThreadContainer";
@@ -117,12 +117,8 @@ export default function ActiveWorkspaces() {
role="listitem"
>
-
+
{isActive && (
window.dispatchEvent(new Event(SEARCH_RESULT_SELECTED))}
className="hover:bg-[#FFF]/10 light:hover:bg-[#000]/10 transition-all duration-300 rounded-sm px-[8px] py-[2px]"
>
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx
index 64f99f96..d4512b15 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx
@@ -9,7 +9,7 @@ import Workspace from "@/models/workspace";
import handleChat, { ABORT_STREAM_EVENT } from "@/utils/chat";
import { isMobile } from "react-device-detect";
import { SidebarMobileHeader } from "../../Sidebar";
-import { useNavigate, useParams } from "react-router-dom";
+import { useNavigate } from "react-router-dom";
import { v4 } from "uuid";
import handleSocketResponse, {
websocketURI,
@@ -35,10 +35,13 @@ import TextSizeMenu from "./TextSizeMenu";
import WorkspaceModelPicker from "./WorkspaceModelPicker";
import SourcesSidebar, { SourcesSidebarProvider } from "./SourcesSidebar";
-export default function ChatContainer({ workspace, knownHistory = [] }) {
+export default function ChatContainer({
+ workspace,
+ threadSlug = null,
+ knownHistory = [],
+}) {
const navigate = useNavigate();
const { t } = useTranslation();
- const { threadSlug = null } = useParams();
const [loadingResponse, setLoadingResponse] = useState(false);
const [chatHistory, setChatHistory] = useState(knownHistory);
const [socketId, setSocketId] = useState(null);
diff --git a/frontend/src/components/WorkspaceChat/index.jsx b/frontend/src/components/WorkspaceChat/index.jsx
index 063a1d2d..ff2fee34 100644
--- a/frontend/src/components/WorkspaceChat/index.jsx
+++ b/frontend/src/components/WorkspaceChat/index.jsx
@@ -16,14 +16,16 @@ import { PENDING_HOME_MESSAGE } from "@/utils/constants";
export default function WorkspaceChat({ loading, workspace }) {
useWatchForAutoPlayAssistantTTSResponse();
const { threadSlug = null } = useParams();
- const [history, setHistory] = useState([]);
- const [loadingHistory, setLoadingHistory] = useState(true);
+ // Stores { key, workspace, history } currently rendered. Lags the props so
+ // the previous chat stays mounted until the next one's history is ready,
+ // avoiding a skeleton/loader flash on workspace/thread switches.
+ const [loaded, setLoaded] = useState(null);
useEffect(() => {
async function getHistory() {
if (loading) return;
if (!workspace?.slug) {
- setLoadingHistory(false);
+ setLoaded({ key: "none", workspace: null, history: [] });
return false;
}
@@ -31,14 +33,18 @@ export default function WorkspaceChat({ loading, workspace }) {
? await Workspace.threads.chatHistory(workspace.slug, threadSlug)
: await Workspace.chatHistory(workspace.slug);
- setHistory(chatHistory);
- setLoadingHistory(false);
+ setLoaded({
+ key: `${workspace.slug}:${threadSlug ?? "default"}`,
+ workspace,
+ threadSlug,
+ history: chatHistory,
+ });
}
getHistory();
- }, [workspace, loading]);
+ }, [workspace, loading, threadSlug]);
const hasPendingMessage = !!sessionStorage.getItem(PENDING_HOME_MESSAGE);
- if (loadingHistory) {
+ if (loaded === null) {
if (hasPendingMessage) {
return (
@@ -46,7 +52,7 @@ export default function WorkspaceChat({ loading, workspace }) {
}
return ;
}
- if (!loading && !loadingHistory && !workspace) {
+ if (!loading && !workspace) {
return (
<>
{loading === false && !workspace && (
@@ -88,8 +94,16 @@ export default function WorkspaceChat({ loading, workspace }) {
setEventDelegatorForCodeSnippets();
return (
-
-
+
+
);
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index d7778627..ed59fa42 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -52,15 +52,7 @@ const router = createBrowserRouter([
);
return { element: };
},
- },
- {
- path: "/workspace/:slug/t/:threadSlug",
- lazy: async () => {
- const { default: WorkspaceChat } = await import(
- "@/pages/WorkspaceChat"
- );
- return { element: };
- },
+ children: [{ path: "t/:threadSlug" }],
},
{
path: "/accept-invite/:code",
diff --git a/frontend/src/pages/WorkspaceChat/index.jsx b/frontend/src/pages/WorkspaceChat/index.jsx
index 68eb5ff1..ce3789d9 100644
--- a/frontend/src/pages/WorkspaceChat/index.jsx
+++ b/frontend/src/pages/WorkspaceChat/index.jsx
@@ -16,19 +16,31 @@ export default function WorkspaceChat() {
return <>{requiresAuth !== null && }>;
}
- return ;
+ return (
+
+ {!isMobile && }
+
+
+ );
}
function ShowWorkspaceChat() {
const { slug } = useParams();
const [workspace, setWorkspace] = useState(null);
- const [loading, setLoading] = useState(true);
+ // Tracks which workspace `workspace` belongs to. While a new workspace's
+ // data is in flight, we keep the previous workspace's chat mounted
+ // (Slack/Linear-style transition) instead of flashing a skeleton.
+ const [loadedSlug, setLoadedSlug] = useState(null);
useEffect(() => {
async function getWorkspace() {
if (!slug) return;
const _workspace = await Workspace.bySlug(slug);
- if (!_workspace) return setLoading(false);
+ if (!_workspace) {
+ setWorkspace(null);
+ setLoadedSlug(slug);
+ return;
+ }
const [suggestedMessages, { showAgentCommand }] = await Promise.all([
Workspace.getSuggestedMessages(slug),
@@ -39,7 +51,7 @@ function ShowWorkspaceChat() {
suggestedMessages,
showAgentCommand,
});
- setLoading(false);
+ setLoadedSlug(slug);
localStorage.setItem(
LAST_VISITED_WORKSPACE,
JSON.stringify({
@@ -49,14 +61,12 @@ function ShowWorkspaceChat() {
);
}
getWorkspace();
- }, []);
+ }, [slug]);
return (
- <>
-
- {!isMobile && }
-
-
- >
+
);
}