From d699cacf11899eb7700fdb22d83db40ba16c2fdb Mon Sep 17 00:00:00 2001 From: JamesOBrien2 Date: Sat, 24 Oct 2026 17:01:27 +0100 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 () => { From 9d464780f1a12dc74b1abca4c7d52d119cbeae44 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 06:10:42 +0000 Subject: [PATCH 07/11] fix(web): dismiss docked device previews on tab close --- .../device/DevicePanel.browser.test.tsx | 26 +++++++++++++-- .../web/src/components/device/DevicePanel.tsx | 5 +++ ...useRightPanelSurfaceClose.browser.test.tsx | 32 +++++++++++++++++++ .../src/hooks/useRightPanelSurfaceClose.ts | 4 +++ apps/web/src/previewMiniPlayerStore.ts | 12 +++++-- 5 files changed, 73 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/device/DevicePanel.browser.test.tsx b/apps/web/src/components/device/DevicePanel.browser.test.tsx index 8e8e0d27eb966..a83762a2dd0ba 100644 --- a/apps/web/src/components/device/DevicePanel.browser.test.tsx +++ b/apps/web/src/components/device/DevicePanel.browser.test.tsx @@ -7,6 +7,7 @@ import { import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; +import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { act, render } from "../../../test/render"; @@ -98,8 +99,11 @@ vi.mock("~/rightPanelStore", () => ({ })); vi.mock("~/components/device/DeviceHostUpdates", () => ({ DeviceHostUpdates: () => null })); vi.mock("~/components/device/DeviceWorkspace", () => ({ - DeviceWorkspace: ({ onPowerOff }: { onPowerOff: () => void }) => ( - + DeviceWorkspace: ({ onPowerOff, onClose }: { onPowerOff: () => void; onClose: () => void }) => ( + <> + + + ), })); vi.mock("~/components/preview/PreviewPanelShell", () => ({ @@ -122,7 +126,7 @@ const switches = [ ] as const; const threadB = switches[0][1]; const picker: Extract = { id: "device", kind: "device" }; -const phoneTarget = { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }; +const phoneTarget = { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" } as const; const opened = { _tag: "Success", value: { hostId: "local", deviceId: "phone" } } as const; const failed = { _tag: "Failure", cause: new Error("boom") } as const; @@ -153,6 +157,7 @@ const errors = () => container.querySelectorAll('[role="alert"]'); function setup() { vi.resetAllMocks(); + usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); mocks.list.mockResolvedValue({ _tag: "Success", value: { hostId: "local", deviceId: "" } }); currentState = deviceState; container = document.createElement("div"); @@ -213,3 +218,18 @@ describe("DevicePanel host probe", () => { ]); }); }); + +it("dismisses the retained floating device when its panel is closed", async () => { + setup(); + usePreviewMiniPlayerStore.getState().open(threadA, { kind: "device", ...phoneTarget }); + await using screen = await render( + devicePanel(threadA, { id: "device:local:phone", kind: "device", target: phoneTarget }), + { container }, + ); + await screen.getByRole("button", { name: "Close device panel" }).click(); + expect( + selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, threadA), + ).toBeNull(); + expect(mocks.closeSurface).toHaveBeenCalledWith(threadA, "device:local:phone"); + expect(mocks.close).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index cee7a3e4e4bcd..c8a21d26acd59 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -143,6 +143,11 @@ export function DevicePanel(props: { const closeSurface = () => { const store = useRightPanelStore.getState(); const { activeSurfaceId } = selectThreadRightPanelState(store.byThreadKey, props.threadRef); + if (props.surface.target) { + usePreviewMiniPlayerStore + .getState() + .close(props.threadRef, { kind: "device", ...props.surface.target }); + } store.closeSurface(props.threadRef, props.surface.id); focusPanelTerminalAfterClose(props.threadRef, activeSurfaceId); }; diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx index d394c67c9dff2..92138b56a835a 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx @@ -3,6 +3,10 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { expect, it, vi } from "vite-plus/test"; import { act, render } from "../../test/render"; import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; +import { + selectThreadPreviewMiniPlayer, + usePreviewMiniPlayerStore, +} from "../previewMiniPlayerStore"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { useRightPanelSurfaceClose } from "./useRightPanelSurfaceClose"; @@ -61,6 +65,7 @@ vi.mock("@t3tools/client-runtime/state/runtime", () => ({ })); function setup() { + usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); fixture.allowed = new Set(["environment-a", "environment-b"]); fixture.sessions = new Map(); fixture.failClose = false; @@ -132,3 +137,30 @@ it.each(["cancel", "revoked"])( ).toHaveLength(1); }, ); + +it("dismisses only the floating device whose tab is closed", async () => { + const { original } = setup(); + const target = { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" } as const; + useRightPanelStore.getState().openDevice(original, target); + const surface = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + original, + ).surfaces.find((entry) => entry.kind === "device")!; + function DeviceClose() { + const { closeSurface } = useRightPanelSurfaceClose({ + threadRef: original, + terminalLabelsById: new Map(), + }); + return ; + } + await using screen = await render(); + for (const deviceId of ["other", "phone"]) { + useRightPanelStore.getState().openDevice(original, target); + usePreviewMiniPlayerStore.getState().open(original, { kind: "device", ...target, deviceId }); + await screen.getByRole("button").click(); + expect( + selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, original) + ?.source ?? null, + ).toEqual(deviceId === "phone" ? null : { kind: "device", ...target, deviceId }); + } +}); diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.ts b/apps/web/src/hooks/useRightPanelSurfaceClose.ts index e79042ec37546..7e6389514eb95 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.ts +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.ts @@ -9,6 +9,7 @@ import { closePreviewSession } from "../components/preview/closePreviewSession"; import { useThreadTerminalActions } from "../components/terminal/useThreadTerminalActions"; import { confirmTerminalClose } from "../lib/terminalCloseConfirm"; import { focusPanelTerminalAfterClose } from "../lib/terminalFocus"; +import { usePreviewMiniPlayerStore } from "../previewMiniPlayerStore"; import { readLocalApi } from "../localApi"; import { setActivePreviewTab, useThreadPreviewState } from "../previewStateStore"; import { @@ -42,6 +43,9 @@ export function useRightPanelSurfaceClose(input: { ); const releaseSurfaceResources = (ref: ScopedThreadRef, surface: RightPanelSurface) => { + if (surface.kind === "device" && surface.target) { + usePreviewMiniPlayerStore.getState().close(ref, { kind: "device", ...surface.target }); + } // Without preview access, server state remains until an authorized close or restart. if (canOperatePreview && surface.kind === "preview" && surface.resourceId) { void closePreviewSession({ diff --git a/apps/web/src/previewMiniPlayerStore.ts b/apps/web/src/previewMiniPlayerStore.ts index bdbb9b1ca876f..7a4ba3787868f 100644 --- a/apps/web/src/previewMiniPlayerStore.ts +++ b/apps/web/src/previewMiniPlayerStore.ts @@ -32,7 +32,7 @@ export interface PreviewMiniPlayerState { interface PreviewMiniPlayerStoreState { readonly byThreadKey: Record; readonly open: (ref: ScopedThreadRef, source: PreviewMiniPlayerSource) => void; - readonly close: (ref: ScopedThreadRef) => void; + readonly close: (ref: ScopedThreadRef, source?: PreviewMiniPlayerSource) => void; /** `sourceKey` guards against a drag that outlives the source it started on. */ readonly move: ( ref: ScopedThreadRef, @@ -83,10 +83,16 @@ export const usePreviewMiniPlayerStore = create()(( }, }; }), - close: (ref) => + close: (ref, source) => set((state) => { const threadKey = scopedThreadKey(ref); - if (!(threadKey in state.byThreadKey)) return state; + const current = state.byThreadKey[threadKey]; + if ( + !current || + (source && + previewMiniPlayerSourceKey(current.source) !== previewMiniPlayerSourceKey(source)) + ) + return state; const { [threadKey]: _closed, ...byThreadKey } = state.byThreadKey; return { byThreadKey }; }), From 9acafc7615f5c0a589ad62b677e072dcc8a887ef Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 14:04:43 +0000 Subject: [PATCH 08/11] test(web): pass embedded to right panel actions in preview test --- apps/web/src/components/preview/PreviewView.browser.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index b973df7fc7caa..f04872c3660f4 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -202,6 +202,7 @@ function PanelCloseAction() { deviceState: {} as Parameters[0]["deviceState"], pullRequestPanelTarget: null, threadPanelPresentation: "inline", + embedded: false, isServerThread: true, isGitRepo: false, browserAvailable: true, From 4b772ed10ae8f485e96bf6f07ff0e56768949584 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 17:18:11 +0000 Subject: [PATCH 09/11] test(web): drop removed explicitDiffOpenRef from preview panel test --- apps/web/src/components/preview/PreviewView.browser.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index 16e37b3a6937f..5a84138f6a840 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -239,7 +239,6 @@ function PanelCloseAction() { pullRequestsSurfaceAvailable: false, gitCwd: null, activeProjectCwd: null, - explicitDiffOpenRef: { current: null }, onDiffPanelOpen: undefined, closeRightPanelSurface: () => {}, }); From 917fae08e3fe30ead289b6e3d9d55f525df75cd1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 06:56:38 +0000 Subject: [PATCH 10/11] test(web): keep only preview dismissal regressions Drop the closePreviewPanel unit test, which the panel-action and docking browser tests already cover, and the picture-in-picture button test, which covers behaviour this change does not touch. Co-Authored-By: Claude Opus 5.5 --- .../components/chat/closePreviewPanel.test.ts | 57 ----------------- .../preview/PreviewView.browser.test.tsx | 62 ++----------------- 2 files changed, 6 insertions(+), 113 deletions(-) delete mode 100644 apps/web/src/components/chat/closePreviewPanel.test.ts diff --git a/apps/web/src/components/chat/closePreviewPanel.test.ts b/apps/web/src/components/chat/closePreviewPanel.test.ts deleted file mode 100644 index 57459dc4c4ad9..0000000000000 --- a/apps/web/src/components/chat/closePreviewPanel.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -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/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index 961a692bbfeb7..438ef3eccf3ec 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -358,41 +358,16 @@ describe("PreviewView", () => { .querySelector(".group")!, ); await userEvent.click(screen.getByRole("button", { name: "Open preview in right panel" })); - 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); - }); await expect .element(screen.getByRole("region", { name: "Floating browser preview" })) .not.toBeInTheDocument(); - closePreviewPanel(TEST_THREAD_REF); - await vi.waitFor(() => { - const activeSurface = selectActiveRightPanelSurface( - useRightPanelStore.getState().byThreadKey, + expect( + selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.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); - }); + ), + ).toEqual(miniPlayer); + closePreviewPanel(TEST_THREAD_REF); await expect .element(screen.getByRole("region", { name: "Floating browser preview" })) .toBeInTheDocument(); @@ -590,31 +565,6 @@ describe("PreviewView", () => { 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")); - 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" })); - - await vi.waitFor(() => { - expect( - selectThreadPreviewMiniPlayerTabId( - usePreviewMiniPlayerStore.getState().byThreadKey, - TEST_THREAD_REF, - ), - ).toBeNull(); - }); - }); - it("does not reopen find when the user returns to a tab they left with find open", async () => { setup(); const tabView = (tabId: string) => ( From 6a6a03d7996e88bb752da84e48e37019c76f3d8f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 07:13:20 +0000 Subject: [PATCH 11/11] fix(web): docked preview floats again in one click Docking keeps the saved float selection, so the panel's float button read it as already floating. Treat the tab the panel itself shows as not floating: the button is unpressed and one click floats it and closes the panel. Co-Authored-By: Claude Opus 5.5 --- .../preview/PreviewView.browser.test.tsx | 22 +++++++++++++++++++ .../src/components/preview/PreviewView.tsx | 6 +++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index 438ef3eccf3ec..ed85f3e4119f5 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -565,6 +565,28 @@ describe("PreviewView", () => { await expect.element(address).toHaveFocus(); }); + it("floats a docked preview from the panel in one click", async () => { + setup(); + useRightPanelStore.setState({ byThreadKey: {} }); + usePreviewMiniPlayerStore.getState().open(TEST_THREAD_REF, browserMiniPlayerSource("tab-1")); + useRightPanelStore.getState().openBrowser(TEST_THREAD_REF, "tab-1"); + + await using screen = await render(view()); + const float = screen.getByRole("button", { name: "Float preview over chat" }); + await expect.element(float).toHaveAttribute("aria-pressed", "false"); + await userEvent.click(float); + + expect( + selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, TEST_THREAD_REF), + ).toBeNull(); + expect( + selectThreadPreviewMiniPlayerTabId( + usePreviewMiniPlayerStore.getState().byThreadKey, + TEST_THREAD_REF, + ), + ).toBe("tab-1"); + }); + it("does not reopen find when the user returns to a tab they left with find open", async () => { setup(); const tabView = (tabId: string) => ( diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index f7cb3082491e9..35be2363d0584 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -198,6 +198,8 @@ export function PreviewView({ usePreviewSession(threadRef); const tabId = requestedTabId ?? previewState.activeTabId; + // A docked float keeps its saved selection, but the panel showing its tab hides it. + const floating = miniPlayerTabId === tabId && requestedTabId !== tabId; const runtimeTabId = tabId ? previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId) : null; @@ -485,7 +487,7 @@ export function PreviewView({ const handlePictureInPicture = () => { if (!tabId) return; - if (miniPlayerTabId === tabId) { + if (floating) { usePreviewMiniPlayerStore.getState().close(threadRef); return; } @@ -998,7 +1000,7 @@ export function PreviewView({ onPictureInPicture={ tabId && (isServerTab || previewBridge) ? handlePictureInPicture : undefined } - pictureInPicture={miniPlayerTabId === tabId} + pictureInPicture={floating} pictureInPictureDisabled={ isUnreachable || (!isServerTab && !desktopOverlay?.hasWebContents) }