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/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..8d69132174595 --- /dev/null +++ b/apps/web/src/components/chat/closePreviewPanel.ts @@ -0,0 +1,9 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { useRightPanelStore } from "../../rightPanelStore"; + +// 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/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..3b686746d5e9c 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -159,9 +159,22 @@ 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, + selectThreadPreviewMiniPlayer, + usePreviewMiniPlayerStore, +} from "~/previewMiniPlayerStore"; 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 { 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"), @@ -180,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, @@ -219,6 +264,115 @@ 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: {} }); + 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, + )!; + 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( + screen + .getByRole("region", { name: "Floating browser preview" }) + .element() + .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, + 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); + }); + 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 () => { setup(); const picked = annotation("annotation-revoked"); @@ -286,4 +440,29 @@ 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")); + 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(); + }); + }); }); 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, 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(); }