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 <rambat1010@gmail.com>
This commit is contained in:
Sean Hatfield 2026-05-01 17:09:40 -07:00 committed by GitHub
parent 7ea6196570
commit 2c82e9df5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 60 additions and 48 deletions

View File

@ -10,7 +10,7 @@ import {
X, X,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "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; const THREAD_CALLOUT_DETAIL_WIDTH = 26;
export default function ThreadItem({ export default function ThreadItem({
@ -96,11 +96,9 @@ export default function ThreadItem({
)} )}
</div> </div>
) : ( ) : (
<a <Link
ref={ref} ref={ref}
href={ to={linkTo}
window.location.pathname === linkTo || ctrlPressed ? "#" : linkTo
}
data-tooltip-id="workspace-thread-name" data-tooltip-id="workspace-thread-name"
data-tooltip-content={thread.name} data-tooltip-content={thread.name}
className="w-full pl-2 py-1 overflow-hidden" className="w-full pl-2 py-1 overflow-hidden"
@ -115,7 +113,7 @@ export default function ThreadItem({
> >
{thread.name} {thread.name}
</p> </p>
</a> </Link>
)} )}
{!!thread.slug && !thread.deleted && !thread.virtual && ( {!!thread.slug && !thread.deleted && !thread.virtual && (
<div ref={optionsContainer} className="flex items-center"> <div ref={optionsContainer} className="flex items-center">

View File

@ -6,7 +6,7 @@ import ManageWorkspace, {
useManageWorkspaceModal, useManageWorkspaceModal,
} from "../../Modals/ManageWorkspace"; } from "../../Modals/ManageWorkspace";
import paths from "@/utils/paths"; 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 { GearSix, UploadSimple, DotsSixVertical } from "@phosphor-icons/react";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import ThreadContainer from "./ThreadContainer"; import ThreadContainer from "./ThreadContainer";
@ -117,12 +117,8 @@ export default function ActiveWorkspaces() {
role="listitem" role="listitem"
> >
<div className="flex gap-x-2 items-center justify-between"> <div className="flex gap-x-2 items-center justify-between">
<a <Link
href={ to={paths.workspace.chat(workspace.slug)}
isActive
? null
: paths.workspace.chat(workspace.slug)
}
aria-current={isActive ? "page" : ""} aria-current={isActive ? "page" : ""}
className={` className={`
transition-all duration-[200ms] transition-all duration-[200ms]
@ -208,7 +204,7 @@ export default function ActiveWorkspaces() {
</div> </div>
)} )}
</div> </div>
</a> </Link>
</div> </div>
{isActive && ( {isActive && (
<ThreadContainer <ThreadContainer

View File

@ -172,7 +172,6 @@ function SearchResultItem({ to, name, hint }) {
return ( return (
<Link <Link
to={to} to={to}
reloadDocument={true}
onClick={() => window.dispatchEvent(new Event(SEARCH_RESULT_SELECTED))} onClick={() => 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]" className="hover:bg-[#FFF]/10 light:hover:bg-[#000]/10 transition-all duration-300 rounded-sm px-[8px] py-[2px]"
> >

View File

@ -9,7 +9,7 @@ import Workspace from "@/models/workspace";
import handleChat, { ABORT_STREAM_EVENT } from "@/utils/chat"; import handleChat, { ABORT_STREAM_EVENT } from "@/utils/chat";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { SidebarMobileHeader } from "../../Sidebar"; import { SidebarMobileHeader } from "../../Sidebar";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { v4 } from "uuid"; import { v4 } from "uuid";
import handleSocketResponse, { import handleSocketResponse, {
websocketURI, websocketURI,
@ -35,10 +35,13 @@ import TextSizeMenu from "./TextSizeMenu";
import WorkspaceModelPicker from "./WorkspaceModelPicker"; import WorkspaceModelPicker from "./WorkspaceModelPicker";
import SourcesSidebar, { SourcesSidebarProvider } from "./SourcesSidebar"; import SourcesSidebar, { SourcesSidebarProvider } from "./SourcesSidebar";
export default function ChatContainer({ workspace, knownHistory = [] }) { export default function ChatContainer({
workspace,
threadSlug = null,
knownHistory = [],
}) {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
const { threadSlug = null } = useParams();
const [loadingResponse, setLoadingResponse] = useState(false); const [loadingResponse, setLoadingResponse] = useState(false);
const [chatHistory, setChatHistory] = useState(knownHistory); const [chatHistory, setChatHistory] = useState(knownHistory);
const [socketId, setSocketId] = useState(null); const [socketId, setSocketId] = useState(null);

View File

@ -16,14 +16,16 @@ import { PENDING_HOME_MESSAGE } from "@/utils/constants";
export default function WorkspaceChat({ loading, workspace }) { export default function WorkspaceChat({ loading, workspace }) {
useWatchForAutoPlayAssistantTTSResponse(); useWatchForAutoPlayAssistantTTSResponse();
const { threadSlug = null } = useParams(); const { threadSlug = null } = useParams();
const [history, setHistory] = useState([]); // Stores { key, workspace, history } currently rendered. Lags the props so
const [loadingHistory, setLoadingHistory] = useState(true); // 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(() => { useEffect(() => {
async function getHistory() { async function getHistory() {
if (loading) return; if (loading) return;
if (!workspace?.slug) { if (!workspace?.slug) {
setLoadingHistory(false); setLoaded({ key: "none", workspace: null, history: [] });
return false; return false;
} }
@ -31,14 +33,18 @@ export default function WorkspaceChat({ loading, workspace }) {
? await Workspace.threads.chatHistory(workspace.slug, threadSlug) ? await Workspace.threads.chatHistory(workspace.slug, threadSlug)
: await Workspace.chatHistory(workspace.slug); : await Workspace.chatHistory(workspace.slug);
setHistory(chatHistory); setLoaded({
setLoadingHistory(false); key: `${workspace.slug}:${threadSlug ?? "default"}`,
workspace,
threadSlug,
history: chatHistory,
});
} }
getHistory(); getHistory();
}, [workspace, loading]); }, [workspace, loading, threadSlug]);
const hasPendingMessage = !!sessionStorage.getItem(PENDING_HOME_MESSAGE); const hasPendingMessage = !!sessionStorage.getItem(PENDING_HOME_MESSAGE);
if (loadingHistory) { if (loaded === null) {
if (hasPendingMessage) { if (hasPendingMessage) {
return ( return (
<div className="transition-all duration-500 relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full" /> <div className="transition-all duration-500 relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full" />
@ -46,7 +52,7 @@ export default function WorkspaceChat({ loading, workspace }) {
} }
return <LoadingChat />; return <LoadingChat />;
} }
if (!loading && !loadingHistory && !workspace) { if (!loading && !workspace) {
return ( return (
<> <>
{loading === false && !workspace && ( {loading === false && !workspace && (
@ -88,8 +94,16 @@ export default function WorkspaceChat({ loading, workspace }) {
setEventDelegatorForCodeSnippets(); setEventDelegatorForCodeSnippets();
return ( return (
<TTSProvider> <TTSProvider>
<DnDFileUploaderProvider workspace={workspace} threadSlug={threadSlug}> <DnDFileUploaderProvider
<ChatContainer workspace={workspace} knownHistory={history} /> workspace={loaded.workspace}
threadSlug={loaded.threadSlug}
>
<ChatContainer
key={loaded.key}
workspace={loaded.workspace}
threadSlug={loaded.threadSlug}
knownHistory={loaded.history}
/>
</DnDFileUploaderProvider> </DnDFileUploaderProvider>
</TTSProvider> </TTSProvider>
); );

View File

@ -52,15 +52,7 @@ const router = createBrowserRouter([
); );
return { element: <PrivateRoute Component={WorkspaceChat} /> }; return { element: <PrivateRoute Component={WorkspaceChat} /> };
}, },
}, children: [{ path: "t/:threadSlug" }],
{
path: "/workspace/:slug/t/:threadSlug",
lazy: async () => {
const { default: WorkspaceChat } = await import(
"@/pages/WorkspaceChat"
);
return { element: <PrivateRoute Component={WorkspaceChat} /> };
},
}, },
{ {
path: "/accept-invite/:code", path: "/accept-invite/:code",

View File

@ -16,19 +16,31 @@ export default function WorkspaceChat() {
return <>{requiresAuth !== null && <PasswordModal mode={mode} />}</>; return <>{requiresAuth !== null && <PasswordModal mode={mode} />}</>;
} }
return <ShowWorkspaceChat />; return (
<div className="w-screen h-screen overflow-hidden bg-zinc-950 light:bg-slate-50 flex">
{!isMobile && <Sidebar />}
<ShowWorkspaceChat />
</div>
);
} }
function ShowWorkspaceChat() { function ShowWorkspaceChat() {
const { slug } = useParams(); const { slug } = useParams();
const [workspace, setWorkspace] = useState(null); 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(() => { useEffect(() => {
async function getWorkspace() { async function getWorkspace() {
if (!slug) return; if (!slug) return;
const _workspace = await Workspace.bySlug(slug); const _workspace = await Workspace.bySlug(slug);
if (!_workspace) return setLoading(false); if (!_workspace) {
setWorkspace(null);
setLoadedSlug(slug);
return;
}
const [suggestedMessages, { showAgentCommand }] = await Promise.all([ const [suggestedMessages, { showAgentCommand }] = await Promise.all([
Workspace.getSuggestedMessages(slug), Workspace.getSuggestedMessages(slug),
@ -39,7 +51,7 @@ function ShowWorkspaceChat() {
suggestedMessages, suggestedMessages,
showAgentCommand, showAgentCommand,
}); });
setLoading(false); setLoadedSlug(slug);
localStorage.setItem( localStorage.setItem(
LAST_VISITED_WORKSPACE, LAST_VISITED_WORKSPACE,
JSON.stringify({ JSON.stringify({
@ -49,14 +61,12 @@ function ShowWorkspaceChat() {
); );
} }
getWorkspace(); getWorkspace();
}, []); }, [slug]);
return ( return (
<> <WorkspaceChatContainer
<div className="w-screen h-screen overflow-hidden bg-zinc-950 light:bg-slate-50 flex"> loading={loadedSlug !== slug}
{!isMobile && <Sidebar />} workspace={workspace}
<WorkspaceChatContainer loading={loading} workspace={workspace} /> />
</div>
</>
); );
} }