| author | |
| committer | |
| log | 12e8997e58dbca8f1bd8c63b67d662eb69cf0e0d |
| tree | 6182307e53a8d98364046ea754ba70bb33ac7003 |
| parent | c726c30a148c2add6a3ec7f31f54ae48dc5d2f0c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>19 files changed, 228 insertions(+), 38 deletions(-)
apps/web/src/browser/BrowserSurfaceSlot.tsx+7-3| ... | ... | @@ -8,6 +8,7 @@ export function BrowserSurfaceSlot(props: { |
| 8 | 8 | readonly tabId: string; |
| 9 | 9 | readonly visible: boolean; |
| 10 | 10 | readonly cornerRadius?: number; |
| 11 | readonly zIndex?: number; | |
| 11 | 12 | readonly layoutVersion?: string | number; |
| 12 | 13 | readonly className?: string; |
| 13 | 14 | readonly fitSourceContent?: boolean; |
| ... | ... | @@ -16,12 +17,13 @@ export function BrowserSurfaceSlot(props: { |
| 16 | 17 | tabId, |
| 17 | 18 | visible, |
| 18 | 19 | cornerRadius = 0, |
| 20 | zIndex = 30, | |
| 19 | 21 | layoutVersion, |
| 20 | 22 | className, |
| 21 | 23 | fitSourceContent = false, |
| 22 | 24 | } = props; |
| 23 | 25 | const elementRef = useRef<HTMLDivElement | null>(null); |
| 24 | const presentationRef = useRef({ visible, cornerRadius }); | |
| 26 | const presentationRef = useRef({ visible, cornerRadius, zIndex }); | |
| 25 | 27 | const updateRef = useRef<(() => void) | null>(null); |
| 26 | 28 | |
| 27 | 29 | useLayoutEffect(() => { |
| ... | ... | @@ -40,6 +42,7 @@ export function BrowserSurfaceSlot(props: { |
| 40 | 42 | }, |
| 41 | 43 | presentation.visible && rect.width > 0 && rect.height > 0, |
| 42 | 44 | presentation.cornerRadius, |
| 45 | presentation.zIndex, | |
| 43 | 46 | ); |
| 44 | 47 | if (presentation.visible && !presented) { |
| 45 | 48 | lease.release(); |
| ... | ... | @@ -53,6 +56,7 @@ export function BrowserSurfaceSlot(props: { |
| 53 | 56 | }, |
| 54 | 57 | rect.width > 0 && rect.height > 0, |
| 55 | 58 | presentation.cornerRadius, |
| 59 | presentation.zIndex, | |
| 56 | 60 | ); |
| 57 | 61 | } |
| 58 | 62 | }; |
| ... | ... | @@ -72,9 +76,9 @@ export function BrowserSurfaceSlot(props: { |
| 72 | 76 | }, [fitSourceContent, tabId]); |
| 73 | 77 | |
| 74 | 78 | useLayoutEffect(() => { |
| 75 | presentationRef.current = { visible, cornerRadius }; | |
| 79 | presentationRef.current = { visible, cornerRadius, zIndex }; | |
| 76 | 80 | updateRef.current?.(); |
| 77 | }, [cornerRadius, layoutVersion, visible]); | |
| 81 | }, [cornerRadius, layoutVersion, visible, zIndex]); | |
| 78 | 82 | |
| 79 | 83 | return <div ref={elementRef} className={className} data-browser-surface-slot={tabId} />; |
| 80 | 84 | } |
apps/web/src/browser/HostedBrowserWebview.tsx+2| ... | ... | @@ -83,6 +83,7 @@ export function HostedBrowserWebview(props: { |
| 83 | 83 | fittedSourceContent: current?.fittedSourceContent ?? null, |
| 84 | 84 | rect: resolveBrowserSurfacePanelRect(state.byTabId, runtimeTabId), |
| 85 | 85 | visible: current?.visible ?? false, |
| 86 | zIndex: current?.zIndex ?? 30, | |
| 86 | 87 | }; |
| 87 | 88 | }), |
| 88 | 89 | ); |
| ... | ... | @@ -259,6 +260,7 @@ export function HostedBrowserWebview(props: { |
| 259 | 260 | // suspend them, and automation continues to see the macOS guests as inactive. |
| 260 | 261 | keepPaintableWhenInactive: isMacPlatform(navigator.platform), |
| 261 | 262 | cornerRadius: presentation.cornerRadius, |
| 263 | zIndex: presentation.zIndex, | |
| 262 | 264 | rect: lastRect, |
| 263 | 265 | hiddenSize, |
| 264 | 266 | }); |
apps/web/src/browser/browserSurfaceStore.test.ts+13| ... | ... | @@ -107,6 +107,7 @@ describe("browserSurfaceStore", () => { |
| 107 | 107 | hidden: { |
| 108 | 108 | rect: staleRect, |
| 109 | 109 | visible: false, |
| 110 | zIndex: 30, | |
| 110 | 111 | content: null, |
| 111 | 112 | fittedSourceContent: null, |
| 112 | 113 | fitSourceContent: false, |
| ... | ... | @@ -117,6 +118,7 @@ describe("browserSurfaceStore", () => { |
| 117 | 118 | active: { |
| 118 | 119 | rect: liveRect, |
| 119 | 120 | visible: true, |
| 121 | zIndex: 30, | |
| 120 | 122 | content: null, |
| 121 | 123 | fittedSourceContent: null, |
| 122 | 124 | fitSourceContent: false, |
| ... | ... | @@ -162,6 +164,17 @@ describe("browserSurfaceStore", () => { |
| 162 | 164 | }); |
| 163 | 165 | }); |
| 164 | 166 | |
| 167 | it("keeps the requested layer with the active surface lease", () => { | |
| 168 | const tabId = "layered-browser-surface"; | |
| 169 | const lease = acquireBrowserSurface(tabId); | |
| 170 | lease.present({ x: 10, y: 20, width: 320, height: 200 }, true, 12, 48); | |
| 171 | ||
| 172 | expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ | |
| 173 | visible: true, | |
| 174 | zIndex: 48, | |
| 175 | }); | |
| 176 | }); | |
| 177 | ||
| 165 | 178 | it("clears fitted presentation state when its lease is released", () => { |
| 166 | 179 | const tabId = "released-fitted-browser-surface"; |
| 167 | 180 | const fittedLease = acquireBrowserSurface(tabId, true); |
apps/web/src/browser/browserSurfaceStore.ts+15-5| ... | ... | @@ -10,6 +10,7 @@ export interface BrowserSurfaceRect { |
| 10 | 10 | export interface BrowserSurfacePresentation { |
| 11 | 11 | readonly rect: BrowserSurfaceRect | null; |
| 12 | 12 | readonly visible: boolean; |
| 13 | readonly zIndex: number; | |
| 13 | 14 | readonly content: BrowserSurfaceContentPresentation | null; |
| 14 | 15 | readonly fittedSourceContent: BrowserSurfaceContentPresentation | null; |
| 15 | 16 | readonly fitSourceContent: boolean; |
| ... | ... | @@ -39,13 +40,19 @@ interface BrowserSurfaceStoreState { |
| 39 | 40 | rect: BrowserSurfaceRect, |
| 40 | 41 | visible: boolean, |
| 41 | 42 | cornerRadius: number, |
| 43 | zIndex: number, | |
| 42 | 44 | ) => void; |
| 43 | 45 | readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; |
| 44 | 46 | readonly release: (tabId: string, owner: symbol) => void; |
| 45 | 47 | } |
| 46 | 48 | |
| 47 | 49 | export interface BrowserSurfaceLease { |
| 48 | readonly present: (rect: BrowserSurfaceRect, visible: boolean, cornerRadius?: number) => boolean; | |
| 50 | readonly present: ( | |
| 51 | rect: BrowserSurfaceRect, | |
| 52 | visible: boolean, | |
| 53 | cornerRadius?: number, | |
| 54 | zIndex?: number, | |
| 55 | ) => boolean; | |
| 49 | 56 | readonly release: () => void; |
| 50 | 57 | } |
| 51 | 58 | |
| ... | ... | @@ -97,6 +104,7 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 97 | 104 | [tabId]: { |
| 98 | 105 | rect: current?.rect ?? null, |
| 99 | 106 | visible: false, |
| 107 | zIndex: current?.zIndex ?? 30, | |
| 100 | 108 | content: current?.content ?? null, |
| 101 | 109 | fittedSourceContent: fitSourceContent ? (current?.content ?? null) : null, |
| 102 | 110 | fitSourceContent, |
| ... | ... | @@ -107,7 +115,7 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 107 | 115 | }, |
| 108 | 116 | }; |
| 109 | 117 | }), |
| 110 | present: (tabId, owner, rect, visible, cornerRadius) => | |
| 118 | present: (tabId, owner, rect, visible, cornerRadius, zIndex) => | |
| 111 | 119 | set((state) => { |
| 112 | 120 | const current = state.byTabId[tabId]; |
| 113 | 121 | if (current?.owner !== owner) return state; |
| ... | ... | @@ -115,6 +123,7 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 115 | 123 | current && |
| 116 | 124 | current.visible === visible && |
| 117 | 125 | current.cornerRadius === cornerRadius && |
| 126 | current.zIndex === zIndex && | |
| 118 | 127 | rectEquals(current.rect, rect) |
| 119 | 128 | ) { |
| 120 | 129 | return state; |
| ... | ... | @@ -122,7 +131,7 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 122 | 131 | return { |
| 123 | 132 | byTabId: { |
| 124 | 133 | ...state.byTabId, |
| 125 | [tabId]: { ...current, rect, visible, cornerRadius, updatedAt: Date.now() }, | |
| 134 | [tabId]: { ...current, rect, visible, cornerRadius, zIndex, updatedAt: Date.now() }, | |
| 126 | 135 | }, |
| 127 | 136 | }; |
| 128 | 137 | }), |
| ... | ... | @@ -136,6 +145,7 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 136 | 145 | [tabId]: { |
| 137 | 146 | rect: null, |
| 138 | 147 | visible: false, |
| 148 | zIndex: 30, | |
| 139 | 149 | content, |
| 140 | 150 | fittedSourceContent: null, |
| 141 | 151 | fitSourceContent: false, |
| ... | ... | @@ -206,10 +216,10 @@ export function acquireBrowserSurface( |
| 206 | 216 | useBrowserSurfaceStore.getState().claim(tabId, owner, fitSourceContent); |
| 207 | 217 | |
| 208 | 218 | return { |
| 209 | present: (rect, visible, cornerRadius = 0) => { | |
| 219 | present: (rect, visible, cornerRadius = 0, zIndex = 30) => { | |
| 210 | 220 | if (released) return false; |
| 211 | 221 | if (useBrowserSurfaceStore.getState().byTabId[tabId]?.owner !== owner) return false; |
| 212 | useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius); | |
| 222 | useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius, zIndex); | |
| 213 | 223 | return true; |
| 214 | 224 | }, |
| 215 | 225 | release: () => { |
apps/web/src/browser/hostedBrowserWebviewStyle.test.ts+2| ... | ... | @@ -30,6 +30,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { |
| 30 | 30 | active: true, |
| 31 | 31 | renderingActive: true, |
| 32 | 32 | cornerRadius: 12, |
| 33 | zIndex: 48, | |
| 33 | 34 | rect: { x: 12, y: 34, width: 360, height: 203 }, |
| 34 | 35 | hiddenSize: { width: 1280, height: 800 }, |
| 35 | 36 | }), |
| ... | ... | @@ -39,6 +40,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { |
| 39 | 40 | width: 360, |
| 40 | 41 | height: 203, |
| 41 | 42 | borderRadius: 12, |
| 43 | zIndex: 48, | |
| 42 | 44 | }); |
| 43 | 45 | }); |
| 44 | 46 |
apps/web/src/browser/hostedBrowserWebviewStyle.ts+3-1| ... | ... | @@ -23,6 +23,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { |
| 23 | 23 | readonly renderingActive: boolean; |
| 24 | 24 | readonly keepPaintableWhenInactive?: boolean; |
| 25 | 25 | readonly cornerRadius?: number; |
| 26 | readonly zIndex?: number; | |
| 26 | 27 | readonly rect: BrowserSurfaceRect | null; |
| 27 | 28 | readonly hiddenSize: HostedBrowserWebviewSize; |
| 28 | 29 | }): HostedBrowserWebviewWrapperStyle { |
| ... | ... | @@ -33,6 +34,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { |
| 33 | 34 | keepPaintableWhenInactive = false, |
| 34 | 35 | rect, |
| 35 | 36 | renderingActive, |
| 37 | zIndex = 30, | |
| 36 | 38 | } = input; |
| 37 | 39 | if (active && rect) { |
| 38 | 40 | return { |
| ... | ... | @@ -40,7 +42,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { |
| 40 | 42 | top: rect.y, |
| 41 | 43 | width: rect.width, |
| 42 | 44 | height: rect.height, |
| 43 | zIndex: 30, | |
| 45 | zIndex, | |
| 44 | 46 | pointerEvents: "auto", |
| 45 | 47 | ...(cornerRadius > 0 ? { borderRadius: cornerRadius } : {}), |
| 46 | 48 | }; |
apps/web/src/components/ChatView.logic.test.ts+22| ... | ... | @@ -50,6 +50,7 @@ import { |
| 50 | 50 | shouldReleaseTimelineAnchorForToolActivity, |
| 51 | 51 | shouldOpenProactivePullRequest, |
| 52 | 52 | shouldOpenProactiveTurnDiff, |
| 53 | shouldRenderPreviewMiniPlayer, | |
| 53 | 54 | shouldShowBranchMismatchBanner, |
| 54 | 55 | shouldShowPlanFollowUpPrompt, |
| 55 | 56 | shouldWriteThreadErrorToCurrentServerThread, |
| ... | ... | @@ -93,6 +94,27 @@ describe("agent browser close confirmation", () => { |
| 93 | 94 | }); |
| 94 | 95 | }); |
| 95 | 96 | |
| 97 | describe("floating browser preview", () => { | |
| 98 | it("only hides the duplicate while the same browser is rendered in the panel", () => { | |
| 99 | expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false); | |
| 100 | expect( | |
| 101 | shouldRenderPreviewMiniPlayer("tab-1", { | |
| 102 | id: "browser:one", | |
| 103 | kind: "preview", | |
| 104 | resourceId: "tab-1", | |
| 105 | }), | |
| 106 | ).toBe(false); | |
| 107 | expect( | |
| 108 | shouldRenderPreviewMiniPlayer("tab-1", { | |
| 109 | id: "browser:two", | |
| 110 | kind: "preview", | |
| 111 | resourceId: "tab-2", | |
| 112 | }), | |
| 113 | ).toBe(true); | |
| 114 | expect(shouldRenderPreviewMiniPlayer("tab-1", { id: "diff", kind: "diff" })).toBe(true); | |
| 115 | }); | |
| 116 | }); | |
| 117 | ||
| 96 | 118 | describe("proactive panels", () => { |
| 97 | 119 | it("opens a pull request only after a newly observed link appears", () => { |
| 98 | 120 | expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(false); |
apps/web/src/components/ChatView.logic.ts+13| ... | ... | @@ -86,6 +86,19 @@ export function agentControlledBrowserCloseConfirmation( |
| 86 | 86 | ].join("\n"); |
| 87 | 87 | } |
| 88 | 88 | |
| 89 | export function shouldRenderPreviewMiniPlayer( | |
| 90 | miniPlayerTabId: string | null, | |
| 91 | renderedRightPanelSurface: RightPanelSurface | null, | |
| 92 | ): boolean { | |
| 93 | return ( | |
| 94 | miniPlayerTabId !== null && | |
| 95 | !( | |
| 96 | renderedRightPanelSurface?.kind === "preview" && | |
| 97 | renderedRightPanelSurface.resourceId === miniPlayerTabId | |
| 98 | ) | |
| 99 | ); | |
| 100 | } | |
| 101 | ||
| 89 | 102 | export function shouldOpenProactivePullRequest( |
| 90 | 103 | previousTargetKey: string | null | undefined, |
| 91 | 104 | targetKey: string | null, |
apps/web/src/components/ChatView.tsx+9-13| ... | ... | @@ -366,6 +366,7 @@ import { |
| 366 | 366 | shouldShowPlanFollowUpPrompt, |
| 367 | 367 | shouldOpenProactivePullRequest, |
| 368 | 368 | shouldOpenProactiveTurnDiff, |
| 369 | shouldRenderPreviewMiniPlayer, | |
| 369 | 370 | getStartedThreadModelChangeBlockReason, |
| 370 | 371 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| 371 | 372 | LastInvokedScriptByProjectSchema, |
| ... | ... | @@ -1847,6 +1848,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 1847 | 1848 | rightPanelPresent && (!shouldUseRightPanelSheet || rightPanelOpen); |
| 1848 | 1849 | const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; |
| 1849 | 1850 | const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; |
| 1851 | const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( | |
| 1852 | activePreviewMiniPlayer?.tabId ?? null, | |
| 1853 | renderedRightPanelSurface, | |
| 1854 | ); | |
| 1850 | 1855 | const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; |
| 1851 | 1856 | const rightPanelMaximized = |
| 1852 | 1857 | canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; |
| ... | ... | @@ -1862,20 +1867,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 1862 | 1867 | useEffect(() => { |
| 1863 | 1868 | if (!activeThreadRef || !activePreviewMiniPlayer) return; |
| 1864 | 1869 | const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]); |
| 1865 | const sameTabOpenInPanel = | |
| 1866 | previewPanelOpen && | |
| 1867 | activeRightPanelSurface?.kind === "preview" && | |
| 1868 | activeRightPanelSurface.resourceId === activePreviewMiniPlayer.tabId; | |
| 1869 | if (!miniTabStillExists || sameTabOpenInPanel) { | |
| 1870 | if (!miniTabStillExists) { | |
| 1870 | 1871 | usePreviewMiniPlayerStore.getState().close(activeThreadRef); |
| 1871 | 1872 | } |
| 1872 | }, [ | |
| 1873 | activePreviewMiniPlayer, | |
| 1874 | activePreviewState.sessions, | |
| 1875 | activeRightPanelSurface, | |
| 1876 | activeThreadRef, | |
| 1877 | previewPanelOpen, | |
| 1878 | ]); | |
| 1873 | }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef]); | |
| 1879 | 1874 | |
| 1880 | 1875 | const existingOpenTerminalThreadKeys = useMemo(() => { |
| 1881 | 1876 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); |
| ... | ... | @@ -7919,7 +7914,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7919 | 7914 | </div> |
| 7920 | 7915 | </div> |
| 7921 | 7916 | |
| 7922 | {activeThreadRef && activePreviewMiniPlayer ? ( | |
| 7917 | {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? ( | |
| 7923 | 7918 | <ThreadPreviewMiniPlayer |
| 7924 | 7919 | key={`${activeThreadKey}:${activePreviewMiniPlayer.tabId}`} |
| 7925 | 7920 | threadRef={activeThreadRef} |
| ... | ... | @@ -8040,6 +8035,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 8040 | 8035 | <RightPanelSheet |
| 8041 | 8036 | animationDurationMs={panelAnimationsActive ? panelAnimationDurationMs : 0} |
| 8042 | 8037 | open={rightPanelOpen} |
| 8038 | underFloatingPreview={previewMiniPlayerVisible} | |
| 8043 | 8039 | onClose={closePreviewPanel} |
| 8044 | 8040 | > |
| 8045 | 8041 | <RightPanelTabs |
apps/web/src/components/RightPanelSheet.tsx+11-1| ... | ... | @@ -1,12 +1,16 @@ |
| 1 | 1 | import { type ReactNode } from "react"; |
| 2 | 2 | |
| 3 | import { RIGHT_PANEL_SHEET_CLASS_NAME } from "../rightPanelLayout"; | |
| 3 | import { | |
| 4 | RIGHT_PANEL_SHEET_CLASS_NAME, | |
| 5 | RIGHT_PANEL_SHEET_LAYER_CLASS_NAME, | |
| 6 | } from "../rightPanelLayout"; | |
| 4 | 7 | import { Sheet, SheetPopup } from "./ui/sheet"; |
| 5 | 8 | |
| 6 | 9 | export function RightPanelSheet(props: { |
| 7 | 10 | animationDurationMs: number; |
| 8 | 11 | children: ReactNode; |
| 9 | 12 | open: boolean; |
| 13 | underFloatingPreview?: boolean; | |
| 10 | 14 | onClose: () => void; |
| 11 | 15 | }) { |
| 12 | 16 | return ( |
| ... | ... | @@ -23,6 +27,12 @@ export function RightPanelSheet(props: { |
| 23 | 27 | side="right" |
| 24 | 28 | showCloseButton={false} |
| 25 | 29 | keepMounted |
| 30 | {...(props.underFloatingPreview | |
| 31 | ? { | |
| 32 | backdropClassName: RIGHT_PANEL_SHEET_LAYER_CLASS_NAME, | |
| 33 | viewportClassName: RIGHT_PANEL_SHEET_LAYER_CLASS_NAME, | |
| 34 | } | |
| 35 | : {})} | |
| 26 | 36 | className={RIGHT_PANEL_SHEET_CLASS_NAME} |
| 27 | 37 | > |
| 28 | 38 | {props.children} |
apps/web/src/components/preview/PreviewAutomationHosts.tsx+46-2| ... | ... | @@ -20,7 +20,7 @@ import { |
| 20 | 20 | type ScopedThreadRef, |
| 21 | 21 | } from "@t3tools/contracts"; |
| 22 | 22 | import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; |
| 23 | import { useCallback, useContext, useEffect, useMemo, useState } from "react"; | |
| 23 | import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; | |
| 24 | 24 | import { Atom } from "effect/unstable/reactivity"; |
| 25 | 25 | |
| 26 | 26 | import { |
| ... | ... | @@ -29,7 +29,7 @@ import { |
| 29 | 29 | reconcilePreviewServerSessions, |
| 30 | 30 | updatePreviewServerSnapshot, |
| 31 | 31 | } from "~/previewStateStore"; |
| 32 | import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; | |
| 32 | import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; | |
| 33 | 33 | import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; |
| 34 | 34 | import { |
| 35 | 35 | readActiveBrowserRecordingTargets, |
| ... | ... | @@ -59,8 +59,10 @@ import { |
| 59 | 59 | PreviewAutomationViewportTimeoutError, |
| 60 | 60 | } from "./previewAutomationErrors"; |
| 61 | 61 | import { |
| 62 | explicitlySuppressesPreviewMiniPlayer, | |
| 62 | 63 | previewAutomationDefaultViewport, |
| 63 | 64 | previewAutomationOpenNeedsOverlay, |
| 65 | shouldAutoShowPreviewForAutomationUse, | |
| 64 | 66 | shouldOpenPreviewMiniPlayer, |
| 65 | 67 | } from "./previewAutomationOpenReadiness"; |
| 66 | 68 | import { |
| ... | ... | @@ -311,6 +313,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 311 | 313 | ); |
| 312 | 314 | const [automationConnectionAtom] = useState(() => Atom.make<string | null>(null)); |
| 313 | 315 | const automationConnectionId = useAtomValue(automationConnectionAtom); |
| 316 | const presentationSuppressedRuntimeTabsRef = useRef(new Map<string, Set<string>>()); | |
| 314 | 317 | |
| 315 | 318 | const handleRequest = useCallback( |
| 316 | 319 | async (request: PreviewAutomationRequest): Promise<unknown> => { |
| ... | ... | @@ -353,6 +356,21 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 353 | 356 | } |
| 354 | 357 | const readyState = readThreadPreviewState(threadRef); |
| 355 | 358 | const runtimeTabId = previewRuntimeTabId(threadRef, readyState.serverEpoch, readyTabId); |
| 359 | if (request.operation !== "open") { | |
| 360 | const { autoShowFloatingPreview } = await resolveBrowserDefaults(); | |
| 361 | if ( | |
| 362 | shouldAutoShowPreviewForAutomationUse({ | |
| 363 | operation: request.operation, | |
| 364 | autoShowFloatingPreview, | |
| 365 | presentationSuppressed: | |
| 366 | presentationSuppressedRuntimeTabsRef.current | |
| 367 | .get(request.threadId) | |
| 368 | ?.has(runtimeTabId) ?? false, | |
| 369 | }) | |
| 370 | ) { | |
| 371 | usePreviewMiniPlayerStore.getState().open(threadRef, readyTabId); | |
| 372 | } | |
| 373 | } | |
| 356 | 374 | browserActivity.release ??= acquireBrowserSurfaceActivity(runtimeTabId); |
| 357 | 375 | await waitForDesktopOverlay( |
| 358 | 376 | threadRef, |
| ... | ... | @@ -450,6 +468,32 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 450 | 468 | input, |
| 451 | 469 | (await resolveBrowserDefaults()).autoShowFloatingPreview, |
| 452 | 470 | ); |
| 471 | const explicitlySuppressed = explicitlySuppressesPreviewMiniPlayer(input); | |
| 472 | const suppressedTabs = presentationSuppressedRuntimeTabsRef.current.get( | |
| 473 | request.threadId, | |
| 474 | ); | |
| 475 | if (explicitlySuppressed) { | |
| 476 | if (suppressedTabs) { | |
| 477 | suppressedTabs.add(activeRuntimeTabId); | |
| 478 | } else { | |
| 479 | presentationSuppressedRuntimeTabsRef.current.set( | |
| 480 | request.threadId, | |
| 481 | new Set([activeRuntimeTabId]), | |
| 482 | ); | |
| 483 | } | |
| 484 | const miniPlayer = selectThreadPreviewMiniPlayer( | |
| 485 | usePreviewMiniPlayerStore.getState().byThreadKey, | |
| 486 | threadRef, | |
| 487 | ); | |
| 488 | if (miniPlayer?.tabId === activeTabId) { | |
| 489 | usePreviewMiniPlayerStore.getState().close(threadRef); | |
| 490 | } | |
| 491 | } else if (shouldPresentPreview) { | |
| 492 | suppressedTabs?.delete(activeRuntimeTabId); | |
| 493 | if (suppressedTabs?.size === 0) { | |
| 494 | presentationSuppressedRuntimeTabsRef.current.delete(request.threadId); | |
| 495 | } | |
| 496 | } | |
| 453 | 497 | if (shouldPresentPreview) { |
| 454 | 498 | usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId); |
| 455 | 499 | } |
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+7-5| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | clampPreviewMiniPlayerSize, |
| 20 | 20 | PREVIEW_MINI_PLAYER_DEFAULT_SIZE, |
| 21 | 21 | PREVIEW_MINI_PLAYER_EDGE_GAP, |
| 22 | PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, | |
| 22 | 23 | } from "./previewMiniPlayerLayout"; |
| 23 | 24 | |
| 24 | 25 | interface DragState { |
| ... | ... | @@ -243,7 +244,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props |
| 243 | 244 | } |
| 244 | 245 | } |
| 245 | 246 | > |
| 246 | <div className="group pointer-events-auto absolute right-2 top-2 z-[34] size-3"> | |
| 247 | <div className="group pointer-events-auto absolute right-2 top-2 z-[49] size-3"> | |
| 247 | 248 | <div |
| 248 | 249 | aria-hidden="true" |
| 249 | 250 | className="absolute right-0 top-0 size-2 rounded-full bg-foreground/25 shadow-sm ring-1 ring-background/70 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0" |
| ... | ... | @@ -316,11 +317,12 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props |
| 316 | 317 | </div> |
| 317 | 318 | |
| 318 | 319 | <div className="relative h-full min-h-0"> |
| 319 | <div className="absolute inset-0 z-[29] rounded-xl bg-muted shadow-2xl/35" /> | |
| 320 | <div className="absolute inset-0 z-[47] rounded-xl bg-muted shadow-2xl/35" /> | |
| 320 | 321 | <BrowserSurfaceSlot |
| 321 | 322 | tabId={runtimeTabId} |
| 322 | 323 | visible={Boolean(desktopOverlay?.hasWebContents)} |
| 323 | 324 | cornerRadius={12} |
| 325 | zIndex={PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX} | |
| 324 | 326 | fitSourceContent |
| 325 | 327 | layoutVersion={ |
| 326 | 328 | position |
| ... | ... | @@ -329,16 +331,16 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props |
| 329 | 331 | } |
| 330 | 332 | className="absolute inset-0" |
| 331 | 333 | /> |
| 332 | <div className="pointer-events-none absolute inset-0 z-[31] rounded-xl ring-1 ring-inset ring-border/80" /> | |
| 334 | <div className="pointer-events-none absolute inset-0 z-[49] rounded-xl ring-1 ring-inset ring-border/80" /> | |
| 333 | 335 | {!desktopOverlay?.hasWebContents ? ( |
| 334 | <div className="pointer-events-none absolute inset-0 z-[32] flex items-center justify-center rounded-xl bg-muted text-xs text-muted-foreground"> | |
| 336 | <div className="pointer-events-none absolute inset-0 z-[49] flex items-center justify-center rounded-xl bg-muted text-xs text-muted-foreground"> | |
| 335 | 337 | Reconnecting preview… |
| 336 | 338 | </div> |
| 337 | 339 | ) : null} |
| 338 | 340 | <button |
| 339 | 341 | type="button" |
| 340 | 342 | aria-label="Resize floating preview" |
| 341 | className="pointer-events-auto absolute bottom-0 right-0 z-[33] size-5 cursor-nwse-resize rounded-br-xl after:absolute after:bottom-1 after:right-1 after:size-2 after:border-b after:border-r after:border-foreground/45" | |
| 343 | className="pointer-events-auto absolute bottom-0 right-0 z-[49] size-5 cursor-nwse-resize rounded-br-xl after:absolute after:bottom-1 after:right-1 after:size-2 after:border-b after:border-r after:border-foreground/45" | |
| 342 | 344 | onPointerDown={handleResizePointerDown} |
| 343 | 345 | onPointerMove={handleResizePointerMove} |
| 344 | 346 | onPointerUp={endResize} |
apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts+47| ... | ... | @@ -3,8 +3,10 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | DEFAULT_PREVIEW_AUTOMATION_VIEWPORT, |
| 6 | explicitlySuppressesPreviewMiniPlayer, | |
| 6 | 7 | previewAutomationDefaultViewport, |
| 7 | 8 | previewAutomationOpenNeedsOverlay, |
| 9 | shouldAutoShowPreviewForAutomationUse, | |
| 8 | 10 | shouldOpenPreviewMiniPlayer, |
| 9 | 11 | } from "./previewAutomationOpenReadiness"; |
| 10 | 12 | |
| ... | ... | @@ -91,4 +93,49 @@ describe("shouldOpenPreviewMiniPlayer with the floating-preview preference", () |
| 91 | 93 | expect(shouldOpenPreviewMiniPlayer({ open: false }, true)).toBe(false); |
| 92 | 94 | expect(shouldOpenPreviewMiniPlayer({ show: true }, false)).toBe(true); |
| 93 | 95 | }); |
| 96 | ||
| 97 | it("distinguishes an explicit background request from a disabled preference", () => { | |
| 98 | expect(explicitlySuppressesPreviewMiniPlayer({ open: false })).toBe(true); | |
| 99 | expect(explicitlySuppressesPreviewMiniPlayer({ show: false })).toBe(true); | |
| 100 | expect(explicitlySuppressesPreviewMiniPlayer({})).toBe(false); | |
| 101 | }); | |
| 102 | }); | |
| 103 | ||
| 104 | describe("auto-show for existing automation tabs", () => { | |
| 105 | it("records floating-preview intent whenever an agent uses the tab", () => { | |
| 106 | expect( | |
| 107 | shouldAutoShowPreviewForAutomationUse({ | |
| 108 | operation: "navigate", | |
| 109 | autoShowFloatingPreview: true, | |
| 110 | presentationSuppressed: false, | |
| 111 | }), | |
| 112 | ).toBe(true); | |
| 113 | }); | |
| 114 | ||
| 115 | it("leaves explicit opens and background-only tabs alone", () => { | |
| 116 | expect( | |
| 117 | shouldAutoShowPreviewForAutomationUse({ | |
| 118 | operation: "open", | |
| 119 | autoShowFloatingPreview: true, | |
| 120 | presentationSuppressed: false, | |
| 121 | }), | |
| 122 | ).toBe(false); | |
| 123 | expect( | |
| 124 | shouldAutoShowPreviewForAutomationUse({ | |
| 125 | operation: "click", | |
| 126 | autoShowFloatingPreview: true, | |
| 127 | presentationSuppressed: true, | |
| 128 | }), | |
| 129 | ).toBe(false); | |
| 130 | }); | |
| 131 | ||
| 132 | it("honours the auto-show preference for reused tabs", () => { | |
| 133 | expect( | |
| 134 | shouldAutoShowPreviewForAutomationUse({ | |
| 135 | operation: "snapshot", | |
| 136 | autoShowFloatingPreview: false, | |
| 137 | presentationSuppressed: false, | |
| 138 | }), | |
| 139 | ).toBe(false); | |
| 140 | }); | |
| 94 | 141 | }); |
apps/web/src/components/preview/previewAutomationOpenReadiness.ts+15| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | FILL_PREVIEW_VIEWPORT, |
| 3 | type PreviewAutomationOperation, | |
| 3 | 4 | type PreviewAutomationOpenInput, |
| 4 | 5 | type PreviewSessionSnapshot, |
| 5 | 6 | type PreviewViewportSetting, |
| ... | ... | @@ -29,6 +30,20 @@ export function shouldOpenPreviewMiniPlayer( |
| 29 | 30 | return input.open ?? input.show ?? autoShowFloatingPreview; |
| 30 | 31 | } |
| 31 | 32 | |
| 33 | export function shouldAutoShowPreviewForAutomationUse(input: { | |
| 34 | readonly operation: PreviewAutomationOperation; | |
| 35 | readonly autoShowFloatingPreview: boolean; | |
| 36 | readonly presentationSuppressed: boolean; | |
| 37 | }): boolean { | |
| 38 | return ( | |
| 39 | input.operation !== "open" && input.autoShowFloatingPreview && !input.presentationSuppressed | |
| 40 | ); | |
| 41 | } | |
| 42 | ||
| 43 | export function explicitlySuppressesPreviewMiniPlayer(input: PreviewAutomationOpenInput): boolean { | |
| 44 | return (input.open ?? input.show) === false; | |
| 45 | } | |
| 46 | ||
| 32 | 47 | export function previewAutomationOpenNeedsOverlay( |
| 33 | 48 | input: PreviewAutomationOpenInput, |
| 34 | 49 | snapshot: PreviewSessionSnapshot, |
apps/web/src/components/preview/previewMiniPlayerLayout.ts+2| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; |
| 2 | 2 | |
| 3 | 3 | export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12; |
| 4 | // The mini-player shell straddles this webview at 47 and 49; dialogs begin at 50. | |
| 5 | export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48; | |
| 4 | 6 | export const PREVIEW_MINI_PLAYER_DEFAULT_SIZE = { width: 320, height: 200 } as const; |
| 5 | 7 | export const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; |
| 6 | 8 |
apps/web/src/components/settings/IntegrationsSettings.tsx+1-1| ... | ... | @@ -560,7 +560,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled |
| 560 | 560 | return ( |
| 561 | 561 | <SettingsRow |
| 562 | 562 | {...searchableSetting("browser-auto-show-floating-preview")} |
| 563 | description="Pop the floating preview into view when an agent opens a browser. An agent that explicitly asks to show or hide its preview still gets what it asked for." | |
| 563 | description="Pop the floating preview into view when an agent uses a browser. An agent that explicitly asks to show or hide its preview still gets what it asked for." | |
| 564 | 564 | resetAction={ |
| 565 | 565 | !disabled && autoShow !== DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW ? ( |
| 566 | 566 | <SettingResetButton |
apps/web/src/components/ui/sheet.tsx+10-6| ... | ... | @@ -64,6 +64,8 @@ function SheetPopup({ |
| 64 | 64 | showCloseButton = true, |
| 65 | 65 | keepMounted = false, |
| 66 | 66 | transitionDurationMs, |
| 67 | backdropClassName, | |
| 68 | viewportClassName, | |
| 67 | 69 | side = "right", |
| 68 | 70 | variant = "default", |
| 69 | 71 | style, |
| ... | ... | @@ -72,6 +74,8 @@ function SheetPopup({ |
| 72 | 74 | showCloseButton?: boolean; |
| 73 | 75 | keepMounted?: boolean; |
| 74 | 76 | transitionDurationMs?: number; |
| 77 | backdropClassName?: string; | |
| 78 | viewportClassName?: string; | |
| 75 | 79 | side?: "right" | "left" | "top" | "bottom"; |
| 76 | 80 | variant?: "default" | "inset"; |
| 77 | 81 | }) { |
| ... | ... | @@ -84,14 +88,14 @@ function SheetPopup({ |
| 84 | 88 | return ( |
| 85 | 89 | <SheetPortal keepMounted={keepMounted}> |
| 86 | 90 | <SheetBackdrop |
| 87 | className={ | |
| 88 | instant | |
| 89 | ? "transition-none! data-ending-style:opacity-100! data-starting-style:opacity-100!" | |
| 90 | : undefined | |
| 91 | } | |
| 91 | className={cn( | |
| 92 | instant && | |
| 93 | "transition-none! data-ending-style:opacity-100! data-starting-style:opacity-100!", | |
| 94 | backdropClassName, | |
| 95 | )} | |
| 92 | 96 | style={transitionStyle} |
| 93 | 97 | /> |
| 94 | <SheetViewport side={side} variant={variant}> | |
| 98 | <SheetViewport className={viewportClassName} side={side} variant={variant}> | |
| 95 | 99 | <SheetPrimitive.Popup |
| 96 | 100 | className={cn( |
| 97 | 101 | "relative flex max-h-full min-h-0 w-full min-w-0 flex-col bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 transition-[opacity,translate] duration-200 ease-in-out will-change-transform before:pointer-events-none before:absolute before:inset-0 before:shadow-[0_1px_--theme(--color-black/4%)] data-ending-style:opacity-0 data-starting-style:opacity-0 max-sm:before:hidden dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", |
apps/web/src/rightPanelLayout.ts+2| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | 1 | export const RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 980px)"; |
| 2 | // Applied only while a floating preview overlaps the compact sheet. | |
| 3 | export const RIGHT_PANEL_SHEET_LAYER_CLASS_NAME = "z-[35]"; | |
| 2 | 4 | export const RIGHT_PANEL_SHEET_CLASS_NAME = |
| 3 | 5 | "w-[min(42vw,28rem)] min-w-80 max-w-[28rem] p-0 max-[760px]:w-[min(88vw,24rem)] max-[760px]:min-w-0 wco:mt-[env(titlebar-area-height)] wco:h-[calc(100%-env(titlebar-area-height))] wco:max-h-[calc(100%-env(titlebar-area-height))]"; |
packages/contracts/src/settings.ts+1-1| ... | ... | @@ -226,7 +226,7 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 226 | 226 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_LINK_TARGET)), |
| 227 | 227 | ), |
| 228 | 228 | /** |
| 229 | * Whether an agent opening a preview pops the floating mini player into | |
| 229 | * Whether an agent using a preview pops the floating mini player into | |
| 230 | 230 | * view. Only applies when the agent didn't ask either way — an explicit |
| 231 | 231 | * `open`/`show` on `preview_open` still wins, since that is the agent |
| 232 | 232 | * deliberately showing or hiding its work. |