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,
} 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({
)}
</div>
) : (
<a
<Link
ref={ref}
href={
window.location.pathname === linkTo || ctrlPressed ? "#" : linkTo
}
to={linkTo}
data-tooltip-id="workspace-thread-name"
data-tooltip-content={thread.name}
className="w-full pl-2 py-1 overflow-hidden"
@ -115,7 +113,7 @@ export default function ThreadItem({
>
{thread.name}
</p>
</a>
</Link>
)}
{!!thread.slug && !thread.deleted && !thread.virtual && (
<div ref={optionsContainer} className="flex items-center">

View File

@ -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"
>
<div className="flex gap-x-2 items-center justify-between">
<a
href={
isActive
? null
: paths.workspace.chat(workspace.slug)
}
<Link
to={paths.workspace.chat(workspace.slug)}
aria-current={isActive ? "page" : ""}
className={`
transition-all duration-[200ms]
@ -208,7 +204,7 @@ export default function ActiveWorkspaces() {
</div>
)}
</div>
</a>
</Link>
</div>
{isActive && (
<ThreadContainer

View File

@ -172,7 +172,6 @@ function SearchResultItem({ to, name, hint }) {
return (
<Link
to={to}
reloadDocument={true}
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]"
>

View File

@ -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);

View File

@ -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 (
<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 />;
}
if (!loading && !loadingHistory && !workspace) {
if (!loading && !workspace) {
return (
<>
{loading === false && !workspace && (
@ -88,8 +94,16 @@ export default function WorkspaceChat({ loading, workspace }) {
setEventDelegatorForCodeSnippets();
return (
<TTSProvider>
<DnDFileUploaderProvider workspace={workspace} threadSlug={threadSlug}>
<ChatContainer workspace={workspace} knownHistory={history} />
<DnDFileUploaderProvider
workspace={loaded.workspace}
threadSlug={loaded.threadSlug}
>
<ChatContainer
key={loaded.key}
workspace={loaded.workspace}
threadSlug={loaded.threadSlug}
knownHistory={loaded.history}
/>
</DnDFileUploaderProvider>
</TTSProvider>
);

View File

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

View File

@ -16,19 +16,31 @@ export default function WorkspaceChat() {
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() {
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 (
<>
<div className="w-screen h-screen overflow-hidden bg-zinc-950 light:bg-slate-50 flex">
{!isMobile && <Sidebar />}
<WorkspaceChatContainer loading={loading} workspace={workspace} />
</div>
</>
<WorkspaceChatContainer
loading={loadedSlug !== slug}
workspace={workspace}
/>
);
}