diff --git a/ui/web/src/components/layout/app-layout.tsx b/ui/web/src/components/layout/app-layout.tsx index 8a6bacd3..d760c619 100644 --- a/ui/web/src/components/layout/app-layout.tsx +++ b/ui/web/src/components/layout/app-layout.tsx @@ -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() { )}
- +
diff --git a/ui/web/src/lib/constants.ts b/ui/web/src/lib/constants.ts index d70e3330..5d91901f 100644 --- a/ui/web/src/lib/constants.ts +++ b/ui/web/src/lib/constants.ts @@ -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", diff --git a/ui/web/src/pages/chat/chat-page.tsx b/ui/web/src/pages/chat/chat-page.tsx index 5d8925a7..7a01529d 100644 --- a/ui/web/src/pages/chat/chat-page.tsx +++ b/ui/web/src/pages/chat/chat-page.tsx @@ -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} /> @@ -187,6 +188,7 @@ export function ChatPage() { sessionsLoading={sessionsLoading} activeSessionKey={sessionKey} onSessionSelect={handleSessionSelect} + onDeleteSession={handleDeleteSession} onNewChat={handleNewChat} /> )} diff --git a/ui/web/src/pages/chat/chat-thread.tsx b/ui/web/src/pages/chat/chat-thread.tsx index 6d1268ba..7f07a4e6 100644 --- a/ui/web/src/pages/chat/chat-thread.tsx +++ b/ui/web/src/pages/chat/chat-thread.tsx @@ -83,15 +83,14 @@ export const ChatThread = memo(function ChatThread({ const displayItems = useMemo(() => buildDisplayItems(messages), [messages]); - if (loading) { - return ( -
-
-
- ); - } - if (messages.length === 0 && !isRunning) { + if (loading) { + return ( +
+
+
+ ); + } return (

{t("empty.title")}

diff --git a/ui/web/src/pages/chat/hooks/use-chat-messages.ts b/ui/web/src/pages/chat/hooks/use-chat-messages.ts index df449b79..3ffdf604 100644 --- a/ui/web/src/pages/chat/hooks/use-chat-messages.ts +++ b/ui/web/src/pages/chat/hooks/use-chat-messages.ts @@ -35,17 +35,17 @@ export function useChatMessages(sessionKey: string, agentId: string) { const activityRef = useRef(null); const blockRepliesRef = useRef([]); - // 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 () => { diff --git a/ui/web/src/routes.tsx b/ui/web/src/routes.tsx index 45f352c9..162b3284 100644 --- a/ui/web/src/routes.tsx +++ b/ui/web/src/routes.tsx @@ -137,7 +137,6 @@ export function AppRoutes() { } /> } /> } /> - } /> } /> } /> } />