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:
parent
7ea6196570
commit
2c82e9df5f
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]"
|
||||
>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user