authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-11 14:07:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 14:07:40-07:00
log8bbe2bf660c361e33db9c30f03fc72ab183eef3f
tree3c4b9df82d89538b95cb059bab235f4804589f0b
parente22040dfc190d04573238c5db2e69df72e5f1017
signature Signed by PGP key B5690EEEBB952194

feat(web): float device streams over chat (#11285)

Co-authored-by: Claude Code <noreply@anthropic.com>

15 files changed, 612 insertions(+), 167 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+44-5
...@@ -126,7 +126,7 @@ describe("floating browser preview", () => {...@@ -126,7 +126,7 @@ describe("floating browser preview", () => {
126 const ref = scopeThreadRef(EnvironmentId.make("env-1"), ThreadId.make("thread-1"));126 const ref = scopeThreadRef(EnvironmentId.make("env-1"), ThreadId.make("thread-1"));
127 const panels = useRightPanelStore.getState();127 const panels = useRightPanelStore.getState();
128 const revision = panels.getUserActionRevision(ref);128 const revision = panels.getUserActionRevision(ref);
129 usePreviewMiniPlayerStore.getState().open(ref, "agent-tab");129 usePreviewMiniPlayerStore.getState().open(ref, { kind: "browser", tabId: "agent-tab" });
130 panels.reconcileBrowserSurfaces(ref, ["agent-tab"]);130 panels.reconcileBrowserSurfaces(ref, ["agent-tab"]);
131 const intent = selectThreadPreviewMiniPlayer(131 const intent = selectThreadPreviewMiniPlayer(
132 usePreviewMiniPlayerStore.getState().byThreadKey,132 usePreviewMiniPlayerStore.getState().byThreadKey,
...@@ -135,7 +135,7 @@ describe("floating browser preview", () => {...@@ -135,7 +135,7 @@ describe("floating browser preview", () => {
135 const isFloating = () =>135 const isFloating = () =>
136 shouldRenderPreviewMiniPlayer(136 shouldRenderPreviewMiniPlayer(
137 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, ref)137 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, ref)
138 ?.tabId ?? null,138 ?.source ?? null,
139 selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref),139 selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref),
140 );140 );
141141
...@@ -152,22 +152,61 @@ describe("floating browser preview", () => {...@@ -152,22 +152,61 @@ describe("floating browser preview", () => {
152 });152 });
153153
154 it("only hides the duplicate while the same browser is rendered in the panel", () => {154 it("only hides the duplicate while the same browser is rendered in the panel", () => {
155 const tab = { kind: "browser", tabId: "tab-1" } as const;
155 expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false);156 expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false);
156 expect(157 expect(
157 shouldRenderPreviewMiniPlayer("tab-1", {158 shouldRenderPreviewMiniPlayer(tab, {
158 id: "browser:one",159 id: "browser:one",
159 kind: "preview",160 kind: "preview",
160 resourceId: "tab-1",161 resourceId: "tab-1",
161 }),162 }),
162 ).toBe(false);163 ).toBe(false);
163 expect(164 expect(
164 shouldRenderPreviewMiniPlayer("tab-1", {165 shouldRenderPreviewMiniPlayer(tab, {
165 id: "browser:two",166 id: "browser:two",
166 kind: "preview",167 kind: "preview",
167 resourceId: "tab-2",168 resourceId: "tab-2",
168 }),169 }),
169 ).toBe(true);170 ).toBe(true);
170 expect(shouldRenderPreviewMiniPlayer("tab-1", { id: "diff", kind: "diff" })).toBe(true);171 expect(shouldRenderPreviewMiniPlayer(tab, { id: "diff", kind: "diff" })).toBe(true);
172 });
173
174 it("only hides a floating device while that device is rendered in the panel", () => {
175 const pixel = {
176 kind: "device",
177 hostId: "nucbox",
178 deviceId: "emulator-5580",
179 platform: "android",
180 name: "Pixel",
181 } as const;
182 const target = {
183 hostId: "nucbox",
184 deviceId: "emulator-5580",
185 platform: "android",
186 name: "Pixel",
187 } as const;
188 expect(
189 shouldRenderPreviewMiniPlayer(pixel, {
190 id: "device:nucbox:emulator-5580",
191 kind: "device",
192 target,
193 }),
194 ).toBe(false);
195 expect(
196 shouldRenderPreviewMiniPlayer(pixel, {
197 id: "device:nucbox:emulator-5554",
198 kind: "device",
199 target: { ...target, deviceId: "emulator-5554" },
200 }),
201 ).toBe(true);
202 expect(shouldRenderPreviewMiniPlayer(pixel, { id: "device", kind: "device" })).toBe(true);
203 expect(
204 shouldRenderPreviewMiniPlayer(pixel, {
205 id: "browser:one",
206 kind: "preview",
207 resourceId: "emulator-5580",
208 }),
209 ).toBe(true);
171 });210 });
172});211});
173212
apps/web/src/components/ChatView.logic.ts+13-6
...@@ -50,6 +50,7 @@ import {...@@ -50,6 +50,7 @@ import {
50import type { DraftThreadEnvMode } from "../composerDraftStore";50import type { DraftThreadEnvMode } from "../composerDraftStore";
51import type { ComposerSubmissionIntent } from "../composer-logic";51import type { ComposerSubmissionIntent } from "../composer-logic";
52import type { TimelineEntry } from "../session-logic";52import type { TimelineEntry } from "../session-logic";
53import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore";
53import type { DesktopPreviewOverlay } from "../previewStateStore";54import type { DesktopPreviewOverlay } from "../previewStateStore";
54import type { RightPanelSurface } from "../rightPanelStore";55import type { RightPanelSurface } from "../rightPanelStore";
55import {56import {
...@@ -88,16 +89,22 @@ export function agentControlledBrowserCloseConfirmation(...@@ -88,16 +89,22 @@ export function agentControlledBrowserCloseConfirmation(
88 ].join("\n");89 ].join("\n");
89}90}
9091
92/** The floating player hides only while the same source is rendered in the panel. */
91export function shouldRenderPreviewMiniPlayer(93export function shouldRenderPreviewMiniPlayer(
92 miniPlayerTabId: string | null,94 source: PreviewMiniPlayerSource | null,
93 renderedRightPanelSurface: RightPanelSurface | null,95 renderedRightPanelSurface: RightPanelSurface | null,
94): boolean {96): boolean {
95 return (97 if (source === null) return false;
96 miniPlayerTabId !== null &&98 if (source.kind === "browser") {
97 !(99 return !(
98 renderedRightPanelSurface?.kind === "preview" &&100 renderedRightPanelSurface?.kind === "preview" &&
99 renderedRightPanelSurface.resourceId === miniPlayerTabId101 renderedRightPanelSurface.resourceId === source.tabId
100 )102 );
103 }
104 return !(
105 renderedRightPanelSurface?.kind === "device" &&
106 renderedRightPanelSurface.target?.hostId === source.hostId &&
107 renderedRightPanelSurface.target.deviceId === source.deviceId
101 );108 );
102}109}
103110
apps/web/src/components/ChatView.tsx+61-25
...@@ -195,6 +195,8 @@ import {...@@ -195,6 +195,8 @@ import {
195 useSidebarPendingFileDropStore,195 useSidebarPendingFileDropStore,
196} from "../sidebarPendingFileDropStore";196} from "../sidebarPendingFileDropStore";
197import {197import {
198 browserMiniPlayerSource,
199 previewMiniPlayerSourceKey,
198 selectThreadPreviewMiniPlayer,200 selectThreadPreviewMiniPlayer,
199 usePreviewMiniPlayerStore,201 usePreviewMiniPlayerStore,
200} from "../previewMiniPlayerStore";202} from "../previewMiniPlayerStore";
...@@ -566,6 +568,8 @@ const PreviewPanel = lazy(() =>...@@ -566,6 +568,8 @@ const PreviewPanel = lazy(() =>
566 import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),568 import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),
567);569);
568const DiffPanel = lazy(() => import("./DiffPanel"));570const DiffPanel = lazy(() => import("./DiffPanel"));
571const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) =>
572 settings.browserAutoShowFloatingPreview;
569const DevicePanel = lazy(() =>573const DevicePanel = lazy(() =>
570 import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })),574 import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })),
571);575);
...@@ -1958,7 +1962,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -1958,7 +1962,7 @@ export default function ChatView(props: ChatViewProps) {
1958 const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null;1962 const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null;
1959 const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? [];1963 const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? [];
1960 const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer(1964 const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer(
1961 activePreviewMiniPlayer?.tabId ?? null,1965 activePreviewMiniPlayer?.source ?? null,
1962 renderedRightPanelSurface,1966 renderedRightPanelSurface,
1963 );1967 );
1964 const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet;1968 const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet;
...@@ -1974,8 +1978,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -1974,8 +1978,10 @@ export default function ChatView(props: ChatViewProps) {
1974 }, [activePreviewState.sessions, activeThreadRef]);1978 }, [activePreviewState.sessions, activeThreadRef]);
19751979
1976 useEffect(() => {1980 useEffect(() => {
1977 if (!activeThreadRef || !activePreviewMiniPlayer) return;1981 if (!activeThreadRef || activePreviewMiniPlayer?.source.kind !== "browser") return;
1978 const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]);1982 const miniTabStillExists = Boolean(
1983 activePreviewState.sessions[activePreviewMiniPlayer.source.tabId],
1984 );
1979 if (!miniTabStillExists) {1985 if (!miniTabStillExists) {
1980 usePreviewMiniPlayerStore.getState().close(activeThreadRef);1986 usePreviewMiniPlayerStore.getState().close(activeThreadRef);
1981 }1987 }
...@@ -4215,9 +4221,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -4215,9 +4221,13 @@ export default function ChatView(props: ChatViewProps) {
4215 }4221 }
4216 useRightPanelStore.getState().open(activeThreadRef, "device");4222 useRightPanelStore.getState().open(activeThreadRef, "device");
4217 }, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]);4223 }, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]);
4218 // Reconcile new server sessions into separate tabs, including sessions opened4224 // A device the agent opens floats over chat like an agent-driven browser,
4219 // by an agent or another client. The first snapshot is a baseline: persisted4225 // or becomes a panel tab when floating previews are off. Sessions opened by
4220 // tabs restore themselves, and existing sessions must not resurrect closed tabs.4226 // another client arrive the same way; sheet layouts get neither. The first
4227 // snapshot is a baseline: persisted tabs restore themselves, and existing
4228 // sessions must not resurrect closed tabs. A session whose device summary
4229 // has not arrived yet stays out of the baseline so a later snapshot opens it.
4230 const autoShowFloatingPreview = useClientSettings(selectAutoShowFloatingPreview);
4221 const previousDeviceSessions = useRef(new Map<string, Set<string>>());4231 const previousDeviceSessions = useRef(new Map<string, Set<string>>());
4222 useEffect(() => {4232 useEffect(() => {
4223 if (!activeThreadRef || !deviceStateLoaded) return;4233 if (!activeThreadRef || !deviceStateLoaded) return;
...@@ -4226,11 +4236,30 @@ export default function ChatView(props: ChatViewProps) {...@@ -4226,11 +4236,30 @@ export default function ChatView(props: ChatViewProps) {
4226 (session) => session.threadId === activeThreadRef.threadId,4236 (session) => session.threadId === activeThreadRef.threadId,
4227 );4237 );
4228 const key = (session: (typeof sessions)[number]) => `${session.hostId}:${session.deviceId}`;4238 const key = (session: (typeof sessions)[number]) => `${session.hostId}:${session.deviceId}`;
4239 const deviceFor = (session: (typeof sessions)[number]) =>
4240 deviceState.devices.find(
4241 (entry) => entry.hostId === session.hostId && entry.id === session.deviceId,
4242 );
4229 const previous = previousDeviceSessions.current.get(threadKey);4243 const previous = previousDeviceSessions.current.get(threadKey);
4230 previousDeviceSessions.current.set(threadKey, new Set(sessions.map(key)));4244 previousDeviceSessions.current.set(
4245 threadKey,
4246 new Set(sessions.filter((session) => deviceFor(session) !== undefined).map(key)),
4247 );
4231 if (!previous || shouldUseRightPanelSheet) return;4248 if (!previous || shouldUseRightPanelSheet) return;
4232 for (const session of sessions) {4249 for (const session of sessions) {
4233 if (previous?.has(key(session))) continue;4250 if (previous.has(key(session))) continue;
4251 const device = deviceFor(session);
4252 if (!device) continue;
4253 const target = {
4254 hostId: session.hostId,
4255 deviceId: session.deviceId,
4256 platform: device.platform,
4257 name: device.name,
4258 };
4259 if (autoShowFloatingPreview) {
4260 usePreviewMiniPlayerStore.getState().open(activeThreadRef, { kind: "device", ...target });
4261 continue;
4262 }
4234 const existing = useRightPanelStore4263 const existing = useRightPanelStore
4235 .getState()4264 .getState()
4236 .byThreadKey[scopedThreadKey(activeThreadRef)]?.surfaces.some(4265 .byThreadKey[scopedThreadKey(activeThreadRef)]?.surfaces.some(
...@@ -4240,28 +4269,30 @@ export default function ChatView(props: ChatViewProps) {...@@ -4240,28 +4269,30 @@ export default function ChatView(props: ChatViewProps) {
4240 surface.target.deviceId === session.deviceId,4269 surface.target.deviceId === session.deviceId,
4241 );4270 );
4242 if (existing) continue;4271 if (existing) continue;
4243 const device = deviceState.devices.find(4272 useRightPanelStore.getState().openDevice(activeThreadRef, target, true);
4244 (entry) => entry.hostId === session.hostId && entry.id === session.deviceId,
4245 );
4246 if (!device) continue;
4247 useRightPanelStore.getState().openDevice(
4248 activeThreadRef,
4249 {
4250 hostId: session.hostId,
4251 deviceId: session.deviceId,
4252 platform: device.platform,
4253 name: device.name,
4254 },
4255 true,
4256 );
4257 }4273 }
4258 }, [4274 }, [
4259 activeThreadRef,4275 activeThreadRef,
4276 autoShowFloatingPreview,
4260 deviceStateLoaded,4277 deviceStateLoaded,
4261 shouldUseRightPanelSheet,4278 shouldUseRightPanelSheet,
4262 deviceState.sessions,4279 deviceState.sessions,
4263 deviceState.devices,4280 deviceState.devices,
4264 ]);4281 ]);
4282 // A floating device follows its session: once the agent or another client
4283 // closes the device there is nothing left to stream.
4284 useEffect(() => {
4285 if (!activeThreadRef || !deviceStateLoaded) return;
4286 const source = activePreviewMiniPlayer?.source;
4287 if (source?.kind !== "device") return;
4288 const sessionStillExists = deviceState.sessions.some(
4289 (session) =>
4290 session.threadId === activeThreadRef.threadId &&
4291 session.hostId === source.hostId &&
4292 session.deviceId === source.deviceId,
4293 );
4294 if (!sessionStillExists) usePreviewMiniPlayerStore.getState().close(activeThreadRef);
4295 }, [activePreviewMiniPlayer, activeThreadRef, deviceState.sessions, deviceStateLoaded]);
4265 const openFileSurface = useCallback(4296 const openFileSurface = useCallback(
4266 (relativePath: string) => {4297 (relativePath: string) => {
4267 if (!activeThreadRef || !activeProject) return;4298 if (!activeThreadRef || !activeProject) return;
...@@ -4421,10 +4452,15 @@ export default function ChatView(props: ChatViewProps) {...@@ -4421,10 +4452,15 @@ export default function ChatView(props: ChatViewProps) {
4421 ]);4452 ]);
4422 const closePreviewPanel = useCallback(() => {4453 const closePreviewPanel = useCallback(() => {
4423 if (activeThreadRef) {4454 if (activeThreadRef) {
4455 // Closing the panel on a live browser or device floats it instead of dropping it.
4424 if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) {4456 if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) {
4425 usePreviewMiniPlayerStore4457 usePreviewMiniPlayerStore
4426 .getState()4458 .getState()
4427 .open(activeThreadRef, activeRightPanelSurface.resourceId);4459 .open(activeThreadRef, browserMiniPlayerSource(activeRightPanelSurface.resourceId));
4460 } else if (activeRightPanelSurface?.kind === "device" && activeRightPanelSurface.target) {
4461 usePreviewMiniPlayerStore
4462 .getState()
4463 .open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target });
4428 }4464 }
4429 setMaximizedRightPanelThreadKey(null);4465 setMaximizedRightPanelThreadKey(null);
4430 useRightPanelStore.getState().close(activeThreadRef);4466 useRightPanelStore.getState().close(activeThreadRef);
...@@ -8834,9 +8870,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -8834,9 +8870,9 @@ export default function ChatView(props: ChatViewProps) {
88348870
8835 {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? (8871 {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? (
8836 <ThreadPreviewMiniPlayer8872 <ThreadPreviewMiniPlayer
8837 key={`${activeThreadKey}:${activePreviewMiniPlayer.tabId}`}8873 key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}
8838 threadRef={activeThreadRef}8874 threadRef={activeThreadRef}
8839 tabId={activePreviewMiniPlayer.tabId}8875 miniPlayer={activePreviewMiniPlayer}
8840 bottomInset={isDraftHeroState ? 0 : composerOverlayHeight}8876 bottomInset={isDraftHeroState ? 0 : composerOverlayHeight}
8841 />8877 />
8842 ) : null}8878 ) : null}
apps/web/src/components/device/DevicePanel.tsx+18
...@@ -7,6 +7,7 @@ import type {...@@ -7,6 +7,7 @@ import type {
7import {7import {
8 ChevronLeft,8 ChevronLeft,
9 Home,9 Home,
10 PictureInPicture2,
10 Power,11 Power,
11 RotateCcw,12 RotateCcw,
12 SlidersHorizontal,13 SlidersHorizontal,
...@@ -16,6 +17,7 @@ import {...@@ -16,6 +17,7 @@ import {
16} from "lucide-react";17} from "lucide-react";
17import { useEffect, useMemo, useState } from "react";18import { useEffect, useMemo, useState } from "react";
1819
20import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
19import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore";21import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore";
20import { Button } from "~/components/ui/button";22import { Button } from "~/components/ui/button";
21import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list";23import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list";
...@@ -116,6 +118,19 @@ export function DevicePanel(props: {...@@ -116,6 +118,19 @@ export function DevicePanel(props: {
116 }118 }
117 };119 };
118120
121 // Floating the device closes the panel, like the browser's floating preview.
122 const floatActive = () => {
123 if (!activeDevice) return;
124 usePreviewMiniPlayerStore.getState().open(props.threadRef, {
125 kind: "device",
126 hostId: activeDevice.hostId,
127 deviceId: activeDevice.id,
128 platform: activeDevice.platform,
129 name: activeDevice.name,
130 });
131 useRightPanelStore.getState().close(props.threadRef);
132 };
133
119 const closeActive = (powerOff: boolean) => {134 const closeActive = (powerOff: boolean) => {
120 if (!powerOff) {135 if (!powerOff) {
121 useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id);136 useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id);
...@@ -218,6 +233,9 @@ export function DevicePanel(props: {...@@ -218,6 +233,9 @@ export function DevicePanel(props: {
218 >233 >
219 <SlidersHorizontal />234 <SlidersHorizontal />
220 </Toggle>235 </Toggle>
236 <DeviceButton label="Float device over chat" onClick={floatActive}>
237 <PictureInPicture2 />
238 </DeviceButton>
221 <DeviceButton label="Power off" onClick={() => closeActive(true)}>239 <DeviceButton label="Power off" onClick={() => closeActive(true)}>
222 <Power />240 <Power />
223 </DeviceButton>241 </DeviceButton>
apps/web/src/components/preview/PreviewAutomationHosts.tsx+13-5
...@@ -29,7 +29,11 @@ import {...@@ -29,7 +29,11 @@ import {
29 reconcilePreviewServerSessions,29 reconcilePreviewServerSessions,
30 updatePreviewServerSnapshot,30 updatePreviewServerSnapshot,
31} from "~/previewStateStore";31} from "~/previewStateStore";
32import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";32import {
33 browserMiniPlayerSource,
34 selectThreadPreviewMiniPlayerTabId,
35 usePreviewMiniPlayerStore,
36} from "~/previewMiniPlayerStore";
33import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver";37import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver";
34import {38import {
35 readActiveBrowserRecordingTargets,39 readActiveBrowserRecordingTargets,
...@@ -378,7 +382,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -378,7 +382,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
378 ?.has(runtimeTabId) ?? false,382 ?.has(runtimeTabId) ?? false,
379 })383 })
380 ) {384 ) {
381 usePreviewMiniPlayerStore.getState().open(threadRef, readyTabId);385 usePreviewMiniPlayerStore
386 .getState()
387 .open(threadRef, browserMiniPlayerSource(readyTabId));
382 }388 }
383 }389 }
384 browserActivity.release ??= acquireBrowserSurfaceActivity(runtimeTabId);390 browserActivity.release ??= acquireBrowserSurfaceActivity(runtimeTabId);
...@@ -493,11 +499,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -493,11 +499,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
493 new Set([activeRuntimeTabId]),499 new Set([activeRuntimeTabId]),
494 );500 );
495 }501 }
496 const miniPlayer = selectThreadPreviewMiniPlayer(502 const miniPlayerTabId = selectThreadPreviewMiniPlayerTabId(
497 usePreviewMiniPlayerStore.getState().byThreadKey,503 usePreviewMiniPlayerStore.getState().byThreadKey,
498 threadRef,504 threadRef,
499 );505 );
500 if (miniPlayer?.tabId === activeTabId) {506 if (miniPlayerTabId === activeTabId) {
501 usePreviewMiniPlayerStore.getState().close(threadRef);507 usePreviewMiniPlayerStore.getState().close(threadRef);
502 }508 }
503 } else if (shouldPresentPreview) {509 } else if (shouldPresentPreview) {
...@@ -507,7 +513,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -507,7 +513,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
507 }513 }
508 }514 }
509 if (shouldPresentPreview) {515 if (shouldPresentPreview) {
510 usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId);516 usePreviewMiniPlayerStore
517 .getState()
518 .open(threadRef, browserMiniPlayerSource(activeTabId));
511 }519 }
512 if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) {520 if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) {
513 await requireReadyTab();521 await requireReadyTab();
apps/web/src/components/preview/PreviewView.test.tsx+9-5
...@@ -171,7 +171,7 @@ vi.mock("~/previewMiniPlayerStore", () => {...@@ -171,7 +171,7 @@ vi.mock("~/previewMiniPlayerStore", () => {
171 byThreadKey: mocks.miniPlayerTabId171 byThreadKey: mocks.miniPlayerTabId
172 ? {172 ? {
173 "environment-1:thread-1": {173 "environment-1:thread-1": {
174 tabId: mocks.miniPlayerTabId,174 source: { kind: "browser", tabId: mocks.miniPlayerTabId },
175 position: null,175 position: null,
176 },176 },
177 }177 }
...@@ -185,9 +185,10 @@ vi.mock("~/previewMiniPlayerStore", () => {...@@ -185,9 +185,10 @@ vi.mock("~/previewMiniPlayerStore", () => {
185 },185 },
186 );186 );
187 return {187 return {
188 selectThreadPreviewMiniPlayer: (188 browserMiniPlayerSource: (tabId: string) => ({ kind: "browser", tabId }),
189 byThreadKey: Record<string, { tabId: string; position: null }>,189 selectThreadPreviewMiniPlayerTabId: (
190 ) => byThreadKey["environment-1:thread-1"] ?? null,190 byThreadKey: Record<string, { source: { tabId: string }; position: null }>,
191 ) => byThreadKey["environment-1:thread-1"]?.source.tabId ?? null,
191 usePreviewMiniPlayerStore,192 usePreviewMiniPlayerStore,
192 };193 };
193});194});
...@@ -485,7 +486,10 @@ describe("PreviewView navigation", () => {...@@ -485,7 +486,10 @@ describe("PreviewView navigation", () => {
485 renderToStaticMarkup(<PreviewView {...props} />);486 renderToStaticMarkup(<PreviewView {...props} />);
486 expect(mocks.pictureInPicturePressed).toBe(false);487 expect(mocks.pictureInPicturePressed).toBe(false);
487 mocks.togglePictureInPicture?.();488 mocks.togglePictureInPicture?.();
488 expect(mocks.openMiniPlayer).toHaveBeenCalledWith(props.threadRef, "tab-1");489 expect(mocks.openMiniPlayer).toHaveBeenCalledWith(props.threadRef, {
490 kind: "browser",
491 tabId: "tab-1",
492 });
489 expect(mocks.closeRightPanel).toHaveBeenCalledWith(props.threadRef);493 expect(mocks.closeRightPanel).toHaveBeenCalledWith(props.threadRef);
490494
491 mocks.miniPlayerTabId = "tab-1";495 mocks.miniPlayerTabId = "tab-1";
apps/web/src/components/preview/PreviewView.tsx+11-7
...@@ -34,7 +34,11 @@ import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";...@@ -34,7 +34,11 @@ import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
34import { useEnvironmentHttpBaseUrl } from "~/state/environments";34import { useEnvironmentHttpBaseUrl } from "~/state/environments";
35import { previewEnvironment } from "~/state/preview";35import { previewEnvironment } from "~/state/preview";
36import { useAtomCommand } from "~/state/use-atom-command";36import { useAtomCommand } from "~/state/use-atom-command";
37import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";37import {
38 browserMiniPlayerSource,
39 selectThreadPreviewMiniPlayerTabId,
40 usePreviewMiniPlayerStore,
41} from "~/previewMiniPlayerStore";
38import { useRightPanelStore } from "~/rightPanelStore";42import { useRightPanelStore } from "~/rightPanelStore";
3943
40import { previewBridge } from "./previewBridge";44import { previewBridge } from "./previewBridge";
...@@ -114,8 +118,8 @@ export function PreviewView({...@@ -114,8 +118,8 @@ export function PreviewView({
114 threadRef,118 threadRef,
115 BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT,119 BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT,
116 );120 );
117 const miniPlayer = usePreviewMiniPlayerStore((state) =>121 const miniPlayerTabId = usePreviewMiniPlayerStore((state) =>
118 selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef),122 selectThreadPreviewMiniPlayerTabId(state.byThreadKey, threadRef),
119 );123 );
120 const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation);124 const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation);
121 const addImage = useComposerDraftStore((store) => store.addImage);125 const addImage = useComposerDraftStore((store) => store.addImage);
...@@ -311,13 +315,13 @@ export function PreviewView({...@@ -311,13 +315,13 @@ export function PreviewView({
311315
312 const handlePictureInPicture = useCallback(() => {316 const handlePictureInPicture = useCallback(() => {
313 if (!tabId) return;317 if (!tabId) return;
314 if (miniPlayer?.tabId === tabId) {318 if (miniPlayerTabId === tabId) {
315 usePreviewMiniPlayerStore.getState().close(threadRef);319 usePreviewMiniPlayerStore.getState().close(threadRef);
316 return;320 return;
317 }321 }
318 usePreviewMiniPlayerStore.getState().open(threadRef, tabId);322 usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(tabId));
319 useRightPanelStore.getState().close(threadRef);323 useRightPanelStore.getState().close(threadRef);
320 }, [miniPlayer?.tabId, tabId, threadRef]);324 }, [miniPlayerTabId, tabId, threadRef]);
321325
322 const handleNativePictureInPicture = useCallback(() => {326 const handleNativePictureInPicture = useCallback(() => {
323 if (!previewBridge || !runtimeTabId) return;327 if (!previewBridge || !runtimeTabId) return;
...@@ -722,7 +726,7 @@ export function PreviewView({...@@ -722,7 +726,7 @@ export function PreviewView({
722 captureDisabled={!desktopOverlay || isUnreachable}726 captureDisabled={!desktopOverlay || isUnreachable}
723 recording={recordingRuntimeTabId !== null}727 recording={recordingRuntimeTabId !== null}
724 onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined}728 onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined}
725 pictureInPicture={miniPlayer?.tabId === tabId}729 pictureInPicture={miniPlayerTabId === tabId}
726 pictureInPictureDisabled={!desktopOverlay?.hasWebContents || isUnreachable}730 pictureInPictureDisabled={!desktopOverlay?.hasWebContents || isUnreachable}
727 onPickElement={previewBridge && tabId ? handlePickElement : undefined}731 onPickElement={previewBridge && tabId ? handlePickElement : undefined}
728 pickActive={pickActive}732 pickActive={pickActive}
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+231-79
...@@ -2,7 +2,14 @@...@@ -2,7 +2,14 @@
22
3import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts";3import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts";
4import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react";4import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react";
5import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef, useState } from "react";5import {
6 type PointerEvent as ReactPointerEvent,
7 type ReactNode,
8 useCallback,
9 useLayoutEffect,
10 useRef,
11 useState,
12} from "react";
613
7import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";14import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
8import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";15import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
...@@ -15,17 +22,25 @@ import { cn } from "~/lib/utils";...@@ -15,17 +22,25 @@ import { cn } from "~/lib/utils";
15import { useThreadPreviewState } from "~/previewStateStore";22import { useThreadPreviewState } from "~/previewStateStore";
16import {23import {
17 type PreviewMiniPlayerSize,24 type PreviewMiniPlayerSize,
18 selectThreadPreviewMiniPlayer,25 type PreviewMiniPlayerSource,
26 type PreviewMiniPlayerState,
27 previewMiniPlayerSourceKey,
19 usePreviewMiniPlayerStore,28 usePreviewMiniPlayerStore,
20} from "~/previewMiniPlayerStore";29} from "~/previewMiniPlayerStore";
21import { useRightPanelStore } from "~/rightPanelStore";30import { useRightPanelStore } from "~/rightPanelStore";
31import { useDeviceState } from "~/state/device";
2232
33import { DeviceStreamView } from "../device/DeviceStreamView";
34import type { DeviceScreenSize } from "../device/deviceStream";
23import { previewBridge } from "./previewBridge";35import { previewBridge } from "./previewBridge";
24import {36import {
25 clampPreviewMiniPlayerPosition,37 clampPreviewMiniPlayerPosition,
38 PREVIEW_MINI_PLAYER_CORNER_RADIUS,
26 PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX,39 PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX,
27 type PreviewMiniPlayerFrame,40 type PreviewMiniPlayerFrame,
28 resizePreviewMiniPlayer,41 resizePreviewMiniPlayer,
42 resolveDeviceMiniPlayerCornerRadius,
43 resolveDeviceMiniPlayerSourceSize,
29 resolvePreviewMiniPlayerFrame,44 resolvePreviewMiniPlayerFrame,
30 resolvePreviewMiniPlayerSourceSize,45 resolvePreviewMiniPlayerSourceSize,
31} from "./previewMiniPlayerLayout";46} from "./previewMiniPlayerLayout";
...@@ -40,11 +55,11 @@ interface PointerGesture {...@@ -40,11 +55,11 @@ interface PointerGesture {
4055
41interface Props {56interface Props {
42 readonly threadRef: ScopedThreadRef;57 readonly threadRef: ScopedThreadRef;
43 readonly tabId: string;58 readonly miniPlayer: PreviewMiniPlayerState;
44 readonly bottomInset: number;59 readonly bottomInset: number;
45}60}
4661
47const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12;62const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS;
4863
49// Invisible grab zones straddling each edge; the cursor is the only affordance.64// Invisible grab zones straddling each edge; the cursor is the only affordance.
50const RESIZE_HANDLES: ReadonlyArray<{65const RESIZE_HANDLES: ReadonlyArray<{
...@@ -61,17 +76,34 @@ const RESIZE_HANDLES: ReadonlyArray<{...@@ -61,17 +76,34 @@ const RESIZE_HANDLES: ReadonlyArray<{
61 { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" },76 { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" },
62];77];
6378
64/**79/** Floats the thread's browser tab or device stream over chat. */
65 * Floats the thread's browser surface over chat. Native clipping and the DOM80export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, bottomInset }: Props) {
66 * frame use the same radius so their separately composited edges stay aligned.81 const { source } = miniPlayer;
67 */82 return source.kind === "browser" ? (
68export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props) {83 <BrowserMiniPlayer
69 const containerRef = useRef<HTMLDivElement | null>(null);84 key={source.tabId}
70 const gestureRef = useRef<PointerGesture | null>(null);85 threadRef={threadRef}
71 const [container, setContainer] = useState<PreviewMiniPlayerSize | null>(null);86 tabId={source.tabId}
72 const miniPlayer = usePreviewMiniPlayerStore((state) =>87 miniPlayer={miniPlayer}
73 selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef),88 bottomInset={bottomInset}
89 />
90 ) : (
91 <DeviceMiniPlayer
92 key={previewMiniPlayerSourceKey(source)}
93 threadRef={threadRef}
94 source={source}
95 miniPlayer={miniPlayer}
96 bottomInset={bottomInset}
97 />
74 );98 );
99}
100
101function BrowserMiniPlayer({
102 threadRef,
103 tabId,
104 miniPlayer,
105 bottomInset,
106}: Props & { readonly tabId: string }) {
75 const previewState = useThreadPreviewState(threadRef);107 const previewState = useThreadPreviewState(threadRef);
76 const snapshot = previewState.sessions[tabId] ?? null;108 const snapshot = previewState.sessions[tabId] ?? null;
77 const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId);109 const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId);
...@@ -79,25 +111,11 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props...@@ -79,25 +111,11 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
79 const fittedSourceContent = useBrowserSurfaceStore(111 const fittedSourceContent = useBrowserSurfaceStore(
80 (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null,112 (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null,
81 );113 );
82 const source = resolvePreviewMiniPlayerSourceSize(114 const sourceSize = resolvePreviewMiniPlayerSourceSize(
83 snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT,115 snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT,
84 fittedSourceContent,116 fittedSourceContent,
85 desktopOverlay?.zoomFactor ?? 1,117 desktopOverlay?.zoomFactor ?? 1,
86 );118 );
87 const frame =
88 container && miniPlayer?.tabId === tabId
89 ? resolvePreviewMiniPlayerFrame({
90 width: miniPlayer.width,
91 position: miniPlayer.position,
92 source,
93 container,
94 bottomInset,
95 })
96 : null;
97
98 const close = () => {
99 usePreviewMiniPlayerStore.getState().close(threadRef);
100 };
101119
102 const openInPanel = () => {120 const openInPanel = () => {
103 usePreviewMiniPlayerStore.getState().close(threadRef);121 usePreviewMiniPlayerStore.getState().close(threadRef);
...@@ -118,6 +136,176 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props...@@ -118,6 +136,176 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
118 });136 });
119 };137 };
120138
139 if (!snapshot) return null;
140
141 return (
142 <MiniPlayerShell
143 threadRef={threadRef}
144 miniPlayer={miniPlayer}
145 sourceSize={sourceSize}
146 bottomInset={bottomInset}
147 label="Floating browser preview"
148 onOpenInPanel={openInPanel}
149 pillActions={
150 <Tooltip>
151 <TooltipTrigger
152 render={
153 <Button
154 variant={desktopOverlay?.pictureInPicture ? "secondary" : "ghost"}
155 size="icon-xs"
156 aria-label={
157 desktopOverlay?.pictureInPicture
158 ? "Close popped-out preview"
159 : "Pop preview into separate window"
160 }
161 disabled={!desktopOverlay?.hasWebContents}
162 onPointerDown={(event) => event.stopPropagation()}
163 onClick={toggleNativePictureInPicture}
164 />
165 }
166 >
167 <PictureInPicture2 />
168 </TooltipTrigger>
169 <TooltipPopup side="top">
170 {desktopOverlay?.pictureInPicture
171 ? "Close separate window"
172 : "Pop into separate window"}
173 </TooltipPopup>
174 </Tooltip>
175 }
176 >
177 {(frame) => (
178 <>
179 <BrowserSurfaceSlot
180 tabId={runtimeTabId}
181 visible={Boolean(desktopOverlay?.hasWebContents)}
182 cornerRadius={PREVIEW_MINI_PLAYER_CORNER_RADIUS}
183 zIndex={PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX}
184 fitSourceContent
185 layoutVersion={`${frame.x}:${frame.y}`}
186 className="absolute inset-0"
187 />
188 {!desktopOverlay?.hasWebContents ? (
189 <div className="pointer-events-none absolute inset-0 z-[49] flex items-center justify-center rounded-[inherit] bg-muted text-xs text-muted-foreground">
190 Reconnecting preview…
191 </div>
192 ) : null}
193 </>
194 )}
195 </MiniPlayerShell>
196 );
197}
198
199function DeviceMiniPlayer({
200 threadRef,
201 source,
202 miniPlayer,
203 bottomInset,
204}: Props & { readonly source: Extract<PreviewMiniPlayerSource, { kind: "device" }> }) {
205 const { state: deviceState } = useDeviceState(threadRef.environmentId);
206 const [screen, setScreen] = useState<DeviceScreenSize | null>(null);
207 const sourceSize = resolveDeviceMiniPlayerSourceSize(source.platform, screen);
208 const device = deviceState.devices.find(
209 (entry) => entry.hostId === source.hostId && entry.id === source.deviceId,
210 );
211 const hostLabel =
212 deviceState.hosts.find((host) => host.id === source.hostId)?.label ?? "Device host";
213 const cornerRadius = useCallback(
214 (player: PreviewMiniPlayerSize) => resolveDeviceMiniPlayerCornerRadius(source.platform, player),
215 [source.platform],
216 );
217
218 const openInPanel = () => {
219 usePreviewMiniPlayerStore.getState().close(threadRef);
220 useRightPanelStore.getState().openDevice(threadRef, {
221 hostId: source.hostId,
222 deviceId: source.deviceId,
223 platform: source.platform,
224 name: source.name,
225 });
226 };
227
228 return (
229 <MiniPlayerShell
230 threadRef={threadRef}
231 miniPlayer={miniPlayer}
232 sourceSize={sourceSize}
233 bottomInset={bottomInset}
234 label="Floating device preview"
235 onOpenInPanel={openInPanel}
236 cornerRadius={cornerRadius}
237 >
238 {() => (
239 // The stream is DOM, so it takes the band the browser's native webview would.
240 <div
241 className="pointer-events-auto absolute inset-0 overflow-hidden rounded-[inherit]"
242 style={{ zIndex: PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX }}
243 >
244 <DeviceStreamView
245 environmentId={threadRef.environmentId}
246 platform={source.platform}
247 deviceId={source.deviceId}
248 hostId={source.hostId}
249 deviceName={device?.name ?? source.name}
250 deviceDescription={`${hostLabel} · ${device?.version ?? source.platform}`}
251 visible
252 onScreen={setScreen}
253 />
254 </div>
255 )}
256 </MiniPlayerShell>
257 );
258}
259
260/**
261 * The frame, drag/resize gestures, and hover pill shared by every floating
262 * source. Native clipping and the DOM frame use the same radius so their
263 * separately composited edges stay aligned.
264 */
265function MiniPlayerShell({
266 threadRef,
267 miniPlayer,
268 sourceSize,
269 bottomInset,
270 label,
271 onOpenInPanel,
272 pillActions,
273 cornerRadius = frameCornerRadius,
274 children,
275}: {
276 readonly threadRef: ScopedThreadRef;
277 readonly miniPlayer: PreviewMiniPlayerState;
278 readonly sourceSize: PreviewMiniPlayerSize;
279 readonly bottomInset: number;
280 readonly label: string;
281 readonly onOpenInPanel: () => void;
282 readonly pillActions?: ReactNode;
283 /** The clip radius for a given frame; the pill stays inside the curve. */
284 readonly cornerRadius?: (frame: PreviewMiniPlayerSize) => number;
285 readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode;
286}) {
287 const containerRef = useRef<HTMLDivElement | null>(null);
288 const gestureRef = useRef<PointerGesture | null>(null);
289 const [container, setContainer] = useState<PreviewMiniPlayerSize | null>(null);
290 const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source);
291 const frame = container
292 ? resolvePreviewMiniPlayerFrame({
293 width: miniPlayer.width,
294 position: miniPlayer.position,
295 source: sourceSize,
296 container,
297 bottomInset,
298 })
299 : null;
300
301 const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS;
302 // Inside a wide curve the default 8px inset would land on the clipped-away corner.
303 const pillInset = Math.max(8, Math.round(radius * 0.55));
304
305 const close = () => {
306 usePreviewMiniPlayerStore.getState().close(threadRef);
307 };
308
121 useLayoutEffect(() => {309 useLayoutEffect(() => {
122 const element = containerRef.current;310 const element = containerRef.current;
123 if (!element) return;311 if (!element) return;
...@@ -160,7 +348,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props...@@ -160,7 +348,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
160 if (gesture.direction === null) {348 if (gesture.direction === null) {
161 store.move(349 store.move(
162 threadRef,350 threadRef,
163 tabId,351 sourceKey,
164 clampPreviewMiniPlayerPosition(352 clampPreviewMiniPlayerPosition(
165 { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y },353 { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y },
166 container,354 container,
...@@ -174,12 +362,12 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props...@@ -174,12 +362,12 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
174 start: gesture.frame,362 start: gesture.frame,
175 direction: gesture.direction,363 direction: gesture.direction,
176 delta,364 delta,
177 source,365 source: sourceSize,
178 container,366 container,
179 bottomInset,367 bottomInset,
180 });368 });
181 store.resize(threadRef, tabId, next.width);369 store.resize(threadRef, sourceKey, next.width);
182 store.move(threadRef, tabId, { x: next.x, y: next.y });370 store.move(threadRef, sourceKey, { x: next.x, y: next.y });
183 };371 };
184372
185 const endGesture = (event: ReactPointerEvent<HTMLElement>) => {373 const endGesture = (event: ReactPointerEvent<HTMLElement>) => {
...@@ -190,24 +378,25 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props...@@ -190,24 +378,25 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
190 }378 }
191 };379 };
192380
193 if (!snapshot || miniPlayer?.tabId !== tabId) return null;
194
195 return (381 return (
196 <div ref={containerRef} className="pointer-events-none absolute inset-0">382 <div ref={containerRef} className="pointer-events-none absolute inset-0">
197 {frame ? (383 {frame ? (
198 <section384 <section
199 aria-label="Floating browser preview"385 aria-label={label}
200 data-preview-mini-player={tabId}386 data-preview-mini-player={sourceKey}
201 className="pointer-events-none absolute select-none"387 className="pointer-events-none absolute select-none"
202 style={{388 style={{
203 left: frame.x,389 left: frame.x,
204 top: frame.y,390 top: frame.y,
205 width: frame.width,391 width: frame.width,
206 height: frame.height,392 height: frame.height,
207 borderRadius: PREVIEW_MINI_PLAYER_CORNER_RADIUS,393 borderRadius: radius,
208 }}394 }}
209 >395 >
210 <div className="group pointer-events-auto absolute right-2 top-2 z-[49] size-3">396 <div
397 className="group pointer-events-auto absolute z-[49] size-3"
398 style={{ right: pillInset, top: pillInset }}
399 >
211 <div400 <div
212 aria-hidden="true"401 aria-hidden="true"
213 className="absolute right-0 top-0 size-2 rounded-full bg-foreground/25 shadow-sm ring-1 ring-background/70 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"402 className="absolute right-0 top-0 size-2 rounded-full bg-foreground/25 shadow-sm ring-1 ring-background/70 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
...@@ -227,7 +416,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props...@@ -227,7 +416,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
227 size="icon-xs"416 size="icon-xs"
228 aria-label="Open preview in right panel"417 aria-label="Open preview in right panel"
229 onPointerDown={(event) => event.stopPropagation()}418 onPointerDown={(event) => event.stopPropagation()}
230 onClick={openInPanel}419 onClick={onOpenInPanel}
231 />420 />
232 }421 }
233 >422 >
...@@ -235,31 +424,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props...@@ -235,31 +424,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
235 </TooltipTrigger>424 </TooltipTrigger>
236 <TooltipPopup side="top">Open in right panel</TooltipPopup>425 <TooltipPopup side="top">Open in right panel</TooltipPopup>
237 </Tooltip>426 </Tooltip>
238 <Tooltip>427 {pillActions}
239 <TooltipTrigger
240 render={
241 <Button
242 variant={desktopOverlay?.pictureInPicture ? "secondary" : "ghost"}
243 size="icon-xs"
244 aria-label={
245 desktopOverlay?.pictureInPicture
246 ? "Close popped-out preview"
247 : "Pop preview into separate window"
248 }
249 disabled={!desktopOverlay?.hasWebContents}
250 onPointerDown={(event) => event.stopPropagation()}
251 onClick={toggleNativePictureInPicture}
252 />
253 }
254 >
255 <PictureInPicture2 />
256 </TooltipTrigger>
257 <TooltipPopup side="top">
258 {desktopOverlay?.pictureInPicture
259 ? "Close separate window"
260 : "Pop into separate window"}
261 </TooltipPopup>
262 </Tooltip>
263 <Tooltip>428 <Tooltip>
264 <TooltipTrigger429 <TooltipTrigger
265 render={430 render={
...@@ -280,21 +445,8 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props...@@ -280,21 +445,8 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
280 </div>445 </div>
281446
282 <div className="absolute inset-0 z-[47] rounded-[inherit] bg-muted shadow-2xl/35" />447 <div className="absolute inset-0 z-[47] rounded-[inherit] bg-muted shadow-2xl/35" />
283 <BrowserSurfaceSlot448 {children(frame)}
284 tabId={runtimeTabId}
285 visible={Boolean(desktopOverlay?.hasWebContents)}
286 cornerRadius={PREVIEW_MINI_PLAYER_CORNER_RADIUS}
287 zIndex={PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX}
288 fitSourceContent
289 layoutVersion={`${frame.x}:${frame.y}`}
290 className="absolute inset-0"
291 />
292 <div className="pointer-events-none absolute inset-0 z-[49] rounded-[inherit] ring-1 ring-inset ring-border/80" />449 <div className="pointer-events-none absolute inset-0 z-[49] rounded-[inherit] ring-1 ring-inset ring-border/80" />
293 {!desktopOverlay?.hasWebContents ? (
294 <div className="pointer-events-none absolute inset-0 z-[49] flex items-center justify-center rounded-[inherit] bg-muted text-xs text-muted-foreground">
295 Reconnecting preview…
296 </div>
297 ) : null}
298 {RESIZE_HANDLES.map(({ direction, className }) => (450 {RESIZE_HANDLES.map(({ direction, className }) => (
299 <div451 <div
300 key={direction}452 key={direction}
apps/web/src/components/preview/previewMiniPlayerLayout.test.ts+46
...@@ -5,6 +5,8 @@ import {...@@ -5,6 +5,8 @@ import {
5 clampPreviewMiniPlayerPosition,5 clampPreviewMiniPlayerPosition,
6 PREVIEW_MINI_PLAYER_EDGE_GAP,6 PREVIEW_MINI_PLAYER_EDGE_GAP,
7 resizePreviewMiniPlayer,7 resizePreviewMiniPlayer,
8 resolveDeviceMiniPlayerCornerRadius,
9 resolveDeviceMiniPlayerSourceSize,
8 resolvePreviewMiniPlayerFrame,10 resolvePreviewMiniPlayerFrame,
9 resolvePreviewMiniPlayerSourceSize,11 resolvePreviewMiniPlayerSourceSize,
10} from "./previewMiniPlayerLayout";12} from "./previewMiniPlayerLayout";
...@@ -30,6 +32,50 @@ describe("resolvePreviewMiniPlayerSourceSize", () => {...@@ -30,6 +32,50 @@ describe("resolvePreviewMiniPlayerSourceSize", () => {
30 });32 });
31});33});
3234
35describe("resolveDeviceMiniPlayerSourceSize", () => {
36 it("stands in with the platform's phone shape until the stream reports a size", () => {
37 const ios = resolveDeviceMiniPlayerSourceSize("ios", null);
38 expect(ios.width / ios.height).toBeCloseTo(9 / 19.5);
39 const android = resolveDeviceMiniPlayerSourceSize("android", null);
40 expect(android.width / android.height).toBeCloseTo(9 / 20);
41 });
42
43 it("turns a rotated screen into a landscape box", () => {
44 const screen = { width: 1_179, height: 2_556, orientation: "landscape_left" } as const;
45 expect(resolveDeviceMiniPlayerSourceSize("ios", screen)).toEqual({
46 width: 2_556,
47 height: 1_179,
48 });
49 expect(
50 resolveDeviceMiniPlayerSourceSize("android", { ...screen, orientation: "portrait" }),
51 ).toEqual({ width: 1_179, height: 2_556 });
52 });
53
54 it("floats a phone at the minimum width rather than the default box", () => {
55 expect(
56 resolvePreviewMiniPlayerFrame({
57 width: null,
58 position: null,
59 source: resolveDeviceMiniPlayerSourceSize("ios", null),
60 container,
61 }),
62 ).toMatchObject({ width: 240, height: 520 });
63 });
64});
65
66describe("resolveDeviceMiniPlayerCornerRadius", () => {
67 it("rounds an Android player like a phone, scaled with its short side", () => {
68 expect(resolveDeviceMiniPlayerCornerRadius("android", { width: 240, height: 520 })).toBe(34);
69 expect(resolveDeviceMiniPlayerCornerRadius("android", { width: 520, height: 240 })).toBe(34);
70 expect(resolveDeviceMiniPlayerCornerRadius("android", { width: 60, height: 130 })).toBe(12);
71 });
72
73 it("keeps the frame radius for iOS, whose stream has square corners", () => {
74 expect(resolveDeviceMiniPlayerCornerRadius("ios", { width: 240, height: 520 })).toBe(12);
75 expect(resolveDeviceMiniPlayerCornerRadius("ios", { width: 720, height: 1_000 })).toBe(12);
76 });
77});
78
33describe("resolvePreviewMiniPlayerFrame", () => {79describe("resolvePreviewMiniPlayerFrame", () => {
34 it("opens at the source aspect ratio in the top-right corner", () => {80 it("opens at the source aspect ratio in the top-right corner", () => {
35 expect(81 expect(
apps/web/src/components/preview/previewMiniPlayerLayout.ts+44-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { PreviewViewportSetting } from "@t3tools/contracts";1import type { DevicePlatform, PreviewViewportSetting } from "@t3tools/contracts";
22
3import type { BrowserSurfaceContentPresentation } from "~/browser/browserSurfaceStore";3import type { BrowserSurfaceContentPresentation } from "~/browser/browserSurfaceStore";
4import {4import {
...@@ -7,7 +7,10 @@ import {...@@ -7,7 +7,10 @@ import {
7} from "~/browser/browserViewportLayout";7} from "~/browser/browserViewportLayout";
8import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore";8import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore";
99
10import type { DeviceScreenSize } from "../device/deviceStream";
11
10export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12;12export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12;
13export const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12;
11// The mini-player shell straddles this webview at 47 and 49; dialogs begin at 50.14// The mini-player shell straddles this webview at 47 and 49; dialogs begin at 50.
12export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48;15export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48;
13// A fresh player is the largest box at the source aspect ratio that fits here.16// A fresh player is the largest box at the source aspect ratio that fits here.
...@@ -34,6 +37,46 @@ export function resolvePreviewMiniPlayerSourceSize(...@@ -34,6 +37,46 @@ export function resolvePreviewMiniPlayerSourceSize(
34 };37 };
35}38}
3639
40/**
41 * The device screen as the user sees it, so a rotated phone floats as a
42 * landscape box. Before the stream reports its size the platform's usual phone
43 * shape stands in, matching the stream view's own placeholder aspect; the
44 * nominal width only keeps the source cap above any sensible player width.
45 */
46export function resolveDeviceMiniPlayerSourceSize(
47 platform: DevicePlatform,
48 screen: DeviceScreenSize | null,
49): PreviewMiniPlayerSize {
50 if (!screen) {
51 const width = 1_000;
52 return { width, height: width / (platform === "ios" ? 9 / 19.5 : 9 / 20) };
53 }
54 const landscape =
55 screen.orientation === "landscape_left" || screen.orientation === "landscape_right";
56 const long = Math.max(screen.width, screen.height);
57 const short = Math.min(screen.width, screen.height);
58 return landscape ? { width: long, height: short } : { width: short, height: long };
59}
60
61/**
62 * The Android emulator composites the skin's rounded corners into its
63 * framebuffer as black wedges (measured at ~13% of the short side on a
64 * Pixel 9), so its player clips at a matching phone-like radius; the sliver
65 * lost under the curve is status-bar padding. iOS simulators stream an
66 * edge-to-edge rectangle and keep the frame radius, which matters for iPads
67 * whose real corners are far tighter than a phone's.
68 */
69export function resolveDeviceMiniPlayerCornerRadius(
70 platform: DevicePlatform,
71 player: PreviewMiniPlayerSize,
72): number {
73 if (platform !== "android") return PREVIEW_MINI_PLAYER_CORNER_RADIUS;
74 return Math.max(
75 PREVIEW_MINI_PLAYER_CORNER_RADIUS,
76 Math.round(Math.min(player.width, player.height) * 0.14),
77 );
78}
79
37const availableArea = (80const availableArea = (
38 container: PreviewMiniPlayerSize,81 container: PreviewMiniPlayerSize,
39 bottomInset: number,82 bottomInset: number,
apps/web/src/components/settings/IntegrationsSettings.tsx+1-1
...@@ -718,7 +718,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled...@@ -718,7 +718,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled
718 return (718 return (
719 <SettingsRow719 <SettingsRow
720 {...searchableSetting("browser-auto-show-floating-preview")}720 {...searchableSetting("browser-auto-show-floating-preview")}
721 description="Show the floating preview when an agent opens a browser unless the agent says otherwise."721 description="Show the floating preview when an agent opens a browser or device unless the agent says otherwise."
722 resetAction={722 resetAction={
723 !disabled && autoShow !== DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW ? (723 !disabled && autoShow !== DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW ? (
724 <SettingResetButton724 <SettingResetButton
apps/web/src/components/settings/settingsSearch.ts+1-1
...@@ -519,7 +519,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -519,7 +519,7 @@ export const SETTINGS_SEARCH_ITEMS = [
519 id: "browser-auto-show-floating-preview",519 id: "browser-auto-show-floating-preview",
520 title: "Auto-show floating preview",520 title: "Auto-show floating preview",
521 to: "/settings/integrations",521 to: "/settings/integrations",
522 searchTerms: ["agent opens browser pop into view hide"],522 searchTerms: ["agent opens browser device simulator pop into view hide"],
523 },523 },
524 {524 {
525 id: "automatic-pull",525 id: "automatic-pull",
apps/web/src/previewMiniPlayerStore.test.ts+51-17
...@@ -2,10 +2,25 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment";...@@ -2,10 +2,25 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { type EnvironmentId, ThreadId } from "@t3tools/contracts";2import { type EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it } from "vite-plus/test";3import { beforeEach, describe, expect, it } from "vite-plus/test";
44
5import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "./previewMiniPlayerStore";5import {
6 browserMiniPlayerSource,
7 type PreviewMiniPlayerSource,
8 selectThreadPreviewMiniPlayer,
9 selectThreadPreviewMiniPlayerTabId,
10 usePreviewMiniPlayerStore,
11} from "./previewMiniPlayerStore";
612
7const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A"));13const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A"));
8const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B"));14const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B"));
15const tabA = browserMiniPlayerSource("tab-a");
16const tabB = browserMiniPlayerSource("tab-b");
17const pixel: PreviewMiniPlayerSource = {
18 kind: "device",
19 hostId: "nucbox",
20 deviceId: "emulator-5580",
21 platform: "android",
22 name: "Pixel",
23};
924
10beforeEach(() => {25beforeEach(() => {
11 usePreviewMiniPlayerStore.setState({ byThreadKey: {} });26 usePreviewMiniPlayerStore.setState({ byThreadKey: {} });
...@@ -13,52 +28,71 @@ beforeEach(() => {...@@ -13,52 +28,71 @@ beforeEach(() => {
1328
14describe("previewMiniPlayerStore", () => {29describe("previewMiniPlayerStore", () => {
15 it("keeps floating previews scoped to their thread", () => {30 it("keeps floating previews scoped to their thread", () => {
16 usePreviewMiniPlayerStore.getState().open(refA, "tab-a");31 usePreviewMiniPlayerStore.getState().open(refA, tabA);
17 usePreviewMiniPlayerStore.getState().open(refB, "tab-b");32 usePreviewMiniPlayerStore.getState().open(refB, tabB);
1833
19 expect(34 expect(
20 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),35 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),
21 ).toMatchObject({ tabId: "tab-a" });36 ).toMatchObject({ source: tabA });
22 expect(37 expect(
23 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refB),38 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refB),
24 ).toMatchObject({ tabId: "tab-b" });39 ).toMatchObject({ source: tabB });
25 });40 });
2641
27 it("preserves position when switching the floating tab within one thread", () => {42 it("preserves position when switching the floating tab within one thread", () => {
28 usePreviewMiniPlayerStore.getState().open(refA, "tab-a");43 usePreviewMiniPlayerStore.getState().open(refA, tabA);
29 usePreviewMiniPlayerStore.getState().move(refA, "tab-a", { x: 24, y: 48 });44 usePreviewMiniPlayerStore.getState().move(refA, "browser:tab-a", { x: 24, y: 48 });
30 usePreviewMiniPlayerStore.getState().open(refA, "tab-b");45 usePreviewMiniPlayerStore.getState().open(refA, tabB);
3146
32 expect(47 expect(
33 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),48 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),
34 ).toEqual({49 ).toEqual({
35 tabId: "tab-b",50 source: tabB,
36 position: { x: 24, y: 48 },51 position: { x: 24, y: 48 },
37 width: null,52 width: null,
38 });53 });
39 });54 });
4055
41 it("ignores stale drag updates after the floating tab changes", () => {56 it("ignores stale drag updates after the floating tab changes", () => {
42 usePreviewMiniPlayerStore.getState().open(refA, "tab-a");57 usePreviewMiniPlayerStore.getState().open(refA, tabA);
43 usePreviewMiniPlayerStore.getState().open(refA, "tab-b");58 usePreviewMiniPlayerStore.getState().open(refA, tabB);
44 usePreviewMiniPlayerStore.getState().move(refA, "tab-a", { x: 100, y: 100 });59 usePreviewMiniPlayerStore.getState().move(refA, "browser:tab-a", { x: 100, y: 100 });
4560
46 expect(61 expect(
47 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),62 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),
48 ).toEqual({63 ).toEqual({
49 tabId: "tab-b",64 source: tabB,
50 position: null,65 position: null,
51 width: null,66 width: null,
52 });67 });
53 });68 });
5469
55 it("preserves a thread-bound width while switching tabs", () => {70 it("preserves a thread-bound width while switching tabs", () => {
56 usePreviewMiniPlayerStore.getState().open(refA, "tab-a");71 usePreviewMiniPlayerStore.getState().open(refA, tabA);
57 usePreviewMiniPlayerStore.getState().resize(refA, "tab-a", 480);72 usePreviewMiniPlayerStore.getState().resize(refA, "browser:tab-a", 480);
58 usePreviewMiniPlayerStore.getState().open(refA, "tab-b");73 usePreviewMiniPlayerStore.getState().open(refA, tabB);
5974
60 expect(75 expect(
61 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),76 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),
62 ).toMatchObject({ tabId: "tab-b", width: 480 });77 ).toMatchObject({ source: tabB, width: 480 });
78 });
79
80 it("floats one source per thread, so a device replaces the browser tab", () => {
81 usePreviewMiniPlayerStore.getState().open(refA, tabA);
82 usePreviewMiniPlayerStore.getState().open(refA, pixel);
83 const floating = selectThreadPreviewMiniPlayer(
84 usePreviewMiniPlayerStore.getState().byThreadKey,
85 refA,
86 );
87
88 expect(floating).toMatchObject({ source: pixel });
89 expect(
90 selectThreadPreviewMiniPlayerTabId(usePreviewMiniPlayerStore.getState().byThreadKey, refA),
91 ).toBeNull();
92 // The same device under a new label is still the same floating source.
93 usePreviewMiniPlayerStore.getState().open(refA, { ...pixel, name: "Renamed" });
94 expect(
95 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),
96 ).toBe(floating);
63 });97 });
64});98});
apps/web/src/previewMiniPlayerStore.ts+60-13
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { scopedThreadKey } from "@t3tools/client-runtime/environment";1import { scopedThreadKey } from "@t3tools/client-runtime/environment";
2import type { ScopedThreadRef } from "@t3tools/contracts";2import type { DevicePlatform, ScopedThreadRef } from "@t3tools/contracts";
3import { create } from "zustand";3import { create } from "zustand";
44
5export interface PreviewMiniPlayerPosition {5export interface PreviewMiniPlayerPosition {
...@@ -12,34 +12,66 @@ export interface PreviewMiniPlayerSize {...@@ -12,34 +12,66 @@ export interface PreviewMiniPlayerSize {
12 readonly height: number;12 readonly height: number;
13}13}
1414
15/** What the floating player mirrors: a browser tab or a device stream. */
16export type PreviewMiniPlayerSource =
17 | { readonly kind: "browser"; readonly tabId: string }
18 | {
19 readonly kind: "device";
20 readonly hostId: string;
21 readonly deviceId: string;
22 readonly platform: DevicePlatform;
23 readonly name: string;
24 };
25
15export interface PreviewMiniPlayerState {26export interface PreviewMiniPlayerState {
16 readonly tabId: string;27 readonly source: PreviewMiniPlayerSource;
17 readonly position: PreviewMiniPlayerPosition | null;28 readonly position: PreviewMiniPlayerPosition | null;
18 /** Height always follows the previewed viewport's aspect ratio. */29 /** Height always follows the mirrored source's aspect ratio. */
19 readonly width: number | null;30 readonly width: number | null;
20}31}
2132
22interface PreviewMiniPlayerStoreState {33interface PreviewMiniPlayerStoreState {
23 readonly byThreadKey: Record<string, PreviewMiniPlayerState>;34 readonly byThreadKey: Record<string, PreviewMiniPlayerState>;
24 readonly open: (ref: ScopedThreadRef, tabId: string) => void;35 readonly open: (ref: ScopedThreadRef, source: PreviewMiniPlayerSource) => void;
25 readonly close: (ref: ScopedThreadRef) => void;36 readonly close: (ref: ScopedThreadRef) => void;
26 readonly move: (ref: ScopedThreadRef, tabId: string, position: PreviewMiniPlayerPosition) => void;37 /** `sourceKey` guards against a drag that outlives the source it started on. */
27 readonly resize: (ref: ScopedThreadRef, tabId: string, width: number) => void;38 readonly move: (
39 ref: ScopedThreadRef,
40 sourceKey: string,
41 position: PreviewMiniPlayerPosition,
42 ) => void;
43 readonly resize: (ref: ScopedThreadRef, sourceKey: string, width: number) => void;
28 readonly removeThread: (ref: ScopedThreadRef) => void;44 readonly removeThread: (ref: ScopedThreadRef) => void;
29}45}
3046
47export function previewMiniPlayerSourceKey(source: PreviewMiniPlayerSource): string {
48 return source.kind === "browser"
49 ? `browser:${source.tabId}`
50 : `device:${encodeURIComponent(source.hostId)}:${encodeURIComponent(source.deviceId)}`;
51}
52
53export const browserMiniPlayerSource = (tabId: string): PreviewMiniPlayerSource => ({
54 kind: "browser",
55 tabId,
56});
57
31export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((set) => ({58export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((set) => ({
32 byThreadKey: {},59 byThreadKey: {},
33 open: (ref, tabId) =>60 open: (ref, source) =>
34 set((state) => {61 set((state) => {
35 const threadKey = scopedThreadKey(ref);62 const threadKey = scopedThreadKey(ref);
36 const current = state.byThreadKey[threadKey];63 const current = state.byThreadKey[threadKey];
37 if (current?.tabId === tabId) return state;64 if (
65 current &&
66 previewMiniPlayerSourceKey(current.source) === previewMiniPlayerSourceKey(source)
67 ) {
68 return state;
69 }
38 return {70 return {
39 byThreadKey: {71 byThreadKey: {
40 ...state.byThreadKey,72 ...state.byThreadKey,
41 [threadKey]: {73 [threadKey]: {
42 tabId,74 source,
43 position: current?.position ?? null,75 position: current?.position ?? null,
44 width: current?.width ?? null,76 width: current?.width ?? null,
45 },77 },
...@@ -53,11 +85,11 @@ export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((...@@ -53,11 +85,11 @@ export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((
53 const { [threadKey]: _closed, ...byThreadKey } = state.byThreadKey;85 const { [threadKey]: _closed, ...byThreadKey } = state.byThreadKey;
54 return { byThreadKey };86 return { byThreadKey };
55 }),87 }),
56 move: (ref, tabId, position) =>88 move: (ref, sourceKey, position) =>
57 set((state) => {89 set((state) => {
58 const threadKey = scopedThreadKey(ref);90 const threadKey = scopedThreadKey(ref);
59 const current = state.byThreadKey[threadKey];91 const current = state.byThreadKey[threadKey];
60 if (!current || current.tabId !== tabId) return state;92 if (!current || previewMiniPlayerSourceKey(current.source) !== sourceKey) return state;
61 if (current.position?.x === position.x && current.position.y === position.y) return state;93 if (current.position?.x === position.x && current.position.y === position.y) return state;
62 return {94 return {
63 byThreadKey: {95 byThreadKey: {
...@@ -66,11 +98,17 @@ export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((...@@ -66,11 +98,17 @@ export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((
66 },98 },
67 };99 };
68 }),100 }),
69 resize: (ref, tabId, width) =>101 resize: (ref, sourceKey, width) =>
70 set((state) => {102 set((state) => {
71 const threadKey = scopedThreadKey(ref);103 const threadKey = scopedThreadKey(ref);
72 const current = state.byThreadKey[threadKey];104 const current = state.byThreadKey[threadKey];
73 if (!current || current.tabId !== tabId || current.width === width) return state;105 if (
106 !current ||
107 previewMiniPlayerSourceKey(current.source) !== sourceKey ||
108 current.width === width
109 ) {
110 return state;
111 }
74 return {112 return {
75 byThreadKey: {113 byThreadKey: {
76 ...state.byThreadKey,114 ...state.byThreadKey,
...@@ -94,3 +132,12 @@ export function selectThreadPreviewMiniPlayer(...@@ -94,3 +132,12 @@ export function selectThreadPreviewMiniPlayer(
94 if (!ref) return null;132 if (!ref) return null;
95 return byThreadKey[scopedThreadKey(ref)] ?? null;133 return byThreadKey[scopedThreadKey(ref)] ?? null;
96}134}
135
136/** The floating browser tab, or null when nothing floats or a device does. */
137export function selectThreadPreviewMiniPlayerTabId(
138 byThreadKey: Record<string, PreviewMiniPlayerState>,
139 ref: ScopedThreadRef | null | undefined,
140): string | null {
141 const source = selectThreadPreviewMiniPlayer(byThreadKey, ref)?.source;
142 return source?.kind === "browser" ? source.tabId : null;
143}
docs/user/devices.md+9-2
...@@ -18,6 +18,10 @@ device to boot it. The panel shows when you or an agent starts a device....@@ -18,6 +18,10 @@ device to boot it. The panel shows when you or an agent starts a device.
18Each device opens in its own tab. Use **+ → Device** to open another, and18Each device opens in its own tab. Use **+ → Device** to open another, and
19double-click a tab name or choose **Rename** from its context menu to rename it.19double-click a tab name or choose **Rename** from its context menu to rename it.
20Only the visible tab streams video; switching tabs keeps both devices running.20Only the visible tab streams video; switching tabs keeps both devices running.
21Choose **Float device over chat** in the toolbar to keep watching and tapping the
22device in a small window while the right panel shows something else; drag the
23window by its handle, resize it from any edge, and use **Open in right panel**
24to bring it back.
21Turn off the device hub in **Settings → Integrations → Devices** to stop the25Turn off the device hub in **Settings → Integrations → Devices** to stop the
22helper processes; simulators and emulators keep running until you power them26helper processes; simulators and emulators keep running until you power them
23off.27off.
...@@ -48,8 +52,11 @@ back from the device after a change....@@ -48,8 +52,11 @@ back from the device after a change.
4852
49## Agents and devices53## Agents and devices
5054
51When an agent opens a device, the panel opens in web and desktop clients connected55When an agent opens a device, it floats over the chat in web and desktop clients
52to the thread. Mobile clients show device activity in the thread timeline. Agents drive the device through the `agent-device` command line. T356connected to the thread, the same way an agent-driven browser does. Turn off
57**Auto-show floating preview** in **Settings → Integrations → Browser** to open a
58right-panel tab instead. Mobile clients show device activity in the thread
59timeline. Agents drive the device through the `agent-device` command line. T3
53Code installs and starts it only after **Agent device access** is enabled. iOS60Code installs and starts it only after **Agent device access** is enabled. iOS
54taps build a small test runner on first use, which takes a couple of minutes61taps build a small test runner on first use, which takes a couple of minutes
55once per server. Restart an existing agent session after granting access so it62once per server. Restart an existing agent session after granting access so it