From d699cacf11899eb7700fdb22d83db40ba16c2fdb Mon Sep 17 00:00:00 2001 From: JamesOBrien2 Date: Sat, 24 Oct 2026 17:01:27 +0100 Subject: [PATCH 1/6] fix(web): keep preview closed after dismissing PiP --- .../components/chat/closePreviewPanel.test.ts | 57 +++++++++++++++++++ .../src/components/chat/closePreviewPanel.ts | 15 +++++ .../components/chat/useRightPanelActions.ts | 21 +------ .../preview/PreviewView.browser.test.tsx | 27 ++++++++- 4 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/components/chat/closePreviewPanel.test.ts create mode 100644 apps/web/src/components/chat/closePreviewPanel.ts diff --git a/apps/web/src/components/chat/closePreviewPanel.test.ts b/apps/web/src/components/chat/closePreviewPanel.test.ts new file mode 100644 index 0000000000000..57459dc4c4ad9 --- /dev/null +++ b/apps/web/src/components/chat/closePreviewPanel.test.ts @@ -0,0 +1,57 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { + browserMiniPlayerSource, + selectThreadPreviewMiniPlayerTabId, + usePreviewMiniPlayerStore, +} from "../../previewMiniPlayerStore"; +import { useRightPanelStore } from "../../rightPanelStore"; + +import { closePreviewPanel } from "./closePreviewPanel"; + +const threadRef = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), +} as const; + +beforeEach(() => { + usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); + useRightPanelStore.setState({ + byThreadKey: {}, + threadPanelVisibilityByThreadKey: {}, + userActionRevisionByThreadKey: {}, + closeRevisionByThreadKey: {}, + }); +}); + +describe("closePreviewPanel", () => { + it("does not resurrect a floating preview the user already turned off", () => { + usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource("tab-1")); + useRightPanelStore.getState().openBrowser(threadRef, "tab-1"); + usePreviewMiniPlayerStore.getState().close(threadRef); + + closePreviewPanel(threadRef); + + expect( + selectThreadPreviewMiniPlayerTabId( + usePreviewMiniPlayerStore.getState().byThreadKey, + threadRef, + ), + ).toBeNull(); + }); + + it("keeps an existing floating preview across a temporary dock into the panel", () => { + usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource("tab-1")); + useRightPanelStore.getState().openBrowser(threadRef, "tab-1"); + + closePreviewPanel(threadRef); + + expect( + selectThreadPreviewMiniPlayerTabId( + usePreviewMiniPlayerStore.getState().byThreadKey, + threadRef, + ), + ).toBe("tab-1"); + }); +}); diff --git a/apps/web/src/components/chat/closePreviewPanel.ts b/apps/web/src/components/chat/closePreviewPanel.ts new file mode 100644 index 0000000000000..c1a43852c2219 --- /dev/null +++ b/apps/web/src/components/chat/closePreviewPanel.ts @@ -0,0 +1,15 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { useRightPanelStore } from "../../rightPanelStore"; + +/** + * Dismiss the right panel without inventing a floating preview. + * + * The mini-player store is the source of truth: a float that was already open + * stays across a temporary dock into the panel, and clearing it via + * "Close floating preview" must still stick when the panel closes afterward. + */ +export function closePreviewPanel(threadRef: ScopedThreadRef): void { + useRightPanelStore.getState().setMaximized(threadRef, false); + useRightPanelStore.getState().close(threadRef); +} diff --git a/apps/web/src/components/chat/useRightPanelActions.ts b/apps/web/src/components/chat/useRightPanelActions.ts index be4c9dd9cbfd5..46bd686c5984f 100644 --- a/apps/web/src/components/chat/useRightPanelActions.ts +++ b/apps/web/src/components/chat/useRightPanelActions.ts @@ -23,12 +23,11 @@ import { BrowserSettingsReadError } from "../../browser/openFileInPreview"; import { addBrowserSurface } from "../preview/addBrowserSurface"; import { focusActivePanelTerminal, focusTerminal } from "../../lib/terminalFocus"; import { subscribePreviewAction } from "../preview/previewActionBus"; -import { floatDeviceSurface } from "../preview/agentOpenedPreviews"; -import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore"; import type { threadPullRequestPanelTarget } from "../pullRequest/pullRequestDetail.logic"; import type { useDeviceState } from "../../state/device"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { openDeviceSetupDialog } from "./ChatOverlayHosts"; +import { closePreviewPanel as dismissPreviewPanel } from "./closePreviewPanel"; import { previewEnvironment } from "../../state/preview"; import { useAtomCommand } from "../../state/use-atom-command"; import type { ChatShortcutCommands } from "./useChatShortcuts"; @@ -38,7 +37,6 @@ interface RightPanelActionsInput { readonly activeRightPanelSurface: RightPanelSurface | null; readonly activePreviewState: ThreadPreviewState; readonly deviceState: ReturnType["state"]; - readonly deviceStateLoaded: boolean; readonly pullRequestPanelTarget: ReturnType | null; readonly threadPanelPresentation: ThreadPanelPresentation; readonly isServerThread: boolean; @@ -71,7 +69,6 @@ export function useRightPanelActions(input: RightPanelActionsInput) { activeRightPanelSurface, activePreviewState, deviceState, - deviceStateLoaded, pullRequestPanelTarget, threadPanelPresentation, isServerThread, @@ -161,21 +158,7 @@ export function useRightPanelActions(input: RightPanelActionsInput) { }; const openFileSurfaceStable = useStableCallback(openFileSurface); const closePreviewPanel = () => { - if (activeThreadRef) { - if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) { - usePreviewMiniPlayerStore - .getState() - .open(activeThreadRef, browserMiniPlayerSource(activeRightPanelSurface.resourceId)); - } else if (activeRightPanelSurface?.kind === "device" && activeRightPanelSurface.target) { - floatDeviceSurface( - activeThreadRef, - { kind: "device", ...activeRightPanelSurface.target }, - deviceStateLoaded ? deviceState : null, - ); - } - useRightPanelStore.getState().setMaximized(activeThreadRef, false); - useRightPanelStore.getState().close(activeThreadRef); - } + if (activeThreadRef) dismissPreviewPanel(activeThreadRef); }; const closePreviewPanelStable = useStableCallback(closePreviewPanel); const togglePreviewPanel = () => { diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index c17655db964bc..305fcde3b9d0a 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -159,7 +159,11 @@ vi.mock(import("~/browser/browserViewportActions"), { spy: true }); import { useComposerDraftStore } from "~/composerDraftStore"; import { useBrowserPointerStore } from "~/browser/browserPointerStore"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; -import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { + browserMiniPlayerSource, + selectThreadPreviewMiniPlayerTabId, + usePreviewMiniPlayerStore, +} from "~/previewMiniPlayerStore"; import { dispatchPreviewAction } from "./previewActionBus"; import { PreviewView } from "./PreviewView"; @@ -286,4 +290,25 @@ describe("PreviewView", () => { act(() => dispatchPreviewAction("focus-url")); await expect.element(address).toHaveFocus(); }); + + it("turns off the floating preview from the chrome picture-in-picture button", async () => { + setup(); + usePreviewMiniPlayerStore.getState().open(TEST_THREAD_REF, browserMiniPlayerSource("tab-1")); + expect( + selectThreadPreviewMiniPlayerTabId( + usePreviewMiniPlayerStore.getState().byThreadKey, + TEST_THREAD_REF, + ), + ).toBe("tab-1"); + + await using _screen = await render(view()); + await userEvent.click(page.getByRole("button", { name: "Close floating preview" })); + + expect( + selectThreadPreviewMiniPlayerTabId( + usePreviewMiniPlayerStore.getState().byThreadKey, + TEST_THREAD_REF, + ), + ).toBeNull(); + }); }); From 8b9d39f1c7eeff1b0679f5c28fdd18c4e5187a81 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 13:40:33 +0000 Subject: [PATCH 2/6] fix(web): preserve floating preview selection while docked --- .../src/components/chat/closePreviewPanel.ts | 8 +-- .../preview/PreviewView.browser.test.tsx | 53 +++++++++++++++++++ .../preview/ThreadPreviewMiniPlayer.tsx | 2 - 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/closePreviewPanel.ts b/apps/web/src/components/chat/closePreviewPanel.ts index c1a43852c2219..8d69132174595 100644 --- a/apps/web/src/components/chat/closePreviewPanel.ts +++ b/apps/web/src/components/chat/closePreviewPanel.ts @@ -2,13 +2,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { useRightPanelStore } from "../../rightPanelStore"; -/** - * Dismiss the right panel without inventing a floating preview. - * - * The mini-player store is the source of truth: a float that was already open - * stays across a temporary dock into the panel, and clearing it via - * "Close floating preview" must still stick when the panel closes afterward. - */ +// Docking hides the selected float; only explicit dismissal clears it. export function closePreviewPanel(threadRef: ScopedThreadRef): void { useRightPanelStore.getState().setMaximized(threadRef, false); useRightPanelStore.getState().close(threadRef); diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index 305fcde3b9d0a..82944c233ea61 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -162,10 +162,16 @@ import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { browserMiniPlayerSource, selectThreadPreviewMiniPlayerTabId, + selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "~/previewMiniPlayerStore"; import { dispatchPreviewAction } from "./previewActionBus"; import { PreviewView } from "./PreviewView"; +import { ThreadPreviewMiniPlayer } from "./ThreadPreviewMiniPlayer"; +import { ChatCanvas } from "../chat/ChatCanvas"; +import { shouldRenderPreviewMiniPlayer } from "../ChatView.logic"; +import { selectSelectedRightPanelSurface, useRightPanelStore } from "~/rightPanelStore"; +import { closePreviewPanel } from "../chat/closePreviewPanel"; const TEST_THREAD_REF = { environmentId: EnvironmentId.make("environment-1"), @@ -223,6 +229,53 @@ function setup() { } describe("PreviewView", () => { + it("returns to the same floating preview after docking and closing the panel", async () => { + setup(); + useRightPanelStore.setState({ byThreadKey: {} }); + const store = usePreviewMiniPlayerStore.getState(); + store.open(TEST_THREAD_REF, browserMiniPlayerSource("tab-1")); + store.resize(TEST_THREAD_REF, "browser:tab-1", 320, { x: 100, y: 150 }); + const miniPlayer = selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + TEST_THREAD_REF, + )!; + await using screen = await render( +
+ + + +
, + ); + await userEvent.hover( + screen + .getByRole("region", { name: "Floating browser preview" }) + .element() + .querySelector(".group")!, + ); + await userEvent.click(screen.getByRole("button", { name: "Open preview in right panel" })); + const docked = selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + TEST_THREAD_REF, + ); + expect(docked).toEqual(miniPlayer); + expect( + shouldRenderPreviewMiniPlayer( + docked?.source ?? null, + selectSelectedRightPanelSurface(useRightPanelStore.getState().byThreadKey, TEST_THREAD_REF), + ), + ).toBe(false); + closePreviewPanel(TEST_THREAD_REF); + expect( + shouldRenderPreviewMiniPlayer( + selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + TEST_THREAD_REF, + )?.source ?? null, + null, + ), + ).toBe(true); + }); + it("retains the annotation locally when its own environment loses access during capture", async () => { setup(); const picked = annotation("annotation-revoked"); diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index a5c3525c3c930..1909e00562027 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -229,7 +229,6 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly ); const openInPanel = () => { - usePreviewMiniPlayerStore.getState().close(threadRef); useRightPanelStore.getState().openBrowser(threadRef, tabId); }; @@ -373,7 +372,6 @@ function DeviceMiniPlayer({ resolveDeviceMiniPlayerCornerRadius(source.platform, player); const openInPanel = () => { - usePreviewMiniPlayerStore.getState().close(threadRef); useRightPanelStore.getState().openDevice(threadRef, { hostId: source.hostId, deviceId: source.deviceId, From da0cd288e307521914fac14d9621fcf849fed9d0 Mon Sep 17 00:00:00 2001 From: JamesOBrien2 Date: Tue, 27 Oct 2026 09:27:15 +0000 Subject: [PATCH 3/6] fix(web): align preview dismissal with device sessions --- apps/web/src/components/ChatView.tsx | 5 +--- .../preview/PreviewView.browser.test.tsx | 24 ++++++++++++++--- .../preview/agentOpenedPreviews.test.ts | 27 ------------------- .../components/preview/agentOpenedPreviews.ts | 20 +------------- 4 files changed, 22 insertions(+), 54 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5369f2158e6e7..5f6aa6030a529 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1865,9 +1865,7 @@ export default function ChatView(props: ChatViewProps) { const visiblePullRequestCount = visiblePullRequests.length; const pullRequestsSurfaceAvailable = isServerThread && supportsThreadPullRequests && visiblePullRequestCount > 0; - const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( - activeThreadRef?.environmentId ?? null, - ); + const { state: deviceState } = useDeviceState(activeThreadRef?.environmentId ?? null); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. const activeProjectRepository = sourceControlClients.repositorySelector( @@ -2302,7 +2300,6 @@ export default function ChatView(props: ChatViewProps) { activeRightPanelSurface, activePreviewState, deviceState, - deviceStateLoaded, pullRequestPanelTarget, threadPanelPresentation, isServerThread, diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index 82944c233ea61..7805accd38fc6 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -169,8 +169,9 @@ import { dispatchPreviewAction } from "./previewActionBus"; import { PreviewView } from "./PreviewView"; import { ThreadPreviewMiniPlayer } from "./ThreadPreviewMiniPlayer"; import { ChatCanvas } from "../chat/ChatCanvas"; +import { createChatCanvasStore } from "../chat/canvasStore"; import { shouldRenderPreviewMiniPlayer } from "../ChatView.logic"; -import { selectSelectedRightPanelSurface, useRightPanelStore } from "~/rightPanelStore"; +import { selectActiveRightPanelSurface, useRightPanelStore } from "~/rightPanelStore"; import { closePreviewPanel } from "../chat/closePreviewPanel"; const TEST_THREAD_REF = { @@ -241,7 +242,17 @@ describe("PreviewView", () => { )!; await using screen = await render(
- +
, @@ -261,17 +272,22 @@ describe("PreviewView", () => { expect( shouldRenderPreviewMiniPlayer( docked?.source ?? null, - selectSelectedRightPanelSurface(useRightPanelStore.getState().byThreadKey, TEST_THREAD_REF), + selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, TEST_THREAD_REF), ), ).toBe(false); closePreviewPanel(TEST_THREAD_REF); + const activeSurface = selectActiveRightPanelSurface( + useRightPanelStore.getState().byThreadKey, + TEST_THREAD_REF, + ); + expect(activeSurface).toBeNull(); expect( shouldRenderPreviewMiniPlayer( selectThreadPreviewMiniPlayer( usePreviewMiniPlayerStore.getState().byThreadKey, TEST_THREAD_REF, )?.source ?? null, - null, + activeSurface, ), ).toBe(true); }); diff --git a/apps/web/src/components/preview/agentOpenedPreviews.test.ts b/apps/web/src/components/preview/agentOpenedPreviews.test.ts index 9464d832d7f79..4d1b59ea39428 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.test.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.test.ts @@ -20,7 +20,6 @@ import { import { useRightPanelStore } from "../../rightPanelStore"; import { applyDeviceSnapshot, - floatDeviceSurface, followPreviewSessions, resetDeviceSessionBaselinesForTests, revealServerTabs, @@ -169,32 +168,6 @@ describe("applyDeviceSnapshot", () => { }); }); -describe("floatDeviceSurface", () => { - const source = { - kind: "device", - hostId: "local", - deviceId: "pixel", - platform: "android", - name: "Pixel 8", - } as const; - - it("floats a device with a live session, or before device state loads", () => { - setup(); - floatDeviceSurface(ref, source, deviceState({ sessions: [session] })); - expect(miniPlayerSource()).toMatchObject({ kind: "device", deviceId: "pixel" }); - - setup(); - floatDeviceSurface(ref, source, null); - expect(miniPlayerSource()).toMatchObject({ kind: "device", deviceId: "pixel" }); - }); - - it("does not float a device whose session ended", () => { - setup(); - floatDeviceSurface(ref, source, deviceState({ sessions: [] })); - expect(miniPlayerSource()).toBeUndefined(); - }); -}); - describe("revealServerTabs", () => { const tab = (tabId: string, force?: boolean): PreviewSessionSnapshot => ({ threadId: "thread-1", diff --git a/apps/web/src/components/preview/agentOpenedPreviews.ts b/apps/web/src/components/preview/agentOpenedPreviews.ts index ccb133074baad..2d9794e5e5f14 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.ts @@ -14,11 +14,7 @@ import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { getClientSettings } from "../../hooks/useSettings"; -import { - browserMiniPlayerSource, - type PreviewMiniPlayerSource, - usePreviewMiniPlayerStore, -} from "../../previewMiniPlayerStore"; +import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore"; import { previewStateAtom, previewThreadRefsAtom, @@ -200,20 +196,6 @@ export function applyDeviceSnapshot( } } -/** - * Floats a device panel tab over chat. A tab whose device session already - * ended has nothing to stream, so it only closes the panel. Before the first - * device snapshot the tab floats, and that snapshot closes it if needed. - */ -export function floatDeviceSurface( - threadRef: ScopedThreadRef, - source: Extract, - state: DeviceServiceState | null, -): void { - if (state !== null && !hasLiveSession(state, threadRef.threadId, source)) return; - usePreviewMiniPlayerStore.getState().open(threadRef, source); -} - export function resetDeviceSessionBaselinesForTests(): void { deviceSessionBaselines.clear(); } From 0937dbcbdcba38c03e75713a150b165b29aff0db Mon Sep 17 00:00:00 2001 From: JamesOBrien2 Date: Tue, 27 Oct 2026 09:53:52 +0000 Subject: [PATCH 4/6] fix(test): address CodeRabbit nitpicks in PreviewView browser test - Replace inline style with Tailwind classes - Wrap store-state assertions in vi.waitFor per browser test guidelines - Apply to both regression test and PiP dismissal test --- .../preview/PreviewView.browser.test.tsx | 86 +++++++++++-------- 1 file changed, 48 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index 7805accd38fc6..08e98e70062c9 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -241,7 +241,7 @@ describe("PreviewView", () => { TEST_THREAD_REF, )!; await using screen = await render( -
+
{ .querySelector(".group")!, ); await userEvent.click(screen.getByRole("button", { name: "Open preview in right panel" })); - const docked = selectThreadPreviewMiniPlayer( - usePreviewMiniPlayerStore.getState().byThreadKey, - TEST_THREAD_REF, - ); - expect(docked).toEqual(miniPlayer); - expect( - shouldRenderPreviewMiniPlayer( - docked?.source ?? null, - selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, TEST_THREAD_REF), - ), - ).toBe(false); + await vi.waitFor(() => { + const docked = selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + TEST_THREAD_REF, + ); + expect(docked).toEqual(miniPlayer); + expect( + shouldRenderPreviewMiniPlayer( + docked?.source ?? null, + selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, TEST_THREAD_REF), + ), + ).toBe(false); + }); closePreviewPanel(TEST_THREAD_REF); - const activeSurface = selectActiveRightPanelSurface( - useRightPanelStore.getState().byThreadKey, - TEST_THREAD_REF, - ); - expect(activeSurface).toBeNull(); - expect( - shouldRenderPreviewMiniPlayer( - selectThreadPreviewMiniPlayer( - usePreviewMiniPlayerStore.getState().byThreadKey, - TEST_THREAD_REF, - )?.source ?? null, - activeSurface, - ), - ).toBe(true); + await vi.waitFor(() => { + const activeSurface = selectActiveRightPanelSurface( + useRightPanelStore.getState().byThreadKey, + TEST_THREAD_REF, + ); + expect(activeSurface).toBeNull(); + }); + await vi.waitFor(() => { + expect( + shouldRenderPreviewMiniPlayer( + selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + TEST_THREAD_REF, + )?.source ?? null, + selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, TEST_THREAD_REF), + ), + ).toBe(true); + }); }); it("retains the annotation locally when its own environment loses access during capture", async () => { @@ -363,21 +369,25 @@ describe("PreviewView", () => { it("turns off the floating preview from the chrome picture-in-picture button", async () => { setup(); usePreviewMiniPlayerStore.getState().open(TEST_THREAD_REF, browserMiniPlayerSource("tab-1")); - expect( - selectThreadPreviewMiniPlayerTabId( - usePreviewMiniPlayerStore.getState().byThreadKey, - TEST_THREAD_REF, - ), - ).toBe("tab-1"); + await vi.waitFor(() => { + expect( + selectThreadPreviewMiniPlayerTabId( + usePreviewMiniPlayerStore.getState().byThreadKey, + TEST_THREAD_REF, + ), + ).toBe("tab-1"); + }); await using _screen = await render(view()); await userEvent.click(page.getByRole("button", { name: "Close floating preview" })); - expect( - selectThreadPreviewMiniPlayerTabId( - usePreviewMiniPlayerStore.getState().byThreadKey, - TEST_THREAD_REF, - ), - ).toBeNull(); + await vi.waitFor(() => { + expect( + selectThreadPreviewMiniPlayerTabId( + usePreviewMiniPlayerStore.getState().byThreadKey, + TEST_THREAD_REF, + ), + ).toBeNull(); + }); }); }); From 70c30572b8b0b443d914979f6e4aa990d05b6efd Mon Sep 17 00:00:00 2001 From: JamesOBrien2 Date: Tue, 27 Oct 2026 14:03:55 +0000 Subject: [PATCH 5/6] test(web): cover closing the preview panel --- .../preview/PreviewView.browser.test.tsx | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index 08e98e70062c9..9823f83033f10 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -173,6 +173,8 @@ import { createChatCanvasStore } from "../chat/canvasStore"; import { shouldRenderPreviewMiniPlayer } from "../ChatView.logic"; import { selectActiveRightPanelSurface, useRightPanelStore } from "~/rightPanelStore"; import { closePreviewPanel } from "../chat/closePreviewPanel"; +import { useRightPanelActions } from "../chat/useRightPanelActions"; +import { __testing as previewStateTesting } from "~/previewStateStore"; const TEST_THREAD_REF = { environmentId: EnvironmentId.make("environment-1"), @@ -191,6 +193,38 @@ function view(onSendAnnotation?: ComponentProps["onSendAnnot ); } +function PanelCloseAction() { + const activeRightPanelSurface = useRightPanelStore((state) => + selectActiveRightPanelSurface(state.byThreadKey, TEST_THREAD_REF), + ); + const { closePreviewPanelStable } = useRightPanelActions({ + activeThreadRef: TEST_THREAD_REF, + activeProject: null, + activeRightPanelSurface, + activePreviewState: previewStateTesting.EMPTY_THREAD_PREVIEW_STATE, + deviceState: {} as Parameters[0]["deviceState"], + pullRequestPanelTarget: null, + threadPanelPresentation: "inline", + isServerThread: true, + isGitRepo: false, + browserAvailable: true, + canOperatePreview: true, + diffOpen: false, + previewPanelOpen: true, + rightPanelOpen: true, + canMaximizeRightPanel: false, + rightPanelMaximized: false, + supportsPullRequests: false, + pullRequestsSurfaceAvailable: false, + gitCwd: null, + activeProjectCwd: null, + explicitDiffOpenRef: { current: null }, + onDiffPanelOpen: undefined, + closeRightPanelSurface: () => {}, + }); + return ; +} + function annotation(id: string, screenshot: unknown = null) { return { id, @@ -230,6 +264,28 @@ function setup() { } describe("PreviewView", () => { + it("does not restore a dismissed floating preview when the panel action closes", async () => { + setup(); + useRightPanelStore.setState({ byThreadKey: {} }); + usePreviewMiniPlayerStore.getState().open(TEST_THREAD_REF, browserMiniPlayerSource("tab-1")); + useRightPanelStore.getState().openBrowser(TEST_THREAD_REF, "tab-1"); + usePreviewMiniPlayerStore.getState().close(TEST_THREAD_REF); + + await using screen = await render(); + await userEvent.click(screen.getByRole("button", { name: "Close panel" })); + await vi.waitFor(() => { + expect( + selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, TEST_THREAD_REF), + ).toBeNull(); + expect( + selectThreadPreviewMiniPlayerTabId( + usePreviewMiniPlayerStore.getState().byThreadKey, + TEST_THREAD_REF, + ), + ).toBeNull(); + }); + }); + it("returns to the same floating preview after docking and closing the panel", async () => { setup(); useRightPanelStore.setState({ byThreadKey: {} }); From 83e05511a6c6459a78e8b6f137ef4394b6d1c4a9 Mon Sep 17 00:00:00 2001 From: JamesOBrien2 Date: Wed, 28 Oct 2026 22:12:18 +0000 Subject: [PATCH 6/6] test(web): assert floating preview visibility after docking --- .../preview/PreviewView.browser.test.tsx | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index 9823f83033f10..3b686746d5e9c 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -296,21 +296,34 @@ describe("PreviewView", () => { usePreviewMiniPlayerStore.getState().byThreadKey, TEST_THREAD_REF, )!; - await using screen = await render( -
+ const canvasStore = createChatCanvasStore(); + function FloatingPreviewHost() { + const current = usePreviewMiniPlayerStore((state) => + selectThreadPreviewMiniPlayer(state.byThreadKey, TEST_THREAD_REF), + ); + const surface = useRightPanelStore((state) => + selectActiveRightPanelSurface(state.byThreadKey, TEST_THREAD_REF), + ); + if (!current || !shouldRenderPreviewMiniPlayer(current.source, surface)) return null; + return ( - + + ); + } + await using screen = await render( +
+
, ); await userEvent.hover( @@ -333,6 +346,9 @@ describe("PreviewView", () => { ), ).toBe(false); }); + await expect + .element(screen.getByRole("region", { name: "Floating browser preview" })) + .not.toBeInTheDocument(); closePreviewPanel(TEST_THREAD_REF); await vi.waitFor(() => { const activeSurface = selectActiveRightPanelSurface( @@ -352,6 +368,9 @@ describe("PreviewView", () => { ), ).toBe(true); }); + await expect + .element(screen.getByRole("region", { name: "Floating browser preview" })) + .toBeInTheDocument(); }); it("retains the annotation locally when its own environment loses access during capture", async () => {