Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -5,7 +5,7 @@ It validates the compositor behavior that unit tests cannot see:
|
|||||||
|
|
||||||
- the resident automation `<webview>` starts in the production parking state;
|
- the resident automation `<webview>` starts in the production parking state;
|
||||||
- the parked guest remains paintable and has a copyable viewport frame;
|
- the parked guest remains paintable and has a copyable viewport frame;
|
||||||
- the resident webview guest is sized to 1280x800 logical pixels;
|
- a never-presented resident webview guest defaults to 1280x800 logical pixels;
|
||||||
- multiple resident webviews are parked as an overlapping stack without per-capture
|
- multiple resident webviews are parked as an overlapping stack without per-capture
|
||||||
stacking changes;
|
stacking changes;
|
||||||
- a newly attached resident webview whose first useful frame is delayed can be captured
|
- a newly attached resident webview whose first useful frame is delayed can be captured
|
||||||
@@ -82,10 +82,11 @@ is usually saved as 2560x1600.
|
|||||||
|
|
||||||
Electron captures copy from the guest web contents' compositor surface. A resident
|
Electron captures copy from the guest web contents' compositor surface. A resident
|
||||||
webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its
|
webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its
|
||||||
copyable surface. The production parking state keeps the host fixed at `left:0`, `top:0`,
|
copyable surface. Each production webview keeps one permanent body-level surface. Presenting
|
||||||
`width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and `pointer-events:none`.
|
or parking changes that surface's geometry without reparenting the webview. The parking state
|
||||||
The webviews inside stay full-size at 1280x800, `display:inline-flex`, and absolutely
|
uses `left:0`, `top:0`, `width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and
|
||||||
overlap at `left:0`, `top:0`.
|
`pointer-events:none`. The webview stays at its resolved logical viewport, defaulting to
|
||||||
|
1280x800 before first presentation, with `display:inline-flex` at `left:0`, `top:0`.
|
||||||
|
|
||||||
There is no renderer prep/restore handshake. Main disables guest background throttling
|
There is no renderer prep/restore handshake. Main disables guest background throttling
|
||||||
once when the webview attaches, then screenshot capture uses the shared serialized queue,
|
once when the webview attaches, then screenshot capture uses the shared serialized queue,
|
||||||
|
|||||||
@@ -411,6 +411,11 @@ describe("mountBrowserAutomationHandler", () => {
|
|||||||
height: 768,
|
height: 768,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(useBrowserStore.getState().browsersById[result.browserId]?.viewport).toEqual({
|
||||||
|
mode: "fixed",
|
||||||
|
width: 1024,
|
||||||
|
height: 768,
|
||||||
|
});
|
||||||
expect(browser.browser.executedRequests).toHaveLength(1);
|
expect(browser.browser.executedRequests).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import {
|
|||||||
removeResidentBrowserWebview,
|
removeResidentBrowserWebview,
|
||||||
resizeResidentBrowserWebview,
|
resizeResidentBrowserWebview,
|
||||||
} from "@/desktop/browser/resident-webviews";
|
} from "@/desktop/browser/resident-webviews";
|
||||||
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/desktop/browser/store";
|
import {
|
||||||
|
createFixedBrowserViewport,
|
||||||
|
createWorkspaceBrowser,
|
||||||
|
getBrowserRecord,
|
||||||
|
useBrowserStore,
|
||||||
|
} from "@/desktop/browser/store";
|
||||||
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
@@ -208,6 +213,9 @@ function resizeBrowserTabForRequest(params: {
|
|||||||
message: `No browser tab found for ID: ${browserId}`,
|
message: `No browser tab found for ID: ${browserId}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
useBrowserStore
|
||||||
|
.getState()
|
||||||
|
.setBrowserViewport(browserId, createFixedBrowserViewport(dimensions.width, dimensions.height));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import {
|
import {
|
||||||
@@ -49,10 +50,22 @@ import {
|
|||||||
isElectronRuntime,
|
isElectronRuntime,
|
||||||
type DesktopBrowserShortcutEvent,
|
type DesktopBrowserShortcutEvent,
|
||||||
} from "@/desktop/host";
|
} from "@/desktop/host";
|
||||||
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/desktop/browser/store";
|
|
||||||
import {
|
import {
|
||||||
|
type BrowserViewport,
|
||||||
|
createFixedBrowserViewport,
|
||||||
|
normalizeWorkspaceBrowserUrl,
|
||||||
|
RESPONSIVE_BROWSER_VIEWPORT,
|
||||||
|
useBrowserStore,
|
||||||
|
} from "@/desktop/browser/store";
|
||||||
|
import {
|
||||||
|
applyBrowserWebviewViewport,
|
||||||
|
applyInactiveBrowserWebviewViewport,
|
||||||
|
isResidentBrowserWebviewReady,
|
||||||
prepareBrowserWebview,
|
prepareBrowserWebview,
|
||||||
|
presentBrowserWebview,
|
||||||
|
rememberBrowserWebviewSize,
|
||||||
releaseResidentBrowserWebview,
|
releaseResidentBrowserWebview,
|
||||||
|
removeResidentBrowserWebview,
|
||||||
takeResidentBrowserWebview,
|
takeResidentBrowserWebview,
|
||||||
} from "../resident-webviews";
|
} from "../resident-webviews";
|
||||||
|
|
||||||
@@ -551,7 +564,7 @@ function DeviceSizeMenu({
|
|||||||
onSelect,
|
onSelect,
|
||||||
triggerStyle,
|
triggerStyle,
|
||||||
}: {
|
}: {
|
||||||
selectedId: DeviceSizeId;
|
selectedId: DeviceSizeId | null;
|
||||||
onSelect: (id: DeviceSizeId) => void;
|
onSelect: (id: DeviceSizeId) => void;
|
||||||
triggerStyle: (state: { hovered?: boolean; pressed?: boolean }) => StyleProp<ViewStyle>;
|
triggerStyle: (state: { hovered?: boolean; pressed?: boolean }) => StyleProp<ViewStyle>;
|
||||||
}) {
|
}) {
|
||||||
@@ -590,6 +603,22 @@ function DeviceSizeMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deviceSizeIdForViewport(viewport: BrowserViewport): DeviceSizeId | null {
|
||||||
|
if (viewport.mode === "responsive") {
|
||||||
|
return "responsive";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
DEVICE_SIZE_PRESETS.find(
|
||||||
|
(preset) => preset.width === viewport.width && preset.height === viewport.height,
|
||||||
|
)?.id ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberResolvedBrowserWebviewSize(browserId: string, webview: HTMLElement): void {
|
||||||
|
const bounds = webview.getBoundingClientRect();
|
||||||
|
rememberBrowserWebviewSize({ browserId, width: bounds.width, height: bounds.height });
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line complexity
|
// eslint-disable-next-line complexity
|
||||||
export function BrowserPane({
|
export function BrowserPane({
|
||||||
browserId,
|
browserId,
|
||||||
@@ -610,8 +639,16 @@ export function BrowserPane({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const browser = useBrowserStore((state) => state.browsersById[browserId] ?? null);
|
const browser = useBrowserStore((state) => state.browsersById[browserId] ?? null);
|
||||||
const updateBrowser = useBrowserStore((state) => state.updateBrowser);
|
const updateBrowser = useBrowserStore((state) => state.updateBrowser);
|
||||||
|
const setBrowserViewport = useBrowserStore((state) => state.setBrowserViewport);
|
||||||
|
const browserViewport = browser?.viewport ?? RESPONSIVE_BROWSER_VIEWPORT;
|
||||||
|
const browserViewportRef = useRef(browserViewport);
|
||||||
|
browserViewportRef.current = browserViewport;
|
||||||
|
const isPresented = useRetainedPanelActive();
|
||||||
|
const isPresentedRef = useRef(isPresented);
|
||||||
|
isPresentedRef.current = isPresented;
|
||||||
const webviewRef = useRef<ElectronWebview | null>(null);
|
const webviewRef = useRef<ElectronWebview | null>(null);
|
||||||
const webviewHostRef = useRef<HTMLDivElement | null>(null);
|
const webviewHostRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const webviewClipRef = useRef<HTMLElement | null>(null);
|
||||||
const urlInputRef = useRef<WebTextInput | null>(null);
|
const urlInputRef = useRef<WebTextInput | null>(null);
|
||||||
const initialUrlRef = useRef(browser?.url ?? "https://example.com");
|
const initialUrlRef = useRef(browser?.url ?? "https://example.com");
|
||||||
const browserIdRef = useRef(browserId);
|
const browserIdRef = useRef(browserId);
|
||||||
@@ -629,7 +666,6 @@ export function BrowserPane({
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const toastRef = useRef(toast);
|
const toastRef = useRef(toast);
|
||||||
toastRef.current = toast;
|
toastRef.current = toast;
|
||||||
const [deviceSizeId, setDeviceSizeId] = useState<DeviceSizeId>("responsive");
|
|
||||||
const [pendingSelection, setPendingSelection] = useState<BrowserElementSelection | null>(null);
|
const [pendingSelection, setPendingSelection] = useState<BrowserElementSelection | null>(null);
|
||||||
// Screenshot is captured at selection time (overlay already torn down, no
|
// Screenshot is captured at selection time (overlay already torn down, no
|
||||||
// scroll drift) and reused when the annotation card is submitted.
|
// scroll drift) and reused when the annotation card is submitted.
|
||||||
@@ -727,7 +763,8 @@ export function BrowserPane({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const host = webviewHostRef.current;
|
const host = webviewHostRef.current;
|
||||||
if (!host) {
|
const clip = webviewClipRef.current;
|
||||||
|
if (!host || !clip) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,6 +777,7 @@ export function BrowserPane({
|
|||||||
const residentWebview = takeResidentBrowserWebview(browserId) as ElectronWebview | null;
|
const residentWebview = takeResidentBrowserWebview(browserId) as ElectronWebview | null;
|
||||||
const webview = residentWebview ?? (document.createElement("webview") as ElectronWebview);
|
const webview = residentWebview ?? (document.createElement("webview") as ElectronWebview);
|
||||||
webviewRef.current = webview;
|
webviewRef.current = webview;
|
||||||
|
domReadyRef.current = isResidentBrowserWebviewReady(webview);
|
||||||
if (!residentWebview) {
|
if (!residentWebview) {
|
||||||
prepareBrowserWebview(webview, {
|
prepareBrowserWebview(webview, {
|
||||||
browserId,
|
browserId,
|
||||||
@@ -747,14 +785,26 @@ export function BrowserPane({
|
|||||||
initialUrl: initialUnsafeNavigationMessage ? "about:blank" : initialUrlRef.current,
|
initialUrl: initialUnsafeNavigationMessage ? "about:blank" : initialUrlRef.current,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
webview.style.display = "flex";
|
releaseResidentBrowserWebview(browserId, webview);
|
||||||
webview.style.flex = "1";
|
if (isPresentedRef.current) {
|
||||||
webview.style.width = "100%";
|
applyBrowserWebviewViewport(webview, browserViewportRef.current);
|
||||||
webview.style.height = "100%";
|
presentBrowserWebview(browserId, webview, host, clip);
|
||||||
webview.style.border = "0";
|
} else {
|
||||||
webview.style.background = "transparent";
|
applyInactiveBrowserWebviewViewport(browserId, webview, browserViewportRef.current);
|
||||||
|
}
|
||||||
|
const sizeObserver =
|
||||||
|
typeof ResizeObserver === "undefined"
|
||||||
|
? null
|
||||||
|
: new ResizeObserver(() => {
|
||||||
|
if (!isPresentedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
presentBrowserWebview(browserIdRef.current, webview, host, clip);
|
||||||
|
rememberResolvedBrowserWebviewSize(browserIdRef.current, webview);
|
||||||
|
});
|
||||||
|
|
||||||
const handleStartLoading = () => {
|
const handleStartLoading = () => {
|
||||||
|
domReadyRef.current = false;
|
||||||
updateBrowser(browserId, { isLoading: true, lastError: null });
|
updateBrowser(browserId, { isLoading: true, lastError: null });
|
||||||
syncNavigationState({ syncUrl: false });
|
syncNavigationState({ syncUrl: false });
|
||||||
};
|
};
|
||||||
@@ -846,7 +896,11 @@ export function BrowserPane({
|
|||||||
webview.addEventListener("focus", handleWebviewFocus);
|
webview.addEventListener("focus", handleWebviewFocus);
|
||||||
webview.addEventListener("mousedown", handleWebviewFocus);
|
webview.addEventListener("mousedown", handleWebviewFocus);
|
||||||
|
|
||||||
host.appendChild(webview);
|
if (isPresentedRef.current) {
|
||||||
|
rememberResolvedBrowserWebviewSize(browserId, webview);
|
||||||
|
}
|
||||||
|
sizeObserver?.observe(host);
|
||||||
|
sizeObserver?.observe(clip);
|
||||||
if (initialUnsafeNavigationMessage) {
|
if (initialUnsafeNavigationMessage) {
|
||||||
updateBrowserRef.current(browserIdRef.current, {
|
updateBrowserRef.current(browserIdRef.current, {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -855,6 +909,7 @@ export function BrowserPane({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
sizeObserver?.disconnect();
|
||||||
webview.removeEventListener("did-start-loading", handleStartLoading);
|
webview.removeEventListener("did-start-loading", handleStartLoading);
|
||||||
webview.removeEventListener("did-stop-loading", handleStopLoading);
|
webview.removeEventListener("did-stop-loading", handleStopLoading);
|
||||||
webview.removeEventListener("will-navigate", handleWillNavigate);
|
webview.removeEventListener("will-navigate", handleWillNavigate);
|
||||||
@@ -866,15 +921,13 @@ export function BrowserPane({
|
|||||||
webview.removeEventListener("dom-ready", handleDomReady);
|
webview.removeEventListener("dom-ready", handleDomReady);
|
||||||
webview.removeEventListener("focus", handleWebviewFocus);
|
webview.removeEventListener("focus", handleWebviewFocus);
|
||||||
webview.removeEventListener("mousedown", handleWebviewFocus);
|
webview.removeEventListener("mousedown", handleWebviewFocus);
|
||||||
if (host.contains(webview)) {
|
const browserStillExists = Boolean(
|
||||||
const browserStillExists = Boolean(
|
useBrowserStore.getState().browsersById[browserIdRef.current],
|
||||||
useBrowserStore.getState().browsersById[browserIdRef.current],
|
);
|
||||||
);
|
if (browserStillExists) {
|
||||||
if (browserStillExists) {
|
releaseResidentBrowserWebview(browserIdRef.current, webview);
|
||||||
releaseResidentBrowserWebview(browserIdRef.current, webview);
|
} else {
|
||||||
} else {
|
removeResidentBrowserWebview(browserIdRef.current);
|
||||||
host.removeChild(webview);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (webviewRef.current === webview) {
|
if (webviewRef.current === webview) {
|
||||||
webviewRef.current = null;
|
webviewRef.current = null;
|
||||||
@@ -884,6 +937,32 @@ export function BrowserPane({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [browserId, onFocusPane]);
|
}, [browserId, onFocusPane]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const webview = webviewRef.current;
|
||||||
|
if (!webview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isPresented) {
|
||||||
|
releaseResidentBrowserWebview(browserId, webview);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyBrowserWebviewViewport(webview, browserViewport);
|
||||||
|
const host = webviewHostRef.current;
|
||||||
|
const clip = webviewClipRef.current;
|
||||||
|
if (host && clip) {
|
||||||
|
presentBrowserWebview(browserId, webview, host, clip);
|
||||||
|
}
|
||||||
|
if (browserViewport.mode === "fixed") {
|
||||||
|
rememberBrowserWebviewSize({
|
||||||
|
browserId,
|
||||||
|
width: browserViewport.width,
|
||||||
|
height: browserViewport.height,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
rememberResolvedBrowserWebviewSize(browserId, webview);
|
||||||
|
}
|
||||||
|
}, [browserId, browserViewport, isPresented]);
|
||||||
|
|
||||||
const navigate = useCallback(
|
const navigate = useCallback(
|
||||||
(nextUrl: string) => {
|
(nextUrl: string) => {
|
||||||
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
|
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
|
||||||
@@ -1525,12 +1604,26 @@ export function BrowserPane({
|
|||||||
[selectorMode],
|
[selectorMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const devicePreset = useMemo(
|
const selectedDeviceSizeId = useMemo(
|
||||||
() =>
|
() => deviceSizeIdForViewport(browserViewport),
|
||||||
DEVICE_SIZE_PRESETS.find((preset) => preset.id === deviceSizeId) ?? DEVICE_SIZE_PRESETS[0],
|
[browserViewport],
|
||||||
[deviceSizeId],
|
);
|
||||||
|
const isResponsiveDevice = browserViewport.mode === "responsive";
|
||||||
|
|
||||||
|
const handleSelectDeviceSize = useCallback(
|
||||||
|
(deviceSizeId: DeviceSizeId) => {
|
||||||
|
const preset =
|
||||||
|
DEVICE_SIZE_PRESETS.find((candidate) => candidate.id === deviceSizeId) ??
|
||||||
|
DEVICE_SIZE_PRESETS[0];
|
||||||
|
setBrowserViewport(
|
||||||
|
browserId,
|
||||||
|
preset.width === null || preset.height === null
|
||||||
|
? RESPONSIVE_BROWSER_VIEWPORT
|
||||||
|
: createFixedBrowserViewport(preset.width, preset.height),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[browserId, setBrowserViewport],
|
||||||
);
|
);
|
||||||
const isResponsiveDevice = devicePreset.width === null;
|
|
||||||
|
|
||||||
const webviewHostStyle = useMemo<CSSProperties>(
|
const webviewHostStyle = useMemo<CSSProperties>(
|
||||||
() =>
|
() =>
|
||||||
@@ -1546,15 +1639,13 @@ export function BrowserPane({
|
|||||||
: {
|
: {
|
||||||
// Fixed-size device frame, centered within webviewWrap (see styles).
|
// Fixed-size device frame, centered within webviewWrap (see styles).
|
||||||
display: "flex",
|
display: "flex",
|
||||||
width: devicePreset.width ?? undefined,
|
width: browserViewport.width,
|
||||||
maxWidth: "100%",
|
height: browserViewport.height,
|
||||||
height: devicePreset.height ?? undefined,
|
|
||||||
maxHeight: "100%",
|
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
background: theme.colors.surface0,
|
background: theme.colors.surface0,
|
||||||
boxShadow: "0 2px 16px rgba(0,0,0,0.25)",
|
boxShadow: "0 2px 16px rgba(0,0,0,0.25)",
|
||||||
},
|
},
|
||||||
[devicePreset.height, devicePreset.width, isResponsiveDevice, theme.colors.surface0],
|
[browserViewport, isResponsiveDevice, theme.colors.surface0],
|
||||||
);
|
);
|
||||||
|
|
||||||
const webviewWrapStyle = useMemo(
|
const webviewWrapStyle = useMemo(
|
||||||
@@ -1566,6 +1657,10 @@ export function BrowserPane({
|
|||||||
webviewHostRef.current = node;
|
webviewHostRef.current = node;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setWebviewClipNode = useCallback((node: unknown) => {
|
||||||
|
webviewClipRef.current = node instanceof HTMLElement ? node : null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!isElectronRuntime()) {
|
if (!isElectronRuntime()) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.unavailableState}>
|
<View style={styles.unavailableState}>
|
||||||
@@ -1624,8 +1719,8 @@ export function BrowserPane({
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.chromeRight}>
|
<View style={styles.chromeRight}>
|
||||||
<DeviceSizeMenu
|
<DeviceSizeMenu
|
||||||
selectedId={deviceSizeId}
|
selectedId={selectedDeviceSizeId}
|
||||||
onSelect={setDeviceSizeId}
|
onSelect={handleSelectDeviceSize}
|
||||||
triggerStyle={baseIconButtonStyle}
|
triggerStyle={baseIconButtonStyle}
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
@@ -1678,7 +1773,11 @@ export function BrowserPane({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={webviewWrapStyle}>
|
<View
|
||||||
|
ref={setWebviewClipNode}
|
||||||
|
style={webviewWrapStyle}
|
||||||
|
testID={`browser-webview-clip-${browserId}`}
|
||||||
|
>
|
||||||
{createElement("div", {
|
{createElement("div", {
|
||||||
ref: setWebviewHostNode,
|
ref: setWebviewHostNode,
|
||||||
style: webviewHostStyle,
|
style: webviewHostStyle,
|
||||||
@@ -1884,6 +1983,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
annotationOverlay: {
|
annotationOverlay: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
zIndex: 1,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
applyBrowserWebviewViewport,
|
||||||
|
applyInactiveBrowserWebviewViewport,
|
||||||
type BrowserWebviewProfileHost,
|
type BrowserWebviewProfileHost,
|
||||||
clearResidentBrowserWebviewsForTests,
|
clearResidentBrowserWebviewsForTests,
|
||||||
ensureResidentBrowserWebview,
|
ensureResidentBrowserWebview,
|
||||||
getResidentBrowserWebview,
|
getResidentBrowserWebview,
|
||||||
|
isResidentBrowserWebviewReady,
|
||||||
prepareBrowserWebview,
|
prepareBrowserWebview,
|
||||||
|
presentBrowserWebview,
|
||||||
|
rememberBrowserWebviewSize,
|
||||||
releaseResidentBrowserWebview,
|
releaseResidentBrowserWebview,
|
||||||
removeResidentBrowserWebview,
|
removeResidentBrowserWebview,
|
||||||
|
resizeResidentBrowserWebview,
|
||||||
takeResidentBrowserWebview,
|
takeResidentBrowserWebview,
|
||||||
} from "./resident-webviews";
|
} from "./resident-webviews";
|
||||||
import {
|
import {
|
||||||
@@ -54,14 +60,28 @@ function expectPermanentHostParking(host: HTMLElement): void {
|
|||||||
expect(host.style.position).toBe("fixed");
|
expect(host.style.position).toBe("fixed");
|
||||||
expect(host.style.left).toBe("0px");
|
expect(host.style.left).toBe("0px");
|
||||||
expect(host.style.top).toBe("0px");
|
expect(host.style.top).toBe("0px");
|
||||||
expect(host.style.width).toBe("1px");
|
expect(host.style.width).toBe("100vw");
|
||||||
expect(host.style.height).toBe("1px");
|
expect(host.style.height).toBe("100vh");
|
||||||
expect(host.style.overflow).toBe("hidden");
|
expect(host.style.overflow).toBe("visible");
|
||||||
expect(host.style.opacity).toBe("1");
|
expect(host.style.opacity).toBe("1");
|
||||||
expect(host.style.pointerEvents).toBe("none");
|
expect(host.style.pointerEvents).toBe("none");
|
||||||
expect(host.style.display).toBe("block");
|
expect(host.style.display).toBe("block");
|
||||||
expect(host.style.visibility).toBe("visible");
|
expect(host.style.zIndex).toBe("0");
|
||||||
expect(host.style.transform).toBe("");
|
}
|
||||||
|
|
||||||
|
function expectParkedSurface(surface: HTMLElement): void {
|
||||||
|
expect(surface.getAttribute("aria-hidden")).toBe("true");
|
||||||
|
expect(surface.style.position).toBe("fixed");
|
||||||
|
expect(surface.style.left).toBe("0px");
|
||||||
|
expect(surface.style.top).toBe("0px");
|
||||||
|
expect(surface.style.width).toBe("1px");
|
||||||
|
expect(surface.style.height).toBe("1px");
|
||||||
|
expect(surface.style.overflow).toBe("hidden");
|
||||||
|
expect(surface.style.opacity).toBe("1");
|
||||||
|
expect(surface.style.pointerEvents).toBe("none");
|
||||||
|
expect(surface.style.display).toBe("block");
|
||||||
|
expect(surface.style.visibility).toBe("visible");
|
||||||
|
expect(surface.style.transform).toBe("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectResidentWebviewParking(webview: HTMLElement): void {
|
function expectResidentWebviewParking(webview: HTMLElement): void {
|
||||||
@@ -94,12 +114,110 @@ describe("resident browser webviews", () => {
|
|||||||
|
|
||||||
const host = residentHost();
|
const host = residentHost();
|
||||||
expect(visibleHost.children).toHaveLength(0);
|
expect(visibleHost.children).toHaveLength(0);
|
||||||
expect(Array.from(host.children)).toEqual([webview]);
|
const surface = host.firstElementChild as HTMLElement;
|
||||||
|
expect(Array.from(surface.children)).toEqual([webview]);
|
||||||
expect(webview.isConnected).toBe(true);
|
expect(webview.isConnected).toBe(true);
|
||||||
expectPermanentHostParking(host);
|
expectPermanentHostParking(host);
|
||||||
|
expectParkedSurface(surface);
|
||||||
expectResidentWebviewParking(webview);
|
expectResidentWebviewParking(webview);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("presents and parks a browser without changing its webview parent", () => {
|
||||||
|
const webview = ensureTestBrowser({
|
||||||
|
browserId: "browser-stable-parent",
|
||||||
|
workspaceId: "workspace-stable-parent",
|
||||||
|
url: "https://example.com",
|
||||||
|
});
|
||||||
|
const anchor = document.createElement("div");
|
||||||
|
const clip = document.createElement("div");
|
||||||
|
Object.defineProperty(anchor, "getBoundingClientRect", {
|
||||||
|
value: () => ({ left: 40, top: 60, width: 640, height: 480 }),
|
||||||
|
});
|
||||||
|
Object.defineProperty(clip, "getBoundingClientRect", {
|
||||||
|
value: () => ({ left: 40, top: 60, width: 640, height: 480 }),
|
||||||
|
});
|
||||||
|
if (!webview?.parentElement) {
|
||||||
|
throw new Error("Expected resident browser surface");
|
||||||
|
}
|
||||||
|
const permanentParent = webview.parentElement;
|
||||||
|
|
||||||
|
presentBrowserWebview("browser-stable-parent", webview, anchor, clip);
|
||||||
|
expect(webview.parentElement).toBe(permanentParent);
|
||||||
|
expect(permanentParent.style.left).toBe("40px");
|
||||||
|
expect(permanentParent.style.top).toBe("60px");
|
||||||
|
expect(permanentParent.style.width).toBe("640px");
|
||||||
|
expect(permanentParent.style.height).toBe("480px");
|
||||||
|
expect(permanentParent.style.pointerEvents).toBe("auto");
|
||||||
|
expect(permanentParent.getAttribute("aria-hidden")).toBe("false");
|
||||||
|
|
||||||
|
releaseResidentBrowserWebview("browser-stable-parent", webview);
|
||||||
|
expect(webview.parentElement).toBe(permanentParent);
|
||||||
|
expectParkedSurface(permanentParent);
|
||||||
|
|
||||||
|
presentBrowserWebview("browser-stable-parent", webview, anchor, clip);
|
||||||
|
expect(webview.parentElement).toBe(permanentParent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clips an oversized fixed viewport to its pane without resizing the webview", () => {
|
||||||
|
const webview = ensureTestBrowser({
|
||||||
|
browserId: "browser-oversized",
|
||||||
|
workspaceId: "workspace-oversized",
|
||||||
|
url: "https://example.com",
|
||||||
|
});
|
||||||
|
if (!webview?.parentElement) {
|
||||||
|
throw new Error("Expected resident browser surface");
|
||||||
|
}
|
||||||
|
applyBrowserWebviewViewport(webview, { mode: "fixed", width: 2560, height: 1440 });
|
||||||
|
const anchor = document.createElement("div");
|
||||||
|
const clip = document.createElement("div");
|
||||||
|
Object.defineProperty(anchor, "getBoundingClientRect", {
|
||||||
|
value: () => ({ left: -800, top: -300, width: 2560, height: 1440 }),
|
||||||
|
});
|
||||||
|
Object.defineProperty(clip, "getBoundingClientRect", {
|
||||||
|
value: () => ({ left: 100, top: 150, width: 800, height: 600 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
presentBrowserWebview("browser-oversized", webview, anchor, clip);
|
||||||
|
|
||||||
|
expect(webview.parentElement.style.left).toBe("100px");
|
||||||
|
expect(webview.parentElement.style.top).toBe("150px");
|
||||||
|
expect(webview.parentElement.style.width).toBe("800px");
|
||||||
|
expect(webview.parentElement.style.height).toBe("600px");
|
||||||
|
expect(webview.style.left).toBe("-900px");
|
||||||
|
expect(webview.style.top).toBe("-450px");
|
||||||
|
expect(webview.style.width).toBe("2560px");
|
||||||
|
expect(webview.style.height).toBe("1440px");
|
||||||
|
|
||||||
|
resizeResidentBrowserWebview({ browserId: "browser-oversized", width: 2560, height: 1440 });
|
||||||
|
|
||||||
|
expect(webview.style.left).toBe("-900px");
|
||||||
|
expect(webview.style.top).toBe("-450px");
|
||||||
|
expect(webview.parentElement.style.width).toBe("800px");
|
||||||
|
expect(webview.parentElement.style.height).toBe("600px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains document readiness with the resident webview", () => {
|
||||||
|
const webview = ensureTestBrowser({
|
||||||
|
browserId: "browser-ready",
|
||||||
|
workspaceId: "workspace-ready",
|
||||||
|
url: "https://example.com",
|
||||||
|
});
|
||||||
|
if (!webview) {
|
||||||
|
throw new Error("Expected resident browser webview");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(isResidentBrowserWebviewReady(webview)).toBe(false);
|
||||||
|
webview.dispatchEvent(new Event("dom-ready"));
|
||||||
|
releaseResidentBrowserWebview("browser-ready", webview);
|
||||||
|
|
||||||
|
const reusedWebview = takeResidentBrowserWebview("browser-ready");
|
||||||
|
expect(reusedWebview).toBe(webview);
|
||||||
|
expect(isResidentBrowserWebviewReady(reusedWebview as HTMLElement)).toBe(true);
|
||||||
|
|
||||||
|
webview.dispatchEvent(new Event("did-start-loading"));
|
||||||
|
expect(isResidentBrowserWebviewReady(webview)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("creates a resident webview for an agent-created unfocused tab", () => {
|
it("creates a resident webview for an agent-created unfocused tab", () => {
|
||||||
const webview = ensureTestBrowser({
|
const webview = ensureTestBrowser({
|
||||||
browserId: "browser-agent",
|
browserId: "browser-agent",
|
||||||
@@ -197,8 +315,10 @@ describe("resident browser webviews", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(webview).toBe(staleWebview);
|
expect(webview).toBe(staleWebview);
|
||||||
expect(Array.from(staleHost.children)).toEqual([staleWebview]);
|
const surface = staleHost.firstElementChild as HTMLElement;
|
||||||
|
expect(Array.from(surface.children)).toEqual([staleWebview]);
|
||||||
expectPermanentHostParking(staleHost);
|
expectPermanentHostParking(staleHost);
|
||||||
|
expectParkedSurface(surface);
|
||||||
expectResidentWebviewParking(staleWebview);
|
expectResidentWebviewParking(staleWebview);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,27 +335,73 @@ describe("resident browser webviews", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const host = residentHost();
|
const host = residentHost();
|
||||||
expect(firstWebview?.parentElement).toBe(host);
|
expect(firstWebview?.parentElement?.parentElement).toBe(host);
|
||||||
expect(secondWebview?.parentElement).toBe(host);
|
expect(secondWebview?.parentElement?.parentElement).toBe(host);
|
||||||
|
expect(firstWebview?.parentElement).not.toBe(secondWebview?.parentElement);
|
||||||
expectResidentWebviewParking(firstWebview as HTMLElement);
|
expectResidentWebviewParking(firstWebview as HTMLElement);
|
||||||
expectResidentWebviewParking(secondWebview as HTMLElement);
|
expectResidentWebviewParking(secondWebview as HTMLElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves a resident webview into a visible pane without recreating the node", () => {
|
it("returns a resident webview without detaching it from its permanent surface", () => {
|
||||||
const webview = ensureTestBrowser({
|
const webview = ensureTestBrowser({
|
||||||
browserId: "browser-visible",
|
browserId: "browser-visible",
|
||||||
workspaceId: "workspace-visible",
|
workspaceId: "workspace-visible",
|
||||||
url: "https://example.com",
|
url: "https://example.com",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const permanentParent = webview?.parentElement;
|
||||||
const visibleWebview = takeResidentBrowserWebview("browser-visible");
|
const visibleWebview = takeResidentBrowserWebview("browser-visible");
|
||||||
|
|
||||||
expect(visibleWebview).toBe(webview);
|
expect(visibleWebview).toBe(webview);
|
||||||
expect(webview?.style.position).toBe("");
|
expect(webview?.parentElement).toBe(permanentParent);
|
||||||
expect(webview?.style.left).toBe("");
|
expect(takeResidentBrowserWebview("browser-visible")).toBe(webview);
|
||||||
expect(webview?.style.top).toBe("");
|
});
|
||||||
expect(webview?.style.zIndex).toBe("");
|
|
||||||
expect(takeResidentBrowserWebview("browser-visible")).toBeNull();
|
it("applies an exact fixed viewport without a flex width override", () => {
|
||||||
|
const webview = document.createElement("webview");
|
||||||
|
|
||||||
|
applyBrowserWebviewViewport(webview, { mode: "fixed", width: 640, height: 480 });
|
||||||
|
|
||||||
|
expect(webview.style.flex).toBe("0 0 auto");
|
||||||
|
expect(webview.style.width).toBe("640px");
|
||||||
|
expect(webview.style.height).toBe("480px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parks a browser at its last resolved viewport dimensions", () => {
|
||||||
|
const visibleHost = document.createElement("div");
|
||||||
|
const webview = document.createElement("webview");
|
||||||
|
visibleHost.appendChild(webview);
|
||||||
|
document.body.appendChild(visibleHost);
|
||||||
|
rememberBrowserWebviewSize({ browserId: "browser-sized", width: 640, height: 480 });
|
||||||
|
|
||||||
|
releaseResidentBrowserWebview("browser-sized", webview);
|
||||||
|
|
||||||
|
expect(webview.style.width).toBe("640px");
|
||||||
|
expect(webview.style.height).toBe("480px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an inactive in-place browser at its last resolved viewport dimensions", () => {
|
||||||
|
const webview = document.createElement("webview");
|
||||||
|
rememberBrowserWebviewSize({ browserId: "browser-inactive", width: 640, height: 480 });
|
||||||
|
|
||||||
|
applyInactiveBrowserWebviewViewport("browser-inactive", webview, { mode: "responsive" });
|
||||||
|
|
||||||
|
expect(webview.style.flex).toBe("0 0 auto");
|
||||||
|
expect(webview.style.width).toBe("640px");
|
||||||
|
expect(webview.style.height).toBe("480px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parks a cold inactive browser at its canonical fixed viewport", () => {
|
||||||
|
const webview = document.createElement("webview");
|
||||||
|
|
||||||
|
applyInactiveBrowserWebviewViewport("browser-restored-fixed", webview, {
|
||||||
|
mode: "fixed",
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(webview.style.width).toBe("800px");
|
||||||
|
expect(webview.style.height).toBe("600px");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an existing visible pane webview instead of creating a resident duplicate", () => {
|
it("returns an existing visible pane webview instead of creating a resident duplicate", () => {
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ import {
|
|||||||
type DesktopAttachedBrowserRegistration,
|
type DesktopAttachedBrowserRegistration,
|
||||||
type DesktopBrowserBridge,
|
type DesktopBrowserBridge,
|
||||||
} from "@/desktop/host";
|
} from "@/desktop/host";
|
||||||
|
import type { BrowserViewport } from "@/desktop/browser/store";
|
||||||
|
|
||||||
const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews";
|
const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews";
|
||||||
const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id";
|
const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id";
|
||||||
|
const BROWSER_SURFACE_ATTRIBUTE = "data-paseo-browser-surface";
|
||||||
const RESIDENT_VIEWPORT_WIDTH = 1280;
|
const RESIDENT_VIEWPORT_WIDTH = 1280;
|
||||||
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
||||||
|
|
||||||
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
|
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
|
||||||
|
const residentSurfacesByBrowserId = new Map<string, HTMLElement>();
|
||||||
const residentWebviewSizesByBrowserId = new Map<string, { width: number; height: number }>();
|
const residentWebviewSizesByBrowserId = new Map<string, { width: number; height: number }>();
|
||||||
|
const readyResidentWebviews = new WeakSet<HTMLElement>();
|
||||||
|
|
||||||
interface BrowserWebviewElement extends HTMLElement {
|
interface BrowserWebviewElement extends HTMLElement {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -70,6 +74,15 @@ function registerBrowserWhenAttached(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerBrowserReadiness(webview: HTMLElement): void {
|
||||||
|
webview.addEventListener("did-start-loading", () => {
|
||||||
|
readyResidentWebviews.delete(webview);
|
||||||
|
});
|
||||||
|
webview.addEventListener("dom-ready", () => {
|
||||||
|
readyResidentWebviews.add(webview);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return null;
|
return null;
|
||||||
@@ -83,24 +96,52 @@ function readDocument(): Document | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyResidentHostParkingStyle(host: HTMLElement): void {
|
function applyResidentHostParkingStyle(host: HTMLElement): void {
|
||||||
// Parked browser webviews must remain paintable at all times; screenshot
|
// The host is permanent. Individual browser surfaces switch between their
|
||||||
// correctness depends on the proven states in docs/browser-capture-harness.md.
|
// pane bounds and the proven paintable 1x1 parking geometry.
|
||||||
host.setAttribute("aria-hidden", "true");
|
host.removeAttribute("aria-hidden");
|
||||||
host.style.position = "fixed";
|
host.style.position = "fixed";
|
||||||
host.style.left = "0";
|
host.style.left = "0";
|
||||||
host.style.top = "0";
|
host.style.top = "0";
|
||||||
host.style.width = "1px";
|
host.style.width = "100vw";
|
||||||
host.style.height = "1px";
|
host.style.height = "100vh";
|
||||||
host.style.overflow = "hidden";
|
host.style.overflow = "visible";
|
||||||
host.style.opacity = "1";
|
host.style.opacity = "1";
|
||||||
host.style.pointerEvents = "none";
|
host.style.pointerEvents = "none";
|
||||||
host.style.display = "block";
|
host.style.display = "block";
|
||||||
host.style.zIndex = "";
|
host.style.zIndex = "0";
|
||||||
host.style.clipPath = "";
|
host.style.clipPath = "";
|
||||||
host.style.visibility = "visible";
|
host.style.visibility = "visible";
|
||||||
host.style.transform = "";
|
host.style.transform = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyParkedBrowserSurfaceStyle(surface: HTMLElement): void {
|
||||||
|
surface.setAttribute("aria-hidden", "true");
|
||||||
|
surface.style.position = "fixed";
|
||||||
|
surface.style.left = "0";
|
||||||
|
surface.style.top = "0";
|
||||||
|
surface.style.width = "1px";
|
||||||
|
surface.style.height = "1px";
|
||||||
|
surface.style.overflow = "hidden";
|
||||||
|
surface.style.opacity = "1";
|
||||||
|
surface.style.pointerEvents = "none";
|
||||||
|
surface.style.display = "block";
|
||||||
|
surface.style.visibility = "visible";
|
||||||
|
surface.style.transform = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBrowserSurface(browserId: string, ownerDocument: Document): HTMLElement {
|
||||||
|
const existing = residentSurfacesByBrowserId.get(browserId);
|
||||||
|
if (existing?.isConnected) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const surface = ownerDocument.createElement("div");
|
||||||
|
surface.setAttribute(BROWSER_SURFACE_ATTRIBUTE, browserId);
|
||||||
|
applyParkedBrowserSurfaceStyle(surface);
|
||||||
|
getResidentBrowserHost(ownerDocument).appendChild(surface);
|
||||||
|
residentSurfacesByBrowserId.set(browserId, surface);
|
||||||
|
return surface;
|
||||||
|
}
|
||||||
|
|
||||||
function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
|
function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
|
||||||
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
|
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -162,6 +203,105 @@ function clearResidentWebviewParkingStyle(webview: HTMLElement): void {
|
|||||||
webview.style.zIndex = "";
|
webview.style.zIndex = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rememberBrowserWebviewSize(input: {
|
||||||
|
browserId: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}): { width: number; height: number } | null {
|
||||||
|
const browserId = trimNonEmpty(input.browserId);
|
||||||
|
if (!browserId || input.width <= 0 || input.height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const dimensions = {
|
||||||
|
width: Math.max(1, Math.round(input.width)),
|
||||||
|
height: Math.max(1, Math.round(input.height)),
|
||||||
|
};
|
||||||
|
residentWebviewSizesByBrowserId.set(browserId, dimensions);
|
||||||
|
return dimensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBrowserWebviewDimensions(webview: HTMLElement, viewport: BrowserViewport): void {
|
||||||
|
webview.style.display = "flex";
|
||||||
|
webview.style.border = "0";
|
||||||
|
webview.style.background = "transparent";
|
||||||
|
if (viewport.mode === "responsive") {
|
||||||
|
webview.style.flex = "1";
|
||||||
|
webview.style.width = "100%";
|
||||||
|
webview.style.height = "100%";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webview.style.flex = "0 0 auto";
|
||||||
|
webview.style.width = `${viewport.width}px`;
|
||||||
|
webview.style.height = `${viewport.height}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBrowserWebviewViewport(webview: HTMLElement, viewport: BrowserViewport): void {
|
||||||
|
clearResidentWebviewParkingStyle(webview);
|
||||||
|
applyBrowserWebviewDimensions(webview, viewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyInactiveBrowserWebviewViewport(
|
||||||
|
browserId: string,
|
||||||
|
webview: HTMLElement,
|
||||||
|
viewport: BrowserViewport,
|
||||||
|
): void {
|
||||||
|
if (viewport.mode === "fixed") {
|
||||||
|
rememberBrowserWebviewSize({ browserId, width: viewport.width, height: viewport.height });
|
||||||
|
}
|
||||||
|
applyResidentWebviewStyle(webview, trimNonEmpty(browserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function presentBrowserWebview(
|
||||||
|
browserId: string,
|
||||||
|
webview: HTMLElement,
|
||||||
|
anchor: HTMLElement,
|
||||||
|
clip: HTMLElement,
|
||||||
|
): void {
|
||||||
|
const normalizedBrowserId = trimNonEmpty(browserId);
|
||||||
|
if (!normalizedBrowserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ownerDocument = readDocument();
|
||||||
|
if (!ownerDocument) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const surface = getBrowserSurface(normalizedBrowserId, ownerDocument);
|
||||||
|
if (webview.parentElement !== surface) {
|
||||||
|
surface.appendChild(webview);
|
||||||
|
}
|
||||||
|
const anchorBounds = anchor.getBoundingClientRect();
|
||||||
|
const clipBounds = clip.getBoundingClientRect();
|
||||||
|
const left = Math.max(anchorBounds.left, clipBounds.left);
|
||||||
|
const top = Math.max(anchorBounds.top, clipBounds.top);
|
||||||
|
const right = Math.min(
|
||||||
|
anchorBounds.left + anchorBounds.width,
|
||||||
|
clipBounds.left + clipBounds.width,
|
||||||
|
);
|
||||||
|
const bottom = Math.min(
|
||||||
|
anchorBounds.top + anchorBounds.height,
|
||||||
|
clipBounds.top + clipBounds.height,
|
||||||
|
);
|
||||||
|
const surfaceLeft = Math.ceil(left);
|
||||||
|
const surfaceTop = Math.ceil(top);
|
||||||
|
const surfaceRight = Math.floor(right);
|
||||||
|
const surfaceBottom = Math.floor(bottom);
|
||||||
|
const hasVisibleArea = surfaceRight > surfaceLeft && surfaceBottom > surfaceTop;
|
||||||
|
surface.setAttribute("aria-hidden", "false");
|
||||||
|
surface.style.position = "fixed";
|
||||||
|
surface.style.left = `${surfaceLeft}px`;
|
||||||
|
surface.style.top = `${surfaceTop}px`;
|
||||||
|
surface.style.width = `${Math.max(0, surfaceRight - surfaceLeft)}px`;
|
||||||
|
surface.style.height = `${Math.max(0, surfaceBottom - surfaceTop)}px`;
|
||||||
|
surface.style.overflow = "hidden";
|
||||||
|
surface.style.opacity = "1";
|
||||||
|
surface.style.pointerEvents = hasVisibleArea ? "auto" : "none";
|
||||||
|
surface.style.display = "flex";
|
||||||
|
surface.style.visibility = "visible";
|
||||||
|
webview.style.position = "absolute";
|
||||||
|
webview.style.left = `${Math.round(anchorBounds.left - surfaceLeft)}px`;
|
||||||
|
webview.style.top = `${Math.round(anchorBounds.top - surfaceTop)}px`;
|
||||||
|
}
|
||||||
|
|
||||||
export function prepareBrowserWebview(
|
export function prepareBrowserWebview(
|
||||||
webview: HTMLElement,
|
webview: HTMLElement,
|
||||||
input: {
|
input: {
|
||||||
@@ -181,6 +321,11 @@ export function prepareBrowserWebview(
|
|||||||
(webview as BrowserWebviewElement).src = input.initialUrl;
|
(webview as BrowserWebviewElement).src = input.initialUrl;
|
||||||
}
|
}
|
||||||
registerBrowserWhenAttached(webview as BrowserWebviewElement, input, browser);
|
registerBrowserWhenAttached(webview as BrowserWebviewElement, input, browser);
|
||||||
|
registerBrowserReadiness(webview);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isResidentBrowserWebviewReady(webview: HTMLElement): boolean {
|
||||||
|
return readyResidentWebviews.has(webview);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureResidentBrowserWebview(input: {
|
export function ensureResidentBrowserWebview(input: {
|
||||||
@@ -247,8 +392,6 @@ export function takeResidentBrowserWebview(browserId: string): HTMLElement | nul
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
|
||||||
clearResidentWebviewParkingStyle(webview);
|
|
||||||
return webview;
|
return webview;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +408,11 @@ export function releaseResidentBrowserWebview(browserId: string, webview: HTMLEl
|
|||||||
|
|
||||||
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
|
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
|
||||||
applyResidentWebviewStyle(webview, normalizedBrowserId);
|
applyResidentWebviewStyle(webview, normalizedBrowserId);
|
||||||
getResidentBrowserHost(ownerDocument).appendChild(webview);
|
const surface = getBrowserSurface(normalizedBrowserId, ownerDocument);
|
||||||
|
applyParkedBrowserSurfaceStyle(surface);
|
||||||
|
if (webview.parentElement !== surface) {
|
||||||
|
surface.appendChild(webview);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resizeResidentBrowserWebview(input: {
|
export function resizeResidentBrowserWebview(input: {
|
||||||
@@ -277,18 +424,18 @@ export function resizeResidentBrowserWebview(input: {
|
|||||||
if (!normalizedBrowserId) {
|
if (!normalizedBrowserId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const width = Math.max(1, Math.round(input.width));
|
const dimensions = rememberBrowserWebviewSize(input);
|
||||||
const height = Math.max(1, Math.round(input.height));
|
if (!dimensions) {
|
||||||
residentWebviewSizesByBrowserId.set(normalizedBrowserId, { width, height });
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const ownerDocument = readDocument();
|
const ownerDocument = readDocument();
|
||||||
const webview = ownerDocument ? findBrowserWebview(normalizedBrowserId, ownerDocument) : null;
|
const webview = ownerDocument ? findBrowserWebview(normalizedBrowserId, ownerDocument) : null;
|
||||||
if (webview) {
|
if (webview) {
|
||||||
webview.style.width = `${width}px`;
|
applyBrowserWebviewDimensions(webview, { mode: "fixed", ...dimensions });
|
||||||
webview.style.height = `${height}px`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { width, height };
|
return dimensions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeResidentBrowserWebview(browserId: string): void {
|
export function removeResidentBrowserWebview(browserId: string): void {
|
||||||
@@ -298,9 +445,12 @@ export function removeResidentBrowserWebview(browserId: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
||||||
|
const surface = residentSurfacesByBrowserId.get(normalizedBrowserId) ?? null;
|
||||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||||
|
residentSurfacesByBrowserId.delete(normalizedBrowserId);
|
||||||
residentWebviewSizesByBrowserId.delete(normalizedBrowserId);
|
residentWebviewSizesByBrowserId.delete(normalizedBrowserId);
|
||||||
resident?.remove();
|
resident?.remove();
|
||||||
|
surface?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearResidentBrowserWebviewsForTests(): void {
|
export function clearResidentBrowserWebviewsForTests(): void {
|
||||||
@@ -308,6 +458,7 @@ export function clearResidentBrowserWebviewsForTests(): void {
|
|||||||
webview.remove();
|
webview.remove();
|
||||||
}
|
}
|
||||||
residentWebviewsByBrowserId.clear();
|
residentWebviewsByBrowserId.clear();
|
||||||
|
residentSurfacesByBrowserId.clear();
|
||||||
residentWebviewSizesByBrowserId.clear();
|
residentWebviewSizesByBrowserId.clear();
|
||||||
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
|
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,26 @@ import {
|
|||||||
type BrowserIndexState,
|
type BrowserIndexState,
|
||||||
type BrowserRecord,
|
type BrowserRecord,
|
||||||
type BrowserRecordPatch,
|
type BrowserRecordPatch,
|
||||||
|
type BrowserViewport,
|
||||||
createBrowserRecord,
|
createBrowserRecord,
|
||||||
|
normalizeBrowserIndexState,
|
||||||
normalizeBrowserUrl,
|
normalizeBrowserUrl,
|
||||||
removeBrowserFromIndex,
|
removeBrowserFromIndex,
|
||||||
sanitizeBrowsersForPersist,
|
sanitizeBrowsersForPersist,
|
||||||
trimNonEmpty,
|
trimNonEmpty,
|
||||||
} from "./state";
|
} from "./state";
|
||||||
|
|
||||||
export type { BrowserRecord } from "./state";
|
export {
|
||||||
|
createFixedBrowserViewport,
|
||||||
|
RESPONSIVE_BROWSER_VIEWPORT,
|
||||||
|
type BrowserRecord,
|
||||||
|
type BrowserViewport,
|
||||||
|
} from "./state";
|
||||||
|
|
||||||
interface BrowserStoreState extends BrowserIndexState {
|
interface BrowserStoreState extends BrowserIndexState {
|
||||||
createBrowser: (input?: { initialUrl?: string }) => string;
|
createBrowser: (input?: { initialUrl?: string }) => string;
|
||||||
updateBrowser: (browserId: string, patch: BrowserRecordPatch) => void;
|
updateBrowser: (browserId: string, patch: BrowserRecordPatch) => void;
|
||||||
|
setBrowserViewport: (browserId: string, viewport: BrowserViewport) => void;
|
||||||
removeBrowser: (browserId: string) => void;
|
removeBrowser: (browserId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +65,9 @@ export const useBrowserStore = create<BrowserStoreState>()(
|
|||||||
updateBrowser: (browserId, patch) => {
|
updateBrowser: (browserId, patch) => {
|
||||||
set((state) => applyBrowserPatch(state, browserId, patch));
|
set((state) => applyBrowserPatch(state, browserId, patch));
|
||||||
},
|
},
|
||||||
|
setBrowserViewport: (browserId, viewport) => {
|
||||||
|
set((state) => applyBrowserPatch(state, browserId, { viewport }));
|
||||||
|
},
|
||||||
removeBrowser: (browserId) => {
|
removeBrowser: (browserId) => {
|
||||||
set((state) => removeBrowserFromIndex(state, browserId));
|
set((state) => removeBrowserFromIndex(state, browserId));
|
||||||
},
|
},
|
||||||
@@ -65,6 +76,10 @@ export const useBrowserStore = create<BrowserStoreState>()(
|
|||||||
name: "workspace-browser-store",
|
name: "workspace-browser-store",
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
partialize: (state) => sanitizeBrowsersForPersist(state),
|
partialize: (state) => sanitizeBrowsersForPersist(state),
|
||||||
|
merge: (persistedState, currentState) => ({
|
||||||
|
...currentState,
|
||||||
|
...normalizeBrowserIndexState(persistedState),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
applyBrowserPatch,
|
applyBrowserPatch,
|
||||||
type BrowserIndexState,
|
type BrowserIndexState,
|
||||||
|
createFixedBrowserViewport,
|
||||||
createBrowserRecord,
|
createBrowserRecord,
|
||||||
|
normalizeBrowserIndexState,
|
||||||
normalizeBrowserUrl,
|
normalizeBrowserUrl,
|
||||||
removeBrowserFromIndex,
|
removeBrowserFromIndex,
|
||||||
sanitizeBrowsersForPersist,
|
sanitizeBrowsersForPersist,
|
||||||
@@ -57,12 +59,25 @@ describe("createBrowserRecord", () => {
|
|||||||
canGoForward: false,
|
canGoForward: false,
|
||||||
faviconUrl: null,
|
faviconUrl: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
viewport: { mode: "responsive" },
|
||||||
createdAt: 1000,
|
createdAt: 1000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("applyBrowserPatch", () => {
|
describe("applyBrowserPatch", () => {
|
||||||
|
it("updates the browser's canonical viewport", () => {
|
||||||
|
const initial = withRecords([
|
||||||
|
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const next = applyBrowserPatch(initial, "b1", {
|
||||||
|
viewport: createFixedBrowserViewport(639.6, 479.6),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.browsersById.b1?.viewport).toEqual({ mode: "fixed", width: 640, height: 480 });
|
||||||
|
});
|
||||||
|
|
||||||
it("normalizes URL updates", () => {
|
it("normalizes URL updates", () => {
|
||||||
const initial = withRecords([
|
const initial = withRecords([
|
||||||
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
|
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
|
||||||
@@ -145,4 +160,32 @@ describe("sanitizeBrowsersForPersist", () => {
|
|||||||
expect(persisted.browsersById.b1?.isLoading).toBe(false);
|
expect(persisted.browsersById.b1?.isLoading).toBe(false);
|
||||||
expect(persisted.browsersById.b1?.lastError).toBe(null);
|
expect(persisted.browsersById.b1?.lastError).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves viewport state", () => {
|
||||||
|
const base = createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 });
|
||||||
|
const state = applyBrowserPatch(withRecords([base]), "b1", {
|
||||||
|
viewport: createFixedBrowserViewport(640, 480),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sanitizeBrowsersForPersist(state).browsersById.b1?.viewport).toEqual({
|
||||||
|
mode: "fixed",
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeBrowserIndexState", () => {
|
||||||
|
it("defaults legacy persisted records to Responsive", () => {
|
||||||
|
const legacy = createBrowserRecord({
|
||||||
|
browserId: "b1",
|
||||||
|
initialUrl: "https://a.test",
|
||||||
|
now: 0,
|
||||||
|
}) as Partial<ReturnType<typeof createBrowserRecord>>;
|
||||||
|
delete legacy.viewport;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeBrowserIndexState({ browsersById: { b1: legacy } }).browsersById.b1?.viewport,
|
||||||
|
).toEqual({ mode: "responsive" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
export type BrowserViewport =
|
||||||
|
| { mode: "responsive" }
|
||||||
|
| { mode: "fixed"; width: number; height: number };
|
||||||
|
|
||||||
|
export const RESPONSIVE_BROWSER_VIEWPORT: BrowserViewport = { mode: "responsive" };
|
||||||
|
|
||||||
export interface BrowserRecord {
|
export interface BrowserRecord {
|
||||||
browserId: string;
|
browserId: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -7,6 +13,7 @@ export interface BrowserRecord {
|
|||||||
canGoForward: boolean;
|
canGoForward: boolean;
|
||||||
faviconUrl: string | null;
|
faviconUrl: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
|
viewport: BrowserViewport;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +23,62 @@ export interface BrowserIndexState {
|
|||||||
browsersById: Record<string, BrowserRecord>;
|
browsersById: Record<string, BrowserRecord>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createFixedBrowserViewport(width: number, height: number): BrowserViewport {
|
||||||
|
return {
|
||||||
|
mode: "fixed",
|
||||||
|
width: Math.max(1, Math.round(width)),
|
||||||
|
height: Math.max(1, Math.round(height)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBrowserViewport(value: unknown): BrowserViewport {
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
(value as { mode?: unknown }).mode === "fixed" &&
|
||||||
|
typeof (value as { width?: unknown }).width === "number" &&
|
||||||
|
typeof (value as { height?: unknown }).height === "number"
|
||||||
|
) {
|
||||||
|
return createFixedBrowserViewport(
|
||||||
|
(value as { width: number }).width,
|
||||||
|
(value as { height: number }).height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RESPONSIVE_BROWSER_VIEWPORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserViewportsEqual(left: BrowserViewport, right: BrowserViewport): boolean {
|
||||||
|
return (
|
||||||
|
left.mode === right.mode &&
|
||||||
|
(left.mode === "responsive" ||
|
||||||
|
(right.mode === "fixed" && left.width === right.width && left.height === right.height))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBrowserIndexState(value: unknown): BrowserIndexState {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return { browsersById: {} };
|
||||||
|
}
|
||||||
|
const browsersById = (value as { browsersById?: unknown }).browsersById;
|
||||||
|
if (!browsersById || typeof browsersById !== "object") {
|
||||||
|
return { browsersById: {} };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
browsersById: Object.fromEntries(
|
||||||
|
Object.entries(browsersById).map(([browserId, record]) => {
|
||||||
|
const browser = record as BrowserRecord & { viewport?: unknown };
|
||||||
|
return [
|
||||||
|
browserId,
|
||||||
|
{
|
||||||
|
...browser,
|
||||||
|
viewport: normalizeBrowserViewport(browser.viewport),
|
||||||
|
} satisfies BrowserRecord,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function trimNonEmpty(value: string | null | undefined): string | null {
|
export function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return null;
|
return null;
|
||||||
@@ -55,6 +118,7 @@ export function createBrowserRecord(input: {
|
|||||||
canGoForward: false,
|
canGoForward: false,
|
||||||
faviconUrl: null,
|
faviconUrl: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
viewport: RESPONSIVE_BROWSER_VIEWPORT,
|
||||||
createdAt: input.now,
|
createdAt: input.now,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -73,9 +137,13 @@ export function applyBrowserPatch<S extends BrowserIndexState>(
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextViewport = patch.viewport
|
||||||
|
? normalizeBrowserViewport(patch.viewport)
|
||||||
|
: existing.viewport;
|
||||||
const nextRecord: BrowserRecord = {
|
const nextRecord: BrowserRecord = {
|
||||||
...existing,
|
...existing,
|
||||||
...patch,
|
...patch,
|
||||||
|
viewport: nextViewport,
|
||||||
url: normalizeBrowserUrl(patch.url ?? existing.url),
|
url: normalizeBrowserUrl(patch.url ?? existing.url),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,7 +154,8 @@ export function applyBrowserPatch<S extends BrowserIndexState>(
|
|||||||
nextRecord.canGoBack === existing.canGoBack &&
|
nextRecord.canGoBack === existing.canGoBack &&
|
||||||
nextRecord.canGoForward === existing.canGoForward &&
|
nextRecord.canGoForward === existing.canGoForward &&
|
||||||
nextRecord.faviconUrl === existing.faviconUrl &&
|
nextRecord.faviconUrl === existing.faviconUrl &&
|
||||||
nextRecord.lastError === existing.lastError
|
nextRecord.lastError === existing.lastError &&
|
||||||
|
browserViewportsEqual(nextRecord.viewport, existing.viewport)
|
||||||
) {
|
) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -272,11 +272,68 @@ async function readGuest(page, browserId) {
|
|||||||
return {
|
return {
|
||||||
webContentsId: webview.getWebContentsId(),
|
webContentsId: webview.getWebContentsId(),
|
||||||
parentId: webview.parentElement?.id ?? null,
|
parentId: webview.parentElement?.id ?? null,
|
||||||
|
width: Math.round(webview.getBoundingClientRect().width),
|
||||||
|
height: Math.round(webview.getBoundingClientRect().height),
|
||||||
};
|
};
|
||||||
}, browserId);
|
}, browserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runRegression({ page, client, serverId, targetUrl }) {
|
async function readPresentation(page, browserId) {
|
||||||
|
return await page.evaluate((id) => {
|
||||||
|
const surface = document.querySelector(`[data-paseo-browser-surface="${id}"]`);
|
||||||
|
const clip = document.querySelector(`[data-testid="browser-webview-clip-${id}"]`);
|
||||||
|
if (!(surface instanceof HTMLElement) || !(clip instanceof HTMLElement)) return null;
|
||||||
|
const surfaceRect = surface.getBoundingClientRect();
|
||||||
|
const clipRect = clip.getBoundingClientRect();
|
||||||
|
const webview = surface.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||||||
|
if (!(webview instanceof HTMLElement)) return null;
|
||||||
|
const webviewRect = webview.getBoundingClientRect();
|
||||||
|
const outsidePoint = {
|
||||||
|
x: Math.max(0, Math.round(clipRect.left - 1)),
|
||||||
|
y: Math.round(clipRect.top + clipRect.height / 2),
|
||||||
|
};
|
||||||
|
const outsideTarget = document.elementFromPoint(outsidePoint.x, outsidePoint.y);
|
||||||
|
return {
|
||||||
|
surface: {
|
||||||
|
left: Math.round(surfaceRect.left),
|
||||||
|
top: Math.round(surfaceRect.top),
|
||||||
|
right: Math.round(surfaceRect.right),
|
||||||
|
bottom: Math.round(surfaceRect.bottom),
|
||||||
|
},
|
||||||
|
clip: {
|
||||||
|
left: Math.round(clipRect.left),
|
||||||
|
top: Math.round(clipRect.top),
|
||||||
|
right: Math.round(clipRect.right),
|
||||||
|
bottom: Math.round(clipRect.bottom),
|
||||||
|
},
|
||||||
|
webview: {
|
||||||
|
left: Math.round(webviewRect.left),
|
||||||
|
top: Math.round(webviewRect.top),
|
||||||
|
},
|
||||||
|
capturesOutsideInput: surface.contains(outsideTarget),
|
||||||
|
};
|
||||||
|
}, browserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readViewport(client, browserId) {
|
||||||
|
const evaluated = await callBrowserTool(client, "browser_evaluate", {
|
||||||
|
browserId,
|
||||||
|
function: "() => ({ width: window.innerWidth, height: window.innerHeight })",
|
||||||
|
});
|
||||||
|
return JSON.parse(evaluated.resultJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordViewportMismatch(failures, label, actual, expected) {
|
||||||
|
if (actual.width === expected.width && actual.height === expected.height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
failures.push(
|
||||||
|
`${label}: expected ${expected.width}x${expected.height}, received ${actual.width}x${actual.height}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRegression({ page, client, serverId, targetUrl, callerAgentId }) {
|
||||||
|
const failures = [];
|
||||||
const originalWorkspaceId = workspaceIds[0];
|
const originalWorkspaceId = workspaceIds[0];
|
||||||
const originalWorkspaceRow = page.getByTestId(
|
const originalWorkspaceRow = page.getByTestId(
|
||||||
`sidebar-workspace-row-${serverId}:${originalWorkspaceId}`,
|
`sidebar-workspace-row-${serverId}:${originalWorkspaceId}`,
|
||||||
@@ -300,6 +357,117 @@ async function runRegression({ page, client, serverId, targetUrl }) {
|
|||||||
);
|
);
|
||||||
const firstGuest = await readGuest(page, browserId);
|
const firstGuest = await readGuest(page, browserId);
|
||||||
assert(firstGuest, "Original browser guest was not attached to its workspace pane");
|
assert(firstGuest, "Original browser guest was not attached to its workspace pane");
|
||||||
|
recordViewportMismatch(
|
||||||
|
failures,
|
||||||
|
"Responsive viewport follows the visible browser pane",
|
||||||
|
await readViewport(client, browserId),
|
||||||
|
{ width: firstGuest.width, height: firstGuest.height },
|
||||||
|
);
|
||||||
|
|
||||||
|
await callBrowserTool(client, "browser_wait", {
|
||||||
|
browserId,
|
||||||
|
text: "Bridge target",
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
});
|
||||||
|
const requestedViewport = { width: 640, height: 480 };
|
||||||
|
await callBrowserTool(client, "browser_resize", { browserId, ...requestedViewport });
|
||||||
|
recordViewportMismatch(
|
||||||
|
failures,
|
||||||
|
"browser_resize updates the visible shared viewport",
|
||||||
|
await readViewport(client, browserId),
|
||||||
|
requestedViewport,
|
||||||
|
);
|
||||||
|
|
||||||
|
const oversizedViewport = { width: 2560, height: 1440 };
|
||||||
|
await callBrowserTool(client, "browser_resize", { browserId, ...oversizedViewport });
|
||||||
|
recordViewportMismatch(
|
||||||
|
failures,
|
||||||
|
"oversized preset preserves the requested guest viewport",
|
||||||
|
await readViewport(client, browserId),
|
||||||
|
oversizedViewport,
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
({ id, width, height }) => {
|
||||||
|
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||||||
|
return (
|
||||||
|
webview instanceof HTMLElement &&
|
||||||
|
Math.round(webview.getBoundingClientRect().width) === width &&
|
||||||
|
Math.round(webview.getBoundingClientRect().height) === height
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ id: browserId, ...oversizedViewport },
|
||||||
|
{ timeout: timeoutMs },
|
||||||
|
);
|
||||||
|
const presentation = await readPresentation(page, browserId);
|
||||||
|
assert(presentation, "Oversized browser presentation geometry was unavailable");
|
||||||
|
if (
|
||||||
|
presentation.surface.left < presentation.clip.left ||
|
||||||
|
presentation.surface.top < presentation.clip.top ||
|
||||||
|
presentation.surface.right > presentation.clip.right ||
|
||||||
|
presentation.surface.bottom > presentation.clip.bottom
|
||||||
|
) {
|
||||||
|
failures.push(
|
||||||
|
`oversized browser surface stays inside its pane: ${JSON.stringify(presentation)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (presentation.capturesOutsideInput) {
|
||||||
|
failures.push("oversized browser surface captures input outside its pane");
|
||||||
|
}
|
||||||
|
await callBrowserTool(client, "browser_resize", { browserId, ...oversizedViewport });
|
||||||
|
const repeatedResizePresentation = await readPresentation(page, browserId);
|
||||||
|
assert(repeatedResizePresentation, "Repeated resize presentation geometry was unavailable");
|
||||||
|
if (
|
||||||
|
repeatedResizePresentation.webview.left !== presentation.webview.left ||
|
||||||
|
repeatedResizePresentation.webview.top !== presentation.webview.top
|
||||||
|
) {
|
||||||
|
failures.push(
|
||||||
|
`repeating an oversized resize preserves the guest offset: before ${JSON.stringify(presentation.webview)}, after ${JSON.stringify(repeatedResizePresentation.webview)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await callBrowserTool(client, "browser_resize", { browserId, ...requestedViewport });
|
||||||
|
|
||||||
|
await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
try {
|
||||||
|
await callBrowserTool(client, "browser_screenshot", { browserId });
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(`inactive browser remains captureable: ${String(error)}`);
|
||||||
|
}
|
||||||
|
recordViewportMismatch(
|
||||||
|
failures,
|
||||||
|
"inactive browser preserves the shared viewport",
|
||||||
|
await readViewport(client, browserId),
|
||||||
|
requestedViewport,
|
||||||
|
);
|
||||||
|
const focusContinuitySentinel = "preserve-browser-document-across-focus";
|
||||||
|
await callBrowserTool(client, "browser_evaluate", {
|
||||||
|
browserId,
|
||||||
|
function: `() => { globalThis.__paseoFocusContinuity = ${JSON.stringify(focusContinuitySentinel)}; return globalThis.__paseoFocusContinuity; }`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await originalDeck.getByTestId(`workspace-tab-browser_${browserId}`).click();
|
||||||
|
await page.waitForFunction(
|
||||||
|
({ id, webContentsId }) => {
|
||||||
|
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||||||
|
return (
|
||||||
|
webview?.parentElement?.getAttribute("data-paseo-browser-surface") === id &&
|
||||||
|
webview.parentElement.style.pointerEvents === "auto" &&
|
||||||
|
webview.getWebContentsId() === webContentsId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ id: browserId, webContentsId: firstGuest.webContentsId },
|
||||||
|
{ timeout: timeoutMs },
|
||||||
|
);
|
||||||
|
const continuityResult = await callBrowserTool(client, "browser_evaluate", {
|
||||||
|
browserId,
|
||||||
|
function: "() => globalThis.__paseoFocusContinuity ?? null",
|
||||||
|
});
|
||||||
|
const continuityValue = JSON.parse(continuityResult.resultJson);
|
||||||
|
if (continuityValue !== focusContinuitySentinel) {
|
||||||
|
failures.push(
|
||||||
|
`focusing the browser tab preserves the current document: expected ${JSON.stringify(focusContinuitySentinel)}, received ${JSON.stringify(continuityValue)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const workspaceId of workspaceIds.slice(1)) {
|
for (const workspaceId of workspaceIds.slice(1)) {
|
||||||
await page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).click();
|
await page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).click();
|
||||||
@@ -312,21 +480,22 @@ async function runRegression({ page, client, serverId, targetUrl }) {
|
|||||||
({ id, previousWebContentsId }) => {
|
({ id, previousWebContentsId }) => {
|
||||||
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||||||
return (
|
return (
|
||||||
webview?.parentElement?.id === "paseo-browser-resident-webviews" &&
|
webview?.parentElement?.getAttribute("data-paseo-browser-surface") === id &&
|
||||||
|
webview.parentElement.style.width === "1px" &&
|
||||||
typeof webview.getWebContentsId === "function" &&
|
typeof webview.getWebContentsId === "function" &&
|
||||||
webview.getWebContentsId() !== previousWebContentsId
|
webview.getWebContentsId() === previousWebContentsId
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{ id: browserId, previousWebContentsId: firstGuest.webContentsId },
|
{ id: browserId, previousWebContentsId: firstGuest.webContentsId },
|
||||||
{ timeout: timeoutMs },
|
{ timeout: timeoutMs },
|
||||||
);
|
);
|
||||||
const replacementGuest = await readGuest(page, browserId);
|
const parkedGuest = await readGuest(page, browserId);
|
||||||
assert(replacementGuest, "Replacement browser guest was not parked after workspace eviction");
|
assert(parkedGuest, "Browser guest was not parked after workspace eviction");
|
||||||
|
|
||||||
const listed = await callBrowserTool(client, "browser_list_tabs");
|
const listed = await callBrowserTool(client, "browser_list_tabs");
|
||||||
assert(
|
assert(
|
||||||
listed.tabs.some((tab) => tab.browserId === browserId),
|
listed.tabs.some((tab) => tab.browserId === browserId),
|
||||||
"browser_list_tabs lost the original tab after guest replacement",
|
"browser_list_tabs lost the original tab after workspace eviction",
|
||||||
);
|
);
|
||||||
|
|
||||||
const snapshot = await callBrowserTool(client, "browser_snapshot", { browserId });
|
const snapshot = await callBrowserTool(client, "browser_snapshot", { browserId });
|
||||||
@@ -344,10 +513,47 @@ async function runRegression({ page, client, serverId, targetUrl }) {
|
|||||||
timeoutMs: 5_000,
|
timeoutMs: 5_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await originalWorkspaceRow.click();
|
||||||
|
await originalDeck.getByTestId(`workspace-tab-browser_${browserId}`).click();
|
||||||
|
await page.waitForFunction(
|
||||||
|
({ id, webContentsId }) => {
|
||||||
|
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||||||
|
return (
|
||||||
|
webview?.parentElement?.getAttribute("data-paseo-browser-surface") === id &&
|
||||||
|
webview.parentElement.style.pointerEvents === "auto" &&
|
||||||
|
webview.getWebContentsId() === webContentsId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ id: browserId, webContentsId: firstGuest.webContentsId },
|
||||||
|
{ timeout: timeoutMs },
|
||||||
|
);
|
||||||
|
recordViewportMismatch(
|
||||||
|
failures,
|
||||||
|
"browser viewport survives workspace eviction and reattachment",
|
||||||
|
await readViewport(client, browserId),
|
||||||
|
requestedViewport,
|
||||||
|
);
|
||||||
|
|
||||||
|
await originalDeck.getByRole("button", { name: "Annotate element" }).click();
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
const selectorResult = await callBrowserTool(client, "browser_evaluate", {
|
||||||
|
browserId,
|
||||||
|
function: "() => Boolean(globalThis.__paseoSelector)",
|
||||||
|
});
|
||||||
|
if (JSON.parse(selectorResult.resultJson) !== true) {
|
||||||
|
failures.push("reused loaded browser remains ready for element annotation after remount");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
throw new Error(`Browser viewport regressions:\n- ${failures.join("\n- ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
browserId,
|
browserId,
|
||||||
originalWebContentsId: firstGuest.webContentsId,
|
originalWebContentsId: firstGuest.webContentsId,
|
||||||
replacementWebContentsId: replacementGuest.webContentsId,
|
finalWebContentsId: parkedGuest.webContentsId,
|
||||||
|
viewport: "passed",
|
||||||
|
inactiveCapture: "passed",
|
||||||
list: "passed",
|
list: "passed",
|
||||||
snapshot: "passed",
|
snapshot: "passed",
|
||||||
click: "passed",
|
click: "passed",
|
||||||
@@ -446,10 +652,11 @@ async function main() {
|
|||||||
client,
|
client,
|
||||||
serverId: status.serverId,
|
serverId: status.serverId,
|
||||||
targetUrl: target.url,
|
targetUrl: target.url,
|
||||||
|
callerAgentId,
|
||||||
});
|
});
|
||||||
writeJson(path.join(artifactDir, "result.json"), report);
|
writeJson(path.join(artifactDir, "result.json"), report);
|
||||||
console.log(
|
console.log(
|
||||||
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} -> ${report.replacementWebContentsId}; list, snapshot, click passed.`,
|
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click passed.`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Browser desktop browser E2E failed. Artifacts: ${artifactDir}`);
|
console.error(`Browser desktop browser E2E failed. Artifacts: ${artifactDir}`);
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ class FakeDebugger {
|
|||||||
public blockCommands = false;
|
public blockCommands = false;
|
||||||
public readonly blockedCommandNames = new Set<string>();
|
public readonly blockedCommandNames = new Set<string>();
|
||||||
public readonly failedCommandNames = new Set<string>();
|
public readonly failedCommandNames = new Set<string>();
|
||||||
|
public readonly failedCommandErrors = new Map<string, Error>();
|
||||||
public readonly promptDialogs: unknown[] = [];
|
public readonly promptDialogs: unknown[] = [];
|
||||||
public failPromptDrain = false;
|
public failPromptDrain = false;
|
||||||
|
public beforeCommand: ((command: string) => void) | null = null;
|
||||||
private messageListener:
|
private messageListener:
|
||||||
| ((event: unknown, method: string, params?: Record<string, unknown>) => void)
|
| ((event: unknown, method: string, params?: Record<string, unknown>) => void)
|
||||||
| null = null;
|
| null = null;
|
||||||
|
private detachListener: (() => void) | null = null;
|
||||||
private readonly blockedCommands: Array<() => void> = [];
|
private readonly blockedCommands: Array<() => void> = [];
|
||||||
|
|
||||||
public isAttached(): boolean {
|
public isAttached(): boolean {
|
||||||
@@ -37,6 +40,11 @@ class FakeDebugger {
|
|||||||
|
|
||||||
public async sendCommand(command: string, params?: Record<string, unknown>): Promise<unknown> {
|
public async sendCommand(command: string, params?: Record<string, unknown>): Promise<unknown> {
|
||||||
this.commands.push({ command, params: params ?? {} });
|
this.commands.push({ command, params: params ?? {} });
|
||||||
|
this.beforeCommand?.(command);
|
||||||
|
const commandError = this.failedCommandErrors.get(command);
|
||||||
|
if (commandError) {
|
||||||
|
throw commandError;
|
||||||
|
}
|
||||||
if (this.failedCommandNames.has(command)) {
|
if (this.failedCommandNames.has(command)) {
|
||||||
throw new Error(`${command} failed`);
|
throw new Error(`${command} failed`);
|
||||||
}
|
}
|
||||||
@@ -58,10 +66,15 @@ class FakeDebugger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public on(
|
public on(
|
||||||
event: "message",
|
event: "message" | "detach",
|
||||||
listener: (event: unknown, method: string, params?: Record<string, unknown>) => void,
|
listener:
|
||||||
|
| ((event: unknown, method: string, params?: Record<string, unknown>) => void)
|
||||||
|
| (() => void),
|
||||||
): void {
|
): void {
|
||||||
expect(event).toBe("message");
|
if (event === "detach") {
|
||||||
|
this.detachListener = listener;
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.messageListener = listener;
|
this.messageListener = listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +85,11 @@ class FakeDebugger {
|
|||||||
this.messageListener({}, method, params);
|
this.messageListener({}, method, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public emitDetach(): void {
|
||||||
|
this.attachedProtocolVersions.length = 0;
|
||||||
|
this.detachListener?.();
|
||||||
|
}
|
||||||
|
|
||||||
public finishNextCommand(): void {
|
public finishNextCommand(): void {
|
||||||
const resolve = this.blockedCommands.shift();
|
const resolve = this.blockedCommands.shift();
|
||||||
if (!resolve) {
|
if (!resolve) {
|
||||||
@@ -92,6 +110,7 @@ type ConsoleMessageListener = (
|
|||||||
class FakeWebContents {
|
class FakeWebContents {
|
||||||
public readonly debugger = new FakeDebugger();
|
public readonly debugger = new FakeDebugger();
|
||||||
public readonly inputEvents: IsolatedKeyboardInputEvent[] = [];
|
public readonly inputEvents: IsolatedKeyboardInputEvent[] = [];
|
||||||
|
public readonly loadedUrls: string[] = [];
|
||||||
public readonly captures: Array<{
|
public readonly captures: Array<{
|
||||||
rect: Rectangle | undefined;
|
rect: Rectangle | undefined;
|
||||||
options: { stayHidden?: boolean } | undefined;
|
options: { stayHidden?: boolean } | undefined;
|
||||||
@@ -138,7 +157,9 @@ class FakeWebContents {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async loadURL(): Promise<void> {}
|
public async loadURL(url: string): Promise<void> {
|
||||||
|
this.loadedUrls.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
public goBack(): void {}
|
public goBack(): void {}
|
||||||
|
|
||||||
@@ -186,6 +207,7 @@ class FakeWebContents {
|
|||||||
|
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
|
this.debugger.emitDetach();
|
||||||
this.destroyedListener?.();
|
this.destroyedListener?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -571,6 +593,54 @@ describe("browser automation IPC adapter", () => {
|
|||||||
);
|
);
|
||||||
warn.mockRestore();
|
warn.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("does not run the command after the debugger target closes during setup", async () => {
|
||||||
|
const contents = new FakeWebContents(31);
|
||||||
|
contents.debugger.beforeCommand = (command) => {
|
||||||
|
if (command === "Page.enable") {
|
||||||
|
contents.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
contents.debugger.failedCommandErrors.set(
|
||||||
|
"Page.enable",
|
||||||
|
new Error("target closed while handling command"),
|
||||||
|
);
|
||||||
|
const tab = adaptWebContents(contents);
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
tab.captureDialogs?.(() => tab.loadURL("https://replacement.example.com")),
|
||||||
|
).rejects.toThrow("target closed while handling command");
|
||||||
|
expect(contents.loadedUrls).toEqual([]);
|
||||||
|
expect(warn).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runs without dialog capture after a live debugger session detaches", async () => {
|
||||||
|
const contents = new FakeWebContents(32);
|
||||||
|
const tab = adaptWebContents(contents);
|
||||||
|
|
||||||
|
await expect(tab.captureDialogs?.(async () => "captured")).resolves.toEqual({
|
||||||
|
result: "captured",
|
||||||
|
dialogs: [],
|
||||||
|
});
|
||||||
|
contents.debugger.emitDetach();
|
||||||
|
contents.debugger.failedCommandNames.add("Page.enable");
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
tab.captureDialogs?.(() => tab.loadURL("https://replacement.example.com")),
|
||||||
|
).resolves.toEqual({ result: undefined, dialogs: [] });
|
||||||
|
expect(contents.loadedUrls).toEqual(["https://replacement.example.com"]);
|
||||||
|
expect(contents.debugger.attachedProtocolVersions).toEqual(["1.3"]);
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
"[browser-automation] Dialog capture unavailable; running command without it",
|
||||||
|
{ contentsId: 32, error: expect.any(Error) },
|
||||||
|
);
|
||||||
|
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
class FakeHostWebContents {
|
class FakeHostWebContents {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ interface WebContentsDebugger {
|
|||||||
event: "message",
|
event: "message",
|
||||||
listener: (event: unknown, method: string, params?: Record<string, unknown>) => void,
|
listener: (event: unknown, method: string, params?: Record<string, unknown>) => void,
|
||||||
): void;
|
): void;
|
||||||
|
on?(event: "detach", listener: () => void): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConsoleMessageEmitter {
|
interface ConsoleMessageEmitter {
|
||||||
@@ -188,6 +189,7 @@ function getDialogMonitor(
|
|||||||
class DialogMonitor {
|
class DialogMonitor {
|
||||||
private enabled = false;
|
private enabled = false;
|
||||||
private listenerRegistered = false;
|
private listenerRegistered = false;
|
||||||
|
private detachGeneration = 0;
|
||||||
private readonly activeCollectors: DialogCollector[] = [];
|
private readonly activeCollectors: DialogCollector[] = [];
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@@ -200,10 +202,14 @@ class DialogMonitor {
|
|||||||
task: () => Promise<T>,
|
task: () => Promise<T>,
|
||||||
): Promise<{ result: T; dialogs: BrowserAutomationDialogEvent[] }> {
|
): Promise<{ result: T; dialogs: BrowserAutomationDialogEvent[] }> {
|
||||||
const collector: DialogCollector = { dialogs: [] };
|
const collector: DialogCollector = { dialogs: [] };
|
||||||
|
const setupDetachGeneration = this.detachGeneration;
|
||||||
try {
|
try {
|
||||||
await this.enable();
|
await this.enable();
|
||||||
await this.installPromptShim();
|
await this.installPromptShim();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (this.contents.isDestroyed() || this.detachGeneration !== setupDetachGeneration) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
console.warn("[browser-automation] Dialog capture unavailable; running command without it", {
|
console.warn("[browser-automation] Dialog capture unavailable; running command without it", {
|
||||||
contentsId: this.contentsId,
|
contentsId: this.contentsId,
|
||||||
error,
|
error,
|
||||||
@@ -244,6 +250,10 @@ class DialogMonitor {
|
|||||||
}
|
}
|
||||||
void this.handleOpening(params ?? {});
|
void this.handleOpening(params ?? {});
|
||||||
});
|
});
|
||||||
|
this.contents.debugger.on("detach", () => {
|
||||||
|
this.enabled = false;
|
||||||
|
this.detachGeneration += 1;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await this.sendDebugCommand("Page.enable");
|
await this.sendDebugCommand("Page.enable");
|
||||||
this.enabled = true;
|
this.enabled = true;
|
||||||
|
|||||||
@@ -57,14 +57,18 @@ function taskToolResult(options: { isError?: boolean; mentionAgentId?: boolean }
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sidechainEntry(): string {
|
function sidechainEntry(options: { agentId?: string; stopReason?: string | null } = {}): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
isSidechain: true,
|
isSidechain: true,
|
||||||
agentId: AGENT_ID,
|
agentId: options.agentId ?? AGENT_ID,
|
||||||
sessionId: "replay-session",
|
sessionId: "replay-session",
|
||||||
timestamp: "2026-07-26T06:27:50.000Z",
|
timestamp: "2026-07-26T06:27:50.000Z",
|
||||||
message: { role: "assistant", content: [{ type: "text", text: "summary of the docs" }] },
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "summary of the docs" }],
|
||||||
|
stop_reason: options.stopReason ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,24 +81,40 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
|
|||||||
let cwd: string;
|
let cwd: string;
|
||||||
let configDir: string;
|
let configDir: string;
|
||||||
|
|
||||||
|
function writeParentSession(parentLines: string[]): string {
|
||||||
|
const historyDir = claudeProjectDirSync(cwd, { configDir });
|
||||||
|
mkdirSync(historyDir, { recursive: true });
|
||||||
|
writeFileSync(path.join(historyDir, "replay-session.jsonl"), parentLines.join("\n"));
|
||||||
|
return path.join(historyDir, "replay-session", "subagents");
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSubagent(options: {
|
||||||
|
subagentDir: string;
|
||||||
|
agentId?: string;
|
||||||
|
meta?: string | null;
|
||||||
|
sidechainLines?: string[];
|
||||||
|
}): void {
|
||||||
|
const agentId = options.agentId ?? AGENT_ID;
|
||||||
|
mkdirSync(options.subagentDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
path.join(options.subagentDir, `agent-${agentId}.jsonl`),
|
||||||
|
(options.sidechainLines ?? [sidechainEntry({ agentId })]).join("\n"),
|
||||||
|
);
|
||||||
|
if (options.meta !== null && options.meta !== undefined) {
|
||||||
|
writeFileSync(path.join(options.subagentDir, `agent-${agentId}.meta.json`), options.meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function writeSession(options: {
|
function writeSession(options: {
|
||||||
parentLines: string[];
|
parentLines: string[];
|
||||||
meta?: string | null;
|
meta?: string | null;
|
||||||
sidechainLines?: string[];
|
sidechainLines?: string[];
|
||||||
}): void {
|
}): void {
|
||||||
const historyDir = claudeProjectDirSync(cwd, { configDir });
|
writeSubagent({
|
||||||
mkdirSync(historyDir, { recursive: true });
|
subagentDir: writeParentSession(options.parentLines),
|
||||||
writeFileSync(path.join(historyDir, "replay-session.jsonl"), options.parentLines.join("\n"));
|
meta: options.meta,
|
||||||
|
sidechainLines: options.sidechainLines,
|
||||||
const subagentDir = path.join(historyDir, "replay-session", "subagents");
|
});
|
||||||
mkdirSync(subagentDir, { recursive: true });
|
|
||||||
writeFileSync(
|
|
||||||
path.join(subagentDir, `agent-${AGENT_ID}.jsonl`),
|
|
||||||
(options.sidechainLines ?? [sidechainEntry()]).join("\n"),
|
|
||||||
);
|
|
||||||
if (options.meta !== null && options.meta !== undefined) {
|
|
||||||
writeFileSync(path.join(subagentDir, `agent-${AGENT_ID}.meta.json`), options.meta);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replayDescriptors(): Promise<
|
async function replayDescriptors(): Promise<
|
||||||
@@ -198,6 +218,69 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
|
|||||||
expect(statuses).toContain("running");
|
expect(statuses).toContain("running");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("finishes a Task subagent from its own end_turn when the parent outcome is missing", async () => {
|
||||||
|
writeSession({
|
||||||
|
parentLines: [taskToolUse()],
|
||||||
|
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||||
|
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
|
||||||
|
id: TOOL_USE_ID,
|
||||||
|
status: "completed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not replay a skill-spawned subagent the parent never named", async () => {
|
||||||
|
// The reported bug. A /code-review subagent has no Task tool_use, so its sidecar carries only
|
||||||
|
// agentType and its id appears nowhere in the parent — it used to replay as running forever,
|
||||||
|
// and every reopen resurrected it.
|
||||||
|
writeSession({
|
||||||
|
parentLines: [parentEntry([{ type: "text", text: "running /code-review" }])],
|
||||||
|
meta: JSON.stringify({ agentType: "general-purpose" }),
|
||||||
|
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(upserts(await replayDescriptors())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not accumulate internal workflow agents as replay-only running rows", async () => {
|
||||||
|
const subagentRoot = writeParentSession([
|
||||||
|
parentEntry([
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "toolu_workflow",
|
||||||
|
name: "Workflow",
|
||||||
|
input: { script: "await Promise.all([agent('one'), agent('two'), agent('three')])" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
const workflowDir = path.join(subagentRoot, "workflows", "wf_c240e728-6ea");
|
||||||
|
|
||||||
|
for (const agentId of ["a17a341a0902f5799", "ae2d01ba2dfa46032", "abc9f20546c326c53"]) {
|
||||||
|
writeSubagent({
|
||||||
|
subagentDir: workflowDir,
|
||||||
|
agentId,
|
||||||
|
meta: JSON.stringify({ agentType: "workflow-subagent", spawnDepth: 1 }),
|
||||||
|
sidechainLines: [sidechainEntry({ agentId, stopReason: "end_turn" })],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(upserts(await replayDescriptors())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not replay a subagent whose toolUseId names no Task call in this transcript", async () => {
|
||||||
|
// A grandchild recorded before spawnDepth existed: the Task call it names was made inside a
|
||||||
|
// sibling's session, so no tool_result for it can ever reach this parent.
|
||||||
|
writeSession({
|
||||||
|
parentLines: [parentEntry([{ type: "text", text: "no task calls here" }])],
|
||||||
|
meta: JSON.stringify({ toolUseId: TOOL_USE_ID, agentType: "Explore" }),
|
||||||
|
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(upserts(await replayDescriptors())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
["truncated json", '{"toolUseId":"toolu_01DgLoPMW9"'],
|
["truncated json", '{"toolUseId":"toolu_01DgLoPMW9"'],
|
||||||
["not json", "toolUseId: toolu"],
|
["not json", "toolUseId: toolu"],
|
||||||
|
|||||||
+120
-13
@@ -99,7 +99,7 @@ describe("observeReplaySubagents", () => {
|
|||||||
// Claude Code writes every descendant into the same subagents/ directory. A grandchild's
|
// Claude Code writes every descendant into the same subagents/ directory. A grandchild's
|
||||||
// toolUseId names a Task call made inside its parent's session, so nothing here can resolve
|
// toolUseId names a Task call made inside its parent's session, so nothing here can resolve
|
||||||
// it: it would replay as an extra row the live stream never showed, with no Task card and no
|
// it: it would replay as an extra row the live stream never showed, with no Task card and no
|
||||||
// outcome, running forever. One recorded session showed 10 subagents live and 22 on reopen.
|
// outcome. One recorded session showed 10 subagents live and 22 on reopen.
|
||||||
const observations = observeReplaySubagents({
|
const observations = observeReplaySubagents({
|
||||||
subagents: [
|
subagents: [
|
||||||
{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, entries: [] },
|
{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, entries: [] },
|
||||||
@@ -126,8 +126,7 @@ describe("observeReplaySubagents", () => {
|
|||||||
expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID });
|
expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recovers identity from meta when the parent's Task call is missing", () => {
|
it("drops a meta-linked subagent when the parent never declared its Task", () => {
|
||||||
// The case the legacy scrape cannot handle: no tool_result, so no link and no identity.
|
|
||||||
const observations = observeReplaySubagents({
|
const observations = observeReplaySubagents({
|
||||||
subagents: [
|
subagents: [
|
||||||
{
|
{
|
||||||
@@ -140,12 +139,7 @@ describe("observeReplaySubagents", () => {
|
|||||||
convertEntry: () => [],
|
convertEntry: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(observations[0]).toMatchObject({
|
expect(observations).toEqual([]);
|
||||||
id: TOOL_USE_ID,
|
|
||||||
toolCallId: TOOL_USE_ID,
|
|
||||||
title: "Explore",
|
|
||||||
description: "Find the code",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the scraped link when there is no meta file", () => {
|
it("falls back to the scraped link when there is no meta file", () => {
|
||||||
@@ -165,14 +159,127 @@ describe("observeReplaySubagents", () => {
|
|||||||
expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" });
|
expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps an unlinkable subagent visible under its own id", () => {
|
it("drops an unlinkable subagent", () => {
|
||||||
const [declared] = observeReplaySubagents({
|
const observations = observeReplaySubagents({
|
||||||
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
|
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
|
||||||
parent: emptyParent(),
|
parent: emptyParent(),
|
||||||
convertEntry: () => [],
|
convertEntry: () => [],
|
||||||
});
|
});
|
||||||
expect(declared).toMatchObject({ id: AGENT_ID });
|
expect(observations).toEqual([]);
|
||||||
expect(declared).not.toHaveProperty("toolCallId");
|
});
|
||||||
|
|
||||||
|
it("drops a terminal subagent the parent has no Task call for", () => {
|
||||||
|
// A legacy grandchild: its toolUseId names a Task call made inside a sibling's transcript, so
|
||||||
|
// no tool_result for it can ever reach the parent. Left running, it never stops.
|
||||||
|
const descriptor = applyToStore(
|
||||||
|
observeReplaySubagents({
|
||||||
|
subagents: [
|
||||||
|
{
|
||||||
|
agentId: AGENT_ID,
|
||||||
|
meta: { toolUseId: TOOL_USE_ID },
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
timestamp: "2026-07-31T03:41:00.000Z",
|
||||||
|
message: { stop_reason: "end_turn" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
parent: emptyParent(),
|
||||||
|
convertEntry: () => [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(descriptor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a terminal skill-spawned subagent that has no link at all", () => {
|
||||||
|
// A /code-review run: no Task tool_use exists to name it, so its sidecar carries only
|
||||||
|
// agentType and its id appears nowhere in the parent transcript.
|
||||||
|
const descriptor = applyToStore(
|
||||||
|
observeReplaySubagents({
|
||||||
|
subagents: [
|
||||||
|
{
|
||||||
|
agentId: AGENT_ID,
|
||||||
|
meta: { agentType: "general-purpose" },
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
timestamp: "2026-07-31T03:41:00.000Z",
|
||||||
|
message: { stop_reason: "end_turn" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
parent: emptyParent(),
|
||||||
|
convertEntry: () => [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(descriptor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a scraped link's failure as failed", () => {
|
||||||
|
const descriptor = applyToStore(
|
||||||
|
observeReplaySubagents({
|
||||||
|
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
|
||||||
|
parent: {
|
||||||
|
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
|
||||||
|
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]),
|
||||||
|
outcomesByToolCallId: new Map(),
|
||||||
|
},
|
||||||
|
convertEntry: () => [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a non-terminal linkless subagent", () => {
|
||||||
|
const observations = observeReplaySubagents({
|
||||||
|
subagents: [
|
||||||
|
{
|
||||||
|
agentId: AGENT_ID,
|
||||||
|
meta: { agentType: "general-purpose" },
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
timestamp: "2026-07-31T03:41:00.000Z",
|
||||||
|
message: { stop_reason: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
parent: emptyParent(),
|
||||||
|
convertEntry: () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(observations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finishes a terminal Task child when the parent has not recorded its outcome", () => {
|
||||||
|
const descriptor = applyToStore(
|
||||||
|
observeReplaySubagents({
|
||||||
|
subagents: [
|
||||||
|
{
|
||||||
|
agentId: AGENT_ID,
|
||||||
|
meta: { toolUseId: TOOL_USE_ID },
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
timestamp: "2026-07-31T03:41:00.000Z",
|
||||||
|
message: { stop_reason: "end_turn" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
|
||||||
|
convertEntry: () => [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(descriptor).toMatchObject({ status: "completed" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves a subagent with no recorded outcome running", () => {
|
it("leaves a subagent with no recorded outcome running", () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import type { AgentTimelineItem } from "../../../agent-sdk-types.js";
|
import type { AgentTimelineItem } from "../../../agent-sdk-types.js";
|
||||||
import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js";
|
import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js";
|
||||||
|
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
|
||||||
import { resolveObservedClaudeModelId } from "../models.js";
|
import { resolveObservedClaudeModelId } from "../models.js";
|
||||||
import type { SubagentObservation } from "./observation.js";
|
import type { SubagentObservation } from "./observation.js";
|
||||||
import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js";
|
import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js";
|
||||||
@@ -152,51 +153,81 @@ export interface ClaudeReplayParentFacts {
|
|||||||
outcomesByToolCallId: ReadonlyMap<string, { failed: boolean }>;
|
outcomesByToolCallId: ReadonlyMap<string, { failed: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolvedLink {
|
interface ParentLink {
|
||||||
id: string;
|
id: string;
|
||||||
toolCallId: string | null;
|
toolCallId: string;
|
||||||
failed: boolean | null;
|
/** Terminal status recorded by the parent, or null when its Task has no outcome yet. */
|
||||||
|
status: ProviderSubagentStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the canonical subagent id.
|
* Resolve a child only when the parent transcript declares the corresponding Task.
|
||||||
*
|
*
|
||||||
* `meta.toolUseId` is authoritative: it is the same id the live stream keys on. The legacy path
|
* `meta.toolUseId` is authoritative for identity — it is the same id the live stream keys on. The
|
||||||
* scrapes `agentId:` out of stringified tool-result content, which both misses the link when the
|
* legacy path scrapes `agentId:` out of stringified tool-result content, which both misses the link
|
||||||
* parent's tool_result is absent and matches unrelated text — on a real five-subagent session it
|
* when the parent's tool_result is absent and matches unrelated text — on a real five-subagent
|
||||||
* produced eight ids, inventing `z`, `string`, and `update`. It stays only as a fallback for
|
* session it produced eight ids, inventing `z`, `string`, and `update`. It stays only as a fallback
|
||||||
* sessions recorded before the meta file existed.
|
* for sessions recorded before the meta file existed.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* meta.toolUseId matches a parent Task?
|
||||||
|
* ├─ yes → use it and the parent's outcome, when present
|
||||||
|
* └─ no → a legacy scraped agentId link matches a parent Task?
|
||||||
|
* ├─ yes → use it and its recorded outcome
|
||||||
|
* └─ no → drop it; live never declared this child either
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Claude writes grandchildren and skill-owned sidechains beside direct children. Admitting those
|
||||||
|
* without a parent Task creates replay-only rows with no identity or lifecycle source. Filtering
|
||||||
|
* here keeps live and replay on the same declaration boundary.
|
||||||
*/
|
*/
|
||||||
function resolveLink(
|
function resolveParentLink(
|
||||||
subagent: ClaudeReplaySubagentInput,
|
subagent: ClaudeReplaySubagentInput,
|
||||||
parent: ClaudeReplayParentFacts,
|
parent: ClaudeReplayParentFacts,
|
||||||
): ResolvedLink {
|
): ParentLink | null {
|
||||||
const metaToolUseId = subagent.meta?.toolUseId?.trim();
|
const metaToolUseId = subagent.meta?.toolUseId?.trim();
|
||||||
if (metaToolUseId) {
|
if (metaToolUseId && parent.toolCalls.has(metaToolUseId)) {
|
||||||
const outcome = parent.outcomesByToolCallId.get(metaToolUseId);
|
const outcome = parent.outcomesByToolCallId.get(metaToolUseId);
|
||||||
|
let status: ProviderSubagentStatus | null = null;
|
||||||
|
if (outcome) status = outcome.failed ? "failed" : "completed";
|
||||||
return {
|
return {
|
||||||
id: metaToolUseId,
|
id: metaToolUseId,
|
||||||
toolCallId: metaToolUseId,
|
toolCallId: metaToolUseId,
|
||||||
failed: outcome ? outcome.failed : null,
|
status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const scraped = parent.linksByAgentId.get(subagent.agentId);
|
const scraped = parent.linksByAgentId.get(subagent.agentId);
|
||||||
if (scraped) {
|
if (scraped && parent.toolCalls.has(scraped.toolCallId)) {
|
||||||
return { id: scraped.toolCallId, toolCallId: scraped.toolCallId, failed: scraped.failed };
|
return {
|
||||||
|
id: scraped.toolCallId,
|
||||||
|
toolCallId: scraped.toolCallId,
|
||||||
|
status: scraped.failed ? "failed" : "completed",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// No link at all: keep the subagent visible under its own agent id rather than dropping it.
|
return null;
|
||||||
return { id: subagent.agentId, toolCallId: null, failed: null };
|
}
|
||||||
|
|
||||||
|
/** A final `end_turn` is the child's own completion signal when the parent result is absent. */
|
||||||
|
function readChildTerminalStatus(
|
||||||
|
entries: readonly ClaudeReplayEntry[],
|
||||||
|
): ProviderSubagentStatus | null {
|
||||||
|
for (let index = entries.length - 1; index >= 0; index--) {
|
||||||
|
const entry = entries[index];
|
||||||
|
if (entry?.type !== "assistant") continue;
|
||||||
|
return entry.message?.stop_reason === "end_turn" ? "completed" : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The parent's tool input is the same source the live path reads, so it wins; meta.json fills the
|
* The parent's tool input is the same identity source the live path reads; meta.json contributes
|
||||||
* gap when the parent's Task call is missing from the transcript.
|
* provider details only after that declaration has admitted the child.
|
||||||
*/
|
*/
|
||||||
function declareSubagent(
|
function declareSubagent(
|
||||||
subagent: ClaudeReplaySubagentInput,
|
subagent: ClaudeReplaySubagentInput,
|
||||||
link: ResolvedLink,
|
link: ParentLink,
|
||||||
toolCall: { title?: string; description?: string } | undefined,
|
toolCall: { title?: string; description?: string } | undefined,
|
||||||
): SubagentObservation {
|
): SubagentObservation {
|
||||||
const title = toolCall?.title ?? subagent.meta?.agentType;
|
const title = toolCall?.title ?? subagent.meta?.agentType;
|
||||||
@@ -214,7 +245,7 @@ function declareSubagent(
|
|||||||
|
|
||||||
function observeSubtitle(
|
function observeSubtitle(
|
||||||
subagent: ClaudeReplaySubagentInput,
|
subagent: ClaudeReplaySubagentInput,
|
||||||
link: ResolvedLink,
|
link: ParentLink,
|
||||||
title: string | undefined,
|
title: string | undefined,
|
||||||
): SubagentObservation | null {
|
): SubagentObservation | null {
|
||||||
const runtime = readRuntime(subagent.entries);
|
const runtime = readRuntime(subagent.entries);
|
||||||
@@ -244,8 +275,9 @@ function observeSubagent(
|
|||||||
parent: ClaudeReplayParentFacts,
|
parent: ClaudeReplayParentFacts,
|
||||||
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[],
|
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[],
|
||||||
): SubagentObservation[] {
|
): SubagentObservation[] {
|
||||||
const link = resolveLink(subagent, parent);
|
const link = resolveParentLink(subagent, parent);
|
||||||
const toolCall = link.toolCallId ? parent.toolCalls.get(link.toolCallId) : undefined;
|
if (!link) return [];
|
||||||
|
const toolCall = parent.toolCalls.get(link.toolCallId);
|
||||||
|
|
||||||
const observations: SubagentObservation[] = [declareSubagent(subagent, link, toolCall)];
|
const observations: SubagentObservation[] = [declareSubagent(subagent, link, toolCall)];
|
||||||
const subtitle = observeSubtitle(subagent, link, toolCall?.title ?? subagent.meta?.agentType);
|
const subtitle = observeSubtitle(subagent, link, toolCall?.title ?? subagent.meta?.agentType);
|
||||||
@@ -263,14 +295,15 @@ function observeSubagent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A transcript with no recorded outcome is a subagent that never finished — leave it running
|
// Prefer the parent's result because it carries failure. If that result is missing, the child's
|
||||||
// rather than inventing a completion.
|
// final end_turn still proves completion. Any other shape remains running.
|
||||||
if (link.failed !== null) {
|
const terminalStatus = link.status ?? readChildTerminalStatus(subagent.entries);
|
||||||
|
if (terminalStatus) {
|
||||||
const lastTimestamp = normalizeProviderReplayTimestamp(subagent.entries.at(-1)?.timestamp);
|
const lastTimestamp = normalizeProviderReplayTimestamp(subagent.entries.at(-1)?.timestamp);
|
||||||
observations.push({
|
observations.push({
|
||||||
kind: "status",
|
kind: "status",
|
||||||
id: link.id,
|
id: link.id,
|
||||||
status: link.failed ? "failed" : "completed",
|
status: terminalStatus,
|
||||||
...(lastTimestamp ? { timestamp: lastTimestamp } : {}),
|
...(lastTimestamp ? { timestamp: lastTimestamp } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -286,9 +319,8 @@ function observeSubagent(
|
|||||||
* another subagent. A grandchild's `toolUseId` names a Task call made inside its parent's session,
|
* another subagent. A grandchild's `toolUseId` names a Task call made inside its parent's session,
|
||||||
* so nothing in this transcript can resolve it — surveyed across recorded sessions, every depth-1
|
* so nothing in this transcript can resolve it — surveyed across recorded sessions, every depth-1
|
||||||
* id matched a Task tool_use block in the parent transcript and no deeper id did. Replaying them
|
* id matched a Task tool_use block in the parent transcript and no deeper id did. Replaying them
|
||||||
* anyway adds rows the live stream never showed, each with no Task card and no recoverable
|
* anyway adds rows the live stream never showed, each with no Task card and no recoverable outcome.
|
||||||
* outcome, which leaves them running forever. One session recorded 10 subagents live and would
|
* One session recorded 10 subagents live and would replay 22.
|
||||||
* replay 22.
|
|
||||||
*
|
*
|
||||||
* Absent depth means a session recorded before the field existed: keep the subagent rather than
|
* Absent depth means a session recorded before the field existed: keep the subagent rather than
|
||||||
* lose it, matching how every other missing field here is treated.
|
* lose it, matching how every other missing field here is treated.
|
||||||
|
|||||||
Reference in New Issue
Block a user