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