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 && } - -
- + ); }