fix(ui): eliminate full-page flash on chat session switch

Root cause: ErrorBoundary in AppLayout used key={location.pathname},
causing React to unmount/remount the entire page on every URL change.

- Add stableErrorBoundaryKey() that strips dynamic segments from pathname
- Merge /chat and /chat/:sessionKey into single /chat/:sessionKey? route
- Derive sessionKey from useParams() instead of duplicating into useState
  (dual state caused race condition: setState + navigate → B→A→B bounce)
- Move session reset from render-time setState to useEffect
- Only show loading spinner when messages array is empty
This commit is contained in:
viettranx committed 2026-03-19 22:38:06 +07:00
1 parent b80ee0ae43
commit 45295bc75a
6 files changed
+49 -39

No files matched your search

+12 -1
View File
@@ -9,6 +9,17 @@ import { useAuthStore } from "@/stores/use-auth-store";
import { useIsTablet } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
/**
* Returns a stable key for pages that handle their own sub-navigation
* (e.g. /chat/:sessionKey, /sessions/:key). Prevents ErrorBoundary from
* remounting the entire page when only the dynamic param changes.
*/
function stableErrorBoundaryKey(pathname: string): string {
// Strip dynamic segments: /chat/anything → /chat
const base = pathname.replace(/^(\/[^/]+)\/.*$/, "$1");
return base;
}
export function AppLayout() {
const { t } = useTranslation("common");
const location = useLocation();
@@ -51,7 +62,7 @@ export function AppLayout() {
</div>
)}
<main className="flex-1 overflow-y-auto">
<ErrorBoundary key={location.pathname}>
<ErrorBoundary key={stableErrorBoundaryKey(location.pathname)}>
<Outlet />
</ErrorBoundary>
</main>
+1 -2
View File
@@ -1,8 +1,7 @@
export const ROUTES = {
LOGIN: "/login",
OVERVIEW: "/overview",
CHAT: "/chat",
CHAT_SESSION: "/chat/:sessionKey",
CHAT: "/chat/:sessionKey?",
AGENTS: "/agents",
AGENT_DETAIL: "/agents/:id",
SESSIONS: "/sessions",
+21 -19
View File
@@ -25,6 +25,9 @@ export function ChatPage() {
const [scrollTrigger, setScrollTrigger] = useState(0);
// sessionKey derived from URL — single source of truth, no separate state
const sessionKey = urlSessionKey ?? "";
const [agentId, setAgentId] = useState(() => {
if (urlSessionKey) {
const { agentId: parsed } = parseSessionKey(urlSessionKey);
@@ -32,13 +35,13 @@ export function ChatPage() {
}
return "default";
});
const [sessionKey, setSessionKey] = useState(urlSessionKey ?? "");
const {
sessions,
loading: sessionsLoading,
refresh: refreshSessions,
buildNewSessionKey,
deleteSession,
} = useChatSessions(agentId);
const {
@@ -55,13 +58,6 @@ export function ChatPage() {
addLocalMessage,
} = useChatMessages(sessionKey, agentId);
// Sync URL param to state
useEffect(() => {
if (urlSessionKey && urlSessionKey !== sessionKey) {
setSessionKey(urlSessionKey);
}
}, [urlSessionKey, sessionKey]);
// Refresh sessions when run completes
const prevIsRunningRef = useRef(false);
useEffect(() => {
@@ -87,30 +83,36 @@ export function ChatPage() {
});
const handleNewChat = useCallback(() => {
const newKey = buildNewSessionKey();
setSessionKey(newKey);
navigate(`/chat/${encodeURIComponent(newKey)}`);
navigate(`/chat/${encodeURIComponent(buildNewSessionKey())}`);
}, [buildNewSessionKey, navigate]);
const handleSessionSelect = useCallback(
(key: string) => {
// Sync agentId from session key to ensure correct routing
const { agentId: parsed } = parseSessionKey(key);
if (parsed && parsed !== agentId) {
setAgentId(parsed);
}
setSessionKey(key);
navigate(`/chat/${encodeURIComponent(key)}`);
},
[navigate, agentId],
);
const handleDeleteSession = useCallback(async (key: string) => {
await deleteSession(key);
if (key === sessionKey) {
const next = sessions.find((s) => s.key !== key);
if (next) {
handleSessionSelect(next.key);
} else {
handleNewChat();
}
}
}, [deleteSession, sessionKey, sessions, handleSessionSelect, handleNewChat]);
const handleAgentChange = useCallback(
(newAgentId: string) => {
setAgentId(newAgentId);
const newKey = `agent:${newAgentId}:ws:direct:${crypto.randomUUID()}`;
setSessionKey(newKey);
navigate(`/chat/${encodeURIComponent(newKey)}`);
navigate(`/chat/${encodeURIComponent(`agent:${newAgentId}:ws:direct:${crypto.randomUUID()}`)}`);
},
[navigate],
);
@@ -120,10 +122,8 @@ export function ChatPage() {
let key = sessionKey;
if (!key) {
key = buildNewSessionKey();
setSessionKey(key);
navigate(`/chat/${encodeURIComponent(key)}`);
navigate(`/chat/${encodeURIComponent(key)}`, { replace: true });
}
// Pass key directly so send() doesn't use a stale closure value
send(message, key, files);
setScrollTrigger((n) => n + 1);
},
@@ -175,6 +175,7 @@ export function ChatPage() {
sessionsLoading={sessionsLoading}
activeSessionKey={sessionKey}
onSessionSelect={handleSessionSelectMobile}
onDeleteSession={handleDeleteSession}
onNewChat={handleNewChatMobile}
/>
</div>
@@ -187,6 +188,7 @@ export function ChatPage() {
sessionsLoading={sessionsLoading}
activeSessionKey={sessionKey}
onSessionSelect={handleSessionSelect}
onDeleteSession={handleDeleteSession}
onNewChat={handleNewChat}
/>
)}
+7 -8
View File
@@ -83,15 +83,14 @@ export const ChatThread = memo(function ChatThread({
const displayItems = useMemo(() => buildDisplayItems(messages), [messages]);
if (loading) {
return (
<div className="flex flex-1 items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
);
}
if (messages.length === 0 && !isRunning) {
if (loading) {
return (
<div className="flex flex-1 items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
);
}
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-muted-foreground">
<p className="text-lg font-medium">{t("empty.title")}</p>
@@ -35,17 +35,17 @@ export function useChatMessages(sessionKey: string, agentId: string) {
const activityRef = useRef<RunActivity | null>(null);
const blockRepliesRef = useRef<ChatMessage[]>([]);
// Clear streaming/run state when session changes, but keep messages
// until new history loads to avoid a flash of empty content.
const [prevKey, setPrevKey] = useState(sessionKey);
if (sessionKey !== prevKey) {
setPrevKey(sessionKey);
// Don't clear messages — they'll be replaced by loadHistory()
// Reset streaming/run state when session changes.
// Messages are NOT cleared here — loadHistory() will replace them atomically.
// loading is NOT set to true — avoids full-page flash while history loads.
const prevKeyRef = useRef(sessionKey);
useEffect(() => {
if (sessionKey === prevKeyRef.current) return;
prevKeyRef.current = sessionKey;
setStreamText(null);
setThinkingText(null);
setToolStream([]);
setIsRunning(false);
setLoading(true);
setActivity(null);
setBlockReplies([]);
setTeamTasks([]);
@@ -56,7 +56,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
toolStreamRef.current = [];
activityRef.current = null;
blockRepliesRef.current = [];
}
}, [sessionKey]);
// Load history (no loading spinner — the empty state placeholder is shown instead)
const loadHistory = useCallback(async () => {
-1
View File
@@ -137,7 +137,6 @@ export function AppRoutes() {
<Route index element={<Navigate to={ROUTES.OVERVIEW} replace />} />
<Route path={ROUTES.OVERVIEW} element={<OverviewPage />} />
<Route path={ROUTES.CHAT} element={<ChatPage />} />
<Route path={ROUTES.CHAT_SESSION} element={<ChatPage />} />
<Route path={ROUTES.AGENTS} element={<AgentsPage key="list" />} />
<Route path={ROUTES.AGENT_DETAIL} element={<AgentsPage key="detail" />} />
<Route path={ROUTES.TEAMS} element={<TeamsPage key="list" />} />