diff --git a/apps/web/app/components/browser/desktop-view.tsx b/apps/web/app/components/browser/desktop-view.tsx index 9710e7e3..770389ee 100644 --- a/apps/web/app/components/browser/desktop-view.tsx +++ b/apps/web/app/components/browser/desktop-view.tsx @@ -3,7 +3,12 @@ import type { SiteListItem } from "@umt/contracts"; import { Hand, Image as ImageIcon, LoaderCircle, Monitor, Power, RotateCw, Undo2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { useBrowserControl, useCloseBrowserSession, useSiteBrowserSession } from "~/api/browser.ts"; +import { + useBrowserControl, + useBrowserReconnect, + useCloseBrowserSession, + useSiteBrowserSession, +} from "~/api/browser.ts"; import { LoadingState } from "~/components/console/empty-state.tsx"; import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; @@ -46,6 +51,19 @@ const CONTROLLER_TONE: Record = { type Mode = "auto" | "screenshot"; +/** 画面加载后多久还没出图算黑屏 */ +const BLANK_TIMEOUT_MS = 5_000; + +/** 同源时看 Selkies 的视频流有没有出帧;跨源 / 拿不到文档时不判黑屏(只按 onLoad 判断) */ +function desktopBlank(frame: HTMLIFrameElement | null): boolean { + try { + const video = frame?.contentDocument?.querySelector("video"); + return !!video && (video.readyState < 2 || video.videoWidth === 0); + } catch { + return false; + } +} + /** 原站视图 Tab:能用桌面模式就用,否则截图模式 */ export function SiteOriginPanel({ site }: { site: SiteListItem }) { const session = useSiteBrowserSession(site.id); @@ -99,13 +117,45 @@ function DesktopView({ const refreshRole = () => void qc.invalidateQueries({ queryKey: qk.sub("browser", "desktop") }); const [reloadKey, setReloadKey] = useState(0); const [loaded, setLoaded] = useState(false); + const frame = useRef(null); + /** 打开时确认失败 / 黑屏超时:交给 useAutoReconnect 自动重连一次 */ + const [stuck, setStuck] = useState(false); const reloadDesktop = () => { setLoaded(false); setReloadKey((k) => k + 1); }; // BR-3:浏览器服务(同一容器里的 Selkies)重启过 → 自动重连会话一次,成功后重建桌面连接 - const rc = useAutoReconnect(site.id, s, false, reloadDesktop); + const rc = useAutoReconnect(site.id, s, stuck, () => { + setStuck(false); + reloadDesktop(); + }); + + // INT-3(VER-3b):桌面 iframe 直连 Selkies,不经过截图 / 操作接口,不会触发服务端的被动重连 → + // 打开桌面时先调一次幂等的 reconnect(上游上下文还在时服务端什么都不做),失败再走自动重连 + const ensure = useBrowserReconnect(site.id); + const { mutate: ensureMutate } = ensure; + const sid = s && s.controller !== "closed" ? s.id : null; + const ensured = useRef(null); + useEffect(() => { + if (!sid || ensured.current === sid) return; + ensured.current = sid; + ensureMutate(sid, { onError: () => setStuck(true) }); + }, [sid, ensureMutate]); + + // 画面加载失败或黑屏超过 5 秒:每个会话自动重连一次(之后靠「重新连接」按钮) + const blankTried = useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: reloadKey 变化 = 新一轮加载,重新计时 + useEffect(() => { + if (!sid || blankTried.current === sid) return; + const t = setTimeout(() => { + if (loaded && !desktopBlank(frame.current)) return; + blankTried.current = sid; + console.debug("[browser] 桌面画面 5 秒未出图,自动重新连接一次", sid); + setStuck(true); + }, BLANK_TIMEOUT_MS); + return () => clearTimeout(t); + }, [sid, loaded, reloadKey]); // 别处(Agent 操作、截图)触发的重连成功后也重建桌面连接 const reconnectedAt = s?.reconnect?.status === "ok" ? s.reconnect.at : null; const prevReconnect = useRef(reconnectedAt); @@ -230,6 +280,7 @@ function DesktopView({ ) : null}