From 8bd7de3defa42645f9ee93f987ecfbdfcaee3bb3 Mon Sep 17 00:00:00 2001 From: haowei Date: Fri, 17 Jul 2026 14:28:25 +0800 Subject: [PATCH] fix(frontend): keep the open workspace/channel across a reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat route was `/chat/*` and carried no selection, while `selectedWorkspaceId` lived only in memory (chatStore persists just `lastChannelByWorkspace`). So after every reload it was necessarily null and ChatLayout's bootstrap unconditionally fell back to the personal workspace — a user reading a team workspace got dumped back home, and a channel could never be linked or shared. Put the selection in the path (`/chat/:workspaceId?/:channelId?`) and make that the source of truth across reloads. The store stays the sole selection API — all 11 call sites (rail, sidebar, dialogs, invite and register flows) are untouched — with ChatLayout reconciling the two in one place: path → store on mount and back/forward, store → path for in-app selections. `hydrateSelection` applies an explicit pair without selectWorkspace's last-channel derivation, which would otherwise clobber the channel the path names. Two things worth flagging for review: - The mirror navigates with `replace`, not `push`. On mobile the conversation screen is itself a pushed history entry, so an entry per channel switch would make Back land on the previous channel instead of popping to the list. Refresh-restore and shareable links both work; browser back/forward *between channels* is deliberately not wired up, as that needs the mobile history model reworked first. - loadWorkspaces read `selectedWorkspaceId` through its mount-time closure, which the path now races: the captured null would overwrite the workspace just restored. It reads fresh via getState() and also validates membership, so a stale or foreign link falls back home instead of resting on a workspace that renders empty. Verified against the kind stack (vite dev → in-cluster gateway): reload on a team workspace stays put; reload on a channel restores it; a non-member workspace id in the path falls back and corrects the URL. Co-Authored-By: Claude Opus 4.8 --- frontend/src/App.tsx | 5 +- frontend/src/features/chat/ChatLayout.tsx | 65 +++++++++++++++++++++-- frontend/src/stores/chatStore.ts | 28 +++++++++- 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 43a57d5a..ac90de0b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -99,8 +99,11 @@ export default function App() { } /> {/* Public: the landing page itself routes signed-out visitors to auth. */} } /> + {/* The open workspace/channel live in the path so they survive a reload and + can be shared as a link; both are optional because /chat is the generic + entry point (ChatLayout then redirects to the personal workspace). */} diff --git a/frontend/src/features/chat/ChatLayout.tsx b/frontend/src/features/chat/ChatLayout.tsx index ca3e192f..fc0a1737 100644 --- a/frontend/src/features/chat/ChatLayout.tsx +++ b/frontend/src/features/chat/ChatLayout.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; import { listWorkspaces, getPersonalWorkspace } from "@/api/workspaces"; import { listChannels, listDms } from "@/api/channels"; import toast from "react-hot-toast"; @@ -24,10 +24,12 @@ export default function ChatLayout() { setPersonalWorkspace, setChannels, selectWorkspace, + hydrateSelection, } = useChatStore(); const isMobile = useIsMobile(); const location = useLocation(); const navigate = useNavigate(); + const { workspaceId: urlWorkspaceId, channelId: urlChannelId } = useParams(); // Notification center bootstrap: hydrate the inbox once, then keep it live over a // user-scoped socket (invites arrive even with no channel open). Kept here because @@ -52,6 +54,52 @@ export default function ChatLayout() { // Mobile-only workspace/nav drawer (the desktop rail, slid in from the left). const [navOpen, setNavOpen] = useState(false); + // ── URL ⇄ store selection sync ─────────────────────────────────────────────── + // The path owns the open workspace/channel: it's what survives a reload and what a + // shared link carries. Every in-app selection still goes through the store (rail, + // sidebar, dialogs), so the two are kept in step by a pair of effects. They can't + // ping-pong: the first only runs when the *path* changed (its deps are the params), + // the second only when the store disagrees with the path, and applying either makes + // them agree. + // + // appliedPathRef records the last selection we reconciled, so a path we wrote + // ourselves doesn't bounce back as an incoming change and undo a fresh selection. + const appliedPathRef = useRef(null); + + // Path → store: on mount, and on browser back/forward. + useEffect(() => { + const key = `${urlWorkspaceId ?? ""}/${urlChannelId ?? ""}`; + if (appliedPathRef.current === key) return; + appliedPathRef.current = key; + // Bare /chat names no selection — leave the store alone and let the bootstrap + // below pick the default, rather than blanking an already-good selection (this + // is the path InvitePage/RegisterPage arrive on after joining a workspace). + if (!urlWorkspaceId) return; + hydrateSelection(urlWorkspaceId, urlChannelId ?? null); + }, [urlWorkspaceId, urlChannelId, hydrateSelection]); + + // Store → path: mirror any in-app selection back out. + useEffect(() => { + if (!selectedWorkspaceId) return; + const want = `/chat/${selectedWorkspaceId}${ + selectedChannelId ? `/${selectedChannelId}` : "" + }`; + if (want === location.pathname) return; + appliedPathRef.current = `${selectedWorkspaceId}/${selectedChannelId ?? ""}`; + // replace, not push: on mobile the conversation screen is itself a pushed history + // entry (see openChatScreen), so a second entry per channel switch would make Back + // land on the previous channel instead of popping to the channel list. `state` is + // carried through for the same reason — dropping it would strip that push flag and + // bounce the user out of the conversation they just opened. + navigate(want, { replace: true, state: location.state }); + }, [ + selectedWorkspaceId, + selectedChannelId, + location.pathname, + location.state, + navigate, + ]); + // Load workspaces + the personal workspace on mount. The personal workspace is the // user's home (DMs + private space), so it's the default selection. On failure we // raise bootstrapFailed and show a retry panel in the main area instead of the @@ -63,7 +111,18 @@ export default function ChatLayout() { .then(([ws, personal]) => { setWorkspaces(ws); if (personal) setPersonalWorkspace(personal); - if (!selectedWorkspaceId) { + // Read the selection fresh instead of through this callback's closure: the + // path hydrates the store while this request is in flight, so a captured + // `null` here would overwrite the workspace the URL just restored. + const current = useChatStore.getState().selectedWorkspaceId; + const known = + !!current && + (current === personal?.workspace_id || + ws.some((w) => w.workspace_id === current)); + // Fall back to the home workspace when nothing is selected, or when the path + // named one this account can't see (a stale link, or someone else's). Without + // the membership check the rail would sit on a workspace that renders empty. + if (!known) { selectWorkspace(personal?.workspace_id ?? ws[0]?.workspace_id ?? null); } setBootstrapFailed(false); @@ -74,7 +133,7 @@ export default function ChatLayout() { "Couldn't load your workspaces — check the gateway connection, then retry." ); }); - }, [selectedWorkspaceId, setWorkspaces, setPersonalWorkspace, selectWorkspace]); + }, [setWorkspaces, setPersonalWorkspace, selectWorkspace]); useEffect(() => { loadWorkspaces(); // Run once on mount; the Retry button re-invokes loadWorkspaces directly. diff --git a/frontend/src/stores/chatStore.ts b/frontend/src/stores/chatStore.ts index be217d5f..276b9cc7 100644 --- a/frontend/src/stores/chatStore.ts +++ b/frontend/src/stores/chatStore.ts @@ -25,6 +25,15 @@ interface ChatState { patchChannel: (id: string, patch: Partial) => void; /** Add a channel if absent, else patch it (used when a DM is found-or-created). */ upsertChannel: (ch: Channel) => void; + /** + * Apply an explicit workspace+channel pair. Used to seed the store from the URL, + * which owns the selection across reloads and shared links (see ChatLayout). + * Unlike selectWorkspace this never derives the channel from + * lastChannelByWorkspace — the URL already names one, and deriving would clobber + * it — but it does refresh that memory, so a later A→B→A switch still restores + * whatever the URL opened. + */ + hydrateSelection: (workspaceId: string | null, channelId: string | null) => void; } export const useChatStore = create()( @@ -71,12 +80,27 @@ export const useChatStore = create()( ? { channels: s.channels.map((c) => (c.channel_id === ch.channel_id ? { ...c, ...ch } : c)) } : { channels: [...s.channels, ch] } ), + hydrateSelection: (workspaceId, channelId) => + set((s) => { + const map = { ...s.lastChannelByWorkspace }; + if (workspaceId) { + if (channelId) map[workspaceId] = channelId; + else delete map[workspaceId]; + } + return { + selectedWorkspaceId: workspaceId, + selectedChannelId: channelId, + lastChannelByWorkspace: map, + }; + }), }), { name: "cheers.chat.selection", // Only the cross-session channel memory is persisted. Workspaces/channels are - // server data refetched on mount, and the live selection is derived on load - // (ChatLayout selects the personal workspace, which restores its last channel). + // server data refetched on mount, and the live selection is restored from the + // URL (/chat/:workspaceId/:channelId), which is its source of truth across + // reloads — persisting it here too would give a shared link two competing + // answers for what to open. partialize: (s) => ({ lastChannelByWorkspace: s.lastChannelByWorkspace }), } )