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() {
} />
} />
} />
- } />
} />
} />
} />