diff --git a/web/src/App.tsx b/web/src/App.tsx index ab6146dd0c..65b4d800eb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -80,11 +80,12 @@ const CHAT_NAV_ITEM: NavItem = { /** * Built-in routes except /chat. Chat is rendered persistently (outside - * ) when embedded — see ChatPageHost below — so the PTY child, - * WebSocket, and xterm instance survive when the user visits another tab - * and comes back. A `display:none` toggle hides the terminal without - * unmounting. Routing still owns the URL so /chat deep-links, browser - * back/forward, and nav highlight keep working. + * ) when embedded — see the persistent chat host block rendered + * inline near the bottom of this file — so the PTY child, WebSocket, + * and xterm instance survive when the user visits another tab and comes + * back. A `display:none` toggle hides the terminal without unmounting. + * Routing still owns the URL so /chat deep-links, browser back/forward, + * and nav highlight keep working. */ const BUILTIN_ROUTES_CORE: Record = { "/": RootRedirect, @@ -578,17 +579,37 @@ export default function App() { or idle-disconnect after N minutes hidden; neither is shipped today. */} - {embeddedChat && !pluginsLoading && !chatOverriddenByPlugin && ( -
- -
+ {embeddedChat && !chatOverriddenByPlugin && ( + pluginsLoading ? ( + // Direct /chat deep-link: plugin manifests haven't resolved + // yet, so we can't tell if a plugin is going to claim this + // route. Show a lightweight placeholder instead of a + // blank page. Typical wait is <50ms; worst case is the + // 2s plugin-registration safety timeout. + isChatRoute ? ( +
+
+ + Loading chat… +
+
+ ) : null + ) : ( +
+ +
+ ) )} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 900893be7d..3b7f3b2acc 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -128,11 +128,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // /chat re-runs the effect (derived flips back to true) and re-locks. // Keying on the raw state would leak the body.overflow="hidden" across // tabs because the dep wouldn't change on tab switch. - const [mobilePanelOpenRaw, setMobilePanelOpen] = useState(false); + const [mobilePanelOpenRaw, setMobilePanelOpenRaw] = useState(false); const mobilePanelOpen = isActive && mobilePanelOpenRaw; const { setEnd } = usePageHeader(); const { t } = useI18n(); - const closeMobilePanel = useCallback(() => setMobilePanelOpen(false), []); + const closeMobilePanel = useCallback(() => setMobilePanelOpenRaw(false), []); const modelToolsLabel = useMemo( () => `${t.app.modelToolsSheetTitle} ${t.app.modelToolsSheetSubtitle}`, [t.app.modelToolsSheetSubtitle, t.app.modelToolsSheetTitle], @@ -174,7 +174,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { useEffect(() => { const mql = window.matchMedia("(min-width: 1024px)"); const onChange = (e: MediaQueryListEvent) => { - if (e.matches) setMobilePanelOpen(false); + if (e.matches) setMobilePanelOpenRaw(false); }; mql.addEventListener("change", onChange); return () => mql.removeEventListener("change", onChange); @@ -194,7 +194,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { setEnd(