mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
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:
1 parent
b80ee0ae43
commit
45295bc75a
6 files changed
+49
-39
No files matched your search
@@ -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,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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" />} />
|
||||
|
||||
Reference in new issue
Block a user