Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -2302,7 +2300,6 @@ export default function ChatView(props: ChatViewProps) {
activeRightPanelSurface,
activePreviewState,
deviceState,
deviceStateLoaded,
pullRequestPanelTarget,
threadPanelPresentation,
isServerThread,
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/components/chat/closePreviewPanel.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
9 changes: 9 additions & 0 deletions apps/web/src/components/chat/closePreviewPanel.ts
Original file line number Diff line number Diff line change
@@ -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);
}
21 changes: 2 additions & 19 deletions apps/web/src/components/chat/useRightPanelActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -38,7 +37,6 @@ interface RightPanelActionsInput {
readonly activeRightPanelSurface: RightPanelSurface | null;
readonly activePreviewState: ThreadPreviewState;
readonly deviceState: ReturnType<typeof useDeviceState>["state"];
readonly deviceStateLoaded: boolean;
readonly pullRequestPanelTarget: ReturnType<typeof threadPullRequestPanelTarget> | null;
readonly threadPanelPresentation: ThreadPanelPresentation;
readonly isServerThread: boolean;
Expand Down Expand Up @@ -71,7 +69,6 @@ export function useRightPanelActions(input: RightPanelActionsInput) {
activeRightPanelSurface,
activePreviewState,
deviceState,
deviceStateLoaded,
pullRequestPanelTarget,
threadPanelPresentation,
isServerThread,
Expand Down Expand Up @@ -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 = () => {
Expand Down
181 changes: 180 additions & 1 deletion apps/web/src/components/preview/PreviewView.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -180,6 +193,38 @@ function view(onSendAnnotation?: ComponentProps<typeof PreviewView>["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<typeof useRightPanelActions>[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 <button onClick={closePreviewPanelStable}>Close panel</button>;
}

function annotation(id: string, screenshot: unknown = null) {
return {
id,
Expand Down Expand Up @@ -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(<PanelCloseAction />);
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 (
<ChatCanvas
store={canvasStore}
threadRef={TEST_THREAD_REF}
preview={{
key: "browser:tab-1",
width: current.width,
position: current.position,
source: { width: 1024, height: 768 },
}}
composerOverlayElement={null}
>
<ThreadPreviewMiniPlayer threadRef={TEST_THREAD_REF} miniPlayer={current} />
</ChatCanvas>
);
}
await using screen = await render(
<div className="flex w-[1000px] h-[700px]">
<FloatingPreviewHost />
</div>,
);
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");
Expand Down Expand Up @@ -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();
});
});
});
2 changes: 0 additions & 2 deletions apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,6 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly
);

const openInPanel = () => {
usePreviewMiniPlayerStore.getState().close(threadRef);
useRightPanelStore.getState().openBrowser(threadRef, tabId);
};

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading