Skip to content
Merged
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
49 changes: 44 additions & 5 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ describe("floating browser preview", () => {
const ref = scopeThreadRef(EnvironmentId.make("env-1"), ThreadId.make("thread-1"));
const panels = useRightPanelStore.getState();
const revision = panels.getUserActionRevision(ref);
usePreviewMiniPlayerStore.getState().open(ref, "agent-tab");
usePreviewMiniPlayerStore.getState().open(ref, { kind: "browser", tabId: "agent-tab" });
panels.reconcileBrowserSurfaces(ref, ["agent-tab"]);
const intent = selectThreadPreviewMiniPlayer(
usePreviewMiniPlayerStore.getState().byThreadKey,
Expand All @@ -135,7 +135,7 @@ describe("floating browser preview", () => {
const isFloating = () =>
shouldRenderPreviewMiniPlayer(
selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, ref)
?.tabId ?? null,
?.source ?? null,
selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref),
);

Expand All @@ -152,22 +152,61 @@ describe("floating browser preview", () => {
});

it("only hides the duplicate while the same browser is rendered in the panel", () => {
const tab = { kind: "browser", tabId: "tab-1" } as const;
expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false);
expect(
shouldRenderPreviewMiniPlayer("tab-1", {
shouldRenderPreviewMiniPlayer(tab, {
id: "browser:one",
kind: "preview",
resourceId: "tab-1",
}),
).toBe(false);
expect(
shouldRenderPreviewMiniPlayer("tab-1", {
shouldRenderPreviewMiniPlayer(tab, {
id: "browser:two",
kind: "preview",
resourceId: "tab-2",
}),
).toBe(true);
expect(shouldRenderPreviewMiniPlayer("tab-1", { id: "diff", kind: "diff" })).toBe(true);
expect(shouldRenderPreviewMiniPlayer(tab, { id: "diff", kind: "diff" })).toBe(true);
});

it("only hides a floating device while that device is rendered in the panel", () => {
const pixel = {
kind: "device",
hostId: "nucbox",
deviceId: "emulator-5580",
platform: "android",
name: "Pixel",
} as const;
const target = {
hostId: "nucbox",
deviceId: "emulator-5580",
platform: "android",
name: "Pixel",
} as const;
expect(
shouldRenderPreviewMiniPlayer(pixel, {
id: "device:nucbox:emulator-5580",
kind: "device",
target,
}),
).toBe(false);
expect(
shouldRenderPreviewMiniPlayer(pixel, {
id: "device:nucbox:emulator-5554",
kind: "device",
target: { ...target, deviceId: "emulator-5554" },
}),
).toBe(true);
expect(shouldRenderPreviewMiniPlayer(pixel, { id: "device", kind: "device" })).toBe(true);
expect(
shouldRenderPreviewMiniPlayer(pixel, {
id: "browser:one",
kind: "preview",
resourceId: "emulator-5580",
}),
).toBe(true);
});
});

Expand Down
19 changes: 13 additions & 6 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
import type { DraftThreadEnvMode } from "../composerDraftStore";
import type { ComposerSubmissionIntent } from "../composer-logic";
import type { TimelineEntry } from "../session-logic";
import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore";
import type { DesktopPreviewOverlay } from "../previewStateStore";
import type { RightPanelSurface } from "../rightPanelStore";
import {
Expand Down Expand Up @@ -88,16 +89,22 @@ export function agentControlledBrowserCloseConfirmation(
].join("\n");
}

/** The floating player hides only while the same source is rendered in the panel. */
export function shouldRenderPreviewMiniPlayer(
miniPlayerTabId: string | null,
source: PreviewMiniPlayerSource | null,
renderedRightPanelSurface: RightPanelSurface | null,
): boolean {
return (
miniPlayerTabId !== null &&
!(
if (source === null) return false;
if (source.kind === "browser") {
return !(
renderedRightPanelSurface?.kind === "preview" &&
renderedRightPanelSurface.resourceId === miniPlayerTabId
)
renderedRightPanelSurface.resourceId === source.tabId
);
}
return !(
renderedRightPanelSurface?.kind === "device" &&
renderedRightPanelSurface.target?.hostId === source.hostId &&
renderedRightPanelSurface.target.deviceId === source.deviceId
);
}

Expand Down
86 changes: 61 additions & 25 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ import {
useSidebarPendingFileDropStore,
} from "../sidebarPendingFileDropStore";
import {
browserMiniPlayerSource,
previewMiniPlayerSourceKey,
selectThreadPreviewMiniPlayer,
usePreviewMiniPlayerStore,
} from "../previewMiniPlayerStore";
Expand Down Expand Up @@ -566,6 +568,8 @@ const PreviewPanel = lazy(() =>
import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),
);
const DiffPanel = lazy(() => import("./DiffPanel"));
const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) =>
settings.browserAutoShowFloatingPreview;
const DevicePanel = lazy(() =>
import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })),
);
Expand Down Expand Up @@ -1958,7 +1962,7 @@ export default function ChatView(props: ChatViewProps) {
const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null;
const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? [];
const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer(
activePreviewMiniPlayer?.tabId ?? null,
activePreviewMiniPlayer?.source ?? null,
renderedRightPanelSurface,
);
const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet;
Expand All @@ -1974,8 +1978,10 @@ export default function ChatView(props: ChatViewProps) {
}, [activePreviewState.sessions, activeThreadRef]);

useEffect(() => {
if (!activeThreadRef || !activePreviewMiniPlayer) return;
const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]);
if (!activeThreadRef || activePreviewMiniPlayer?.source.kind !== "browser") return;
const miniTabStillExists = Boolean(
activePreviewState.sessions[activePreviewMiniPlayer.source.tabId],
);
if (!miniTabStillExists) {
usePreviewMiniPlayerStore.getState().close(activeThreadRef);
}
Expand Down Expand Up @@ -4215,9 +4221,13 @@ export default function ChatView(props: ChatViewProps) {
}
useRightPanelStore.getState().open(activeThreadRef, "device");
}, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]);
// Reconcile new server sessions into separate tabs, including sessions opened
// by an agent or another client. The first snapshot is a baseline: persisted
// tabs restore themselves, and existing sessions must not resurrect closed tabs.
// A device the agent opens floats over chat like an agent-driven browser,
// or becomes a panel tab when floating previews are off. Sessions opened by
// another client arrive the same way; sheet layouts get neither. The first
// snapshot is a baseline: persisted tabs restore themselves, and existing
// sessions must not resurrect closed tabs. A session whose device summary
// has not arrived yet stays out of the baseline so a later snapshot opens it.
const autoShowFloatingPreview = useClientSettings(selectAutoShowFloatingPreview);
const previousDeviceSessions = useRef(new Map<string, Set<string>>());
useEffect(() => {
if (!activeThreadRef || !deviceStateLoaded) return;
Expand All @@ -4226,11 +4236,30 @@ export default function ChatView(props: ChatViewProps) {
(session) => session.threadId === activeThreadRef.threadId,
);
const key = (session: (typeof sessions)[number]) => `${session.hostId}:${session.deviceId}`;
const deviceFor = (session: (typeof sessions)[number]) =>
deviceState.devices.find(
(entry) => entry.hostId === session.hostId && entry.id === session.deviceId,
);
const previous = previousDeviceSessions.current.get(threadKey);
previousDeviceSessions.current.set(threadKey, new Set(sessions.map(key)));
previousDeviceSessions.current.set(
threadKey,
new Set(sessions.filter((session) => deviceFor(session) !== undefined).map(key)),
);
if (!previous || shouldUseRightPanelSheet) return;
for (const session of sessions) {
if (previous?.has(key(session))) continue;
if (previous.has(key(session))) continue;
const device = deviceFor(session);
if (!device) continue;
const target = {
hostId: session.hostId,
deviceId: session.deviceId,
platform: device.platform,
name: device.name,
};
if (autoShowFloatingPreview) {
usePreviewMiniPlayerStore.getState().open(activeThreadRef, { kind: "device", ...target });
continue;
}
const existing = useRightPanelStore
.getState()
.byThreadKey[scopedThreadKey(activeThreadRef)]?.surfaces.some(
Expand All @@ -4240,28 +4269,30 @@ export default function ChatView(props: ChatViewProps) {
surface.target.deviceId === session.deviceId,
);
if (existing) continue;
const device = deviceState.devices.find(
(entry) => entry.hostId === session.hostId && entry.id === session.deviceId,
);
if (!device) continue;
useRightPanelStore.getState().openDevice(
activeThreadRef,
{
hostId: session.hostId,
deviceId: session.deviceId,
platform: device.platform,
name: device.name,
},
true,
);
useRightPanelStore.getState().openDevice(activeThreadRef, target, true);
}
}, [
activeThreadRef,
autoShowFloatingPreview,
deviceStateLoaded,
shouldUseRightPanelSheet,
deviceState.sessions,
deviceState.devices,
]);
// A floating device follows its session: once the agent or another client
// closes the device there is nothing left to stream.
useEffect(() => {
if (!activeThreadRef || !deviceStateLoaded) return;
const source = activePreviewMiniPlayer?.source;
if (source?.kind !== "device") return;
const sessionStillExists = deviceState.sessions.some(
(session) =>
session.threadId === activeThreadRef.threadId &&
session.hostId === source.hostId &&
session.deviceId === source.deviceId,
);
if (!sessionStillExists) usePreviewMiniPlayerStore.getState().close(activeThreadRef);
}, [activePreviewMiniPlayer, activeThreadRef, deviceState.sessions, deviceStateLoaded]);
const openFileSurface = useCallback(
(relativePath: string) => {
if (!activeThreadRef || !activeProject) return;
Expand Down Expand Up @@ -4421,10 +4452,15 @@ export default function ChatView(props: ChatViewProps) {
]);
const closePreviewPanel = useCallback(() => {
if (activeThreadRef) {
// Closing the panel on a live browser or device floats it instead of dropping it.
if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) {
usePreviewMiniPlayerStore
.getState()
.open(activeThreadRef, activeRightPanelSurface.resourceId);
.open(activeThreadRef, browserMiniPlayerSource(activeRightPanelSurface.resourceId));
} else if (activeRightPanelSurface?.kind === "device" && activeRightPanelSurface.target) {
usePreviewMiniPlayerStore
.getState()
.open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target });
}
setMaximizedRightPanelThreadKey(null);
useRightPanelStore.getState().close(activeThreadRef);
Expand Down Expand Up @@ -8834,9 +8870,9 @@ export default function ChatView(props: ChatViewProps) {

{activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? (
<ThreadPreviewMiniPlayer
key={`${activeThreadKey}:${activePreviewMiniPlayer.tabId}`}
key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}
threadRef={activeThreadRef}
tabId={activePreviewMiniPlayer.tabId}
miniPlayer={activePreviewMiniPlayer}
bottomInset={isDraftHeroState ? 0 : composerOverlayHeight}
/>
) : null}
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/components/device/DevicePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
import {
ChevronLeft,
Home,
PictureInPicture2,
Power,
RotateCcw,
SlidersHorizontal,
Expand All @@ -16,6 +17,7 @@ import {
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";

import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore";
import { Button } from "~/components/ui/button";
import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list";
Expand Down Expand Up @@ -116,6 +118,19 @@ export function DevicePanel(props: {
}
};

// Floating the device closes the panel, like the browser's floating preview.
const floatActive = () => {
if (!activeDevice) return;
usePreviewMiniPlayerStore.getState().open(props.threadRef, {
kind: "device",
hostId: activeDevice.hostId,
deviceId: activeDevice.id,
platform: activeDevice.platform,
name: activeDevice.name,
});
useRightPanelStore.getState().close(props.threadRef);
};

const closeActive = (powerOff: boolean) => {
if (!powerOff) {
useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id);
Expand Down Expand Up @@ -218,6 +233,9 @@ export function DevicePanel(props: {
>
<SlidersHorizontal />
</Toggle>
<DeviceButton label="Float device over chat" onClick={floatActive}>
<PictureInPicture2 />
</DeviceButton>
<DeviceButton label="Power off" onClick={() => closeActive(true)}>
<Power />
</DeviceButton>
Expand Down
18 changes: 13 additions & 5 deletions apps/web/src/components/preview/PreviewAutomationHosts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ import {
reconcilePreviewServerSessions,
updatePreviewServerSnapshot,
} from "~/previewStateStore";
import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
import {
browserMiniPlayerSource,
selectThreadPreviewMiniPlayerTabId,
usePreviewMiniPlayerStore,
} from "~/previewMiniPlayerStore";
import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver";
import {
readActiveBrowserRecordingTargets,
Expand Down Expand Up @@ -378,7 +382,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
?.has(runtimeTabId) ?? false,
})
) {
usePreviewMiniPlayerStore.getState().open(threadRef, readyTabId);
usePreviewMiniPlayerStore
.getState()
.open(threadRef, browserMiniPlayerSource(readyTabId));
}
}
browserActivity.release ??= acquireBrowserSurfaceActivity(runtimeTabId);
Expand Down Expand Up @@ -493,11 +499,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
new Set([activeRuntimeTabId]),
);
}
const miniPlayer = selectThreadPreviewMiniPlayer(
const miniPlayerTabId = selectThreadPreviewMiniPlayerTabId(
usePreviewMiniPlayerStore.getState().byThreadKey,
threadRef,
);
if (miniPlayer?.tabId === activeTabId) {
if (miniPlayerTabId === activeTabId) {
usePreviewMiniPlayerStore.getState().close(threadRef);
}
} else if (shouldPresentPreview) {
Expand All @@ -507,7 +513,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
}
}
if (shouldPresentPreview) {
usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId);
usePreviewMiniPlayerStore
.getState()
.open(threadRef, browserMiniPlayerSource(activeTabId));
}
if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) {
await requireReadyTab();
Expand Down
Loading
Loading