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", () => {
126126 const ref = scopeThreadRef(EnvironmentId.make("env-1"), ThreadId.make("thread-1"));
127127 const panels = useRightPanelStore.getState();
128128 const revision = panels.getUserActionRevision(ref);
129 usePreviewMiniPlayerStore.getState().open(ref, "agent-tab");
129 usePreviewMiniPlayerStore.getState().open(ref, { kind: "browser", tabId: "agent-tab" });
130130 panels.reconcileBrowserSurfaces(ref, ["agent-tab"]);
131131 const intent = selectThreadPreviewMiniPlayer(
132132 usePreviewMiniPlayerStore.getState().byThreadKey,
......@@ -135,7 +135,7 @@ describe("floating browser preview", () => {
135135 const isFloating = () =>
136136 shouldRenderPreviewMiniPlayer(
137137 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, ref)
138 ?.tabId ?? null,
138 ?.source ?? null,
139139 selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref),
140140 );
141141
......@@ -152,22 +152,61 @@ describe("floating browser preview", () => {
152152 });
153153
154154 it("only hides the duplicate while the same browser is rendered in the panel", () => {
155 const tab = { kind: "browser", tabId: "tab-1" } as const;
155156 expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false);
156157 expect(
157 shouldRenderPreviewMiniPlayer("tab-1", {
158 shouldRenderPreviewMiniPlayer(tab, {
158159 id: "browser:one",
159160 kind: "preview",
160161 resourceId: "tab-1",
161162 }),
162163 ).toBe(false);
163164 expect(
164 shouldRenderPreviewMiniPlayer("tab-1", {
165 shouldRenderPreviewMiniPlayer(tab, {
165166 id: "browser:two",
166167 kind: "preview",
167168 resourceId: "tab-2",
168169 }),
169170 ).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);
171210 });
172211});
173212
apps/web/src/components/ChatView.logic.ts+13-6
......@@ -50,6 +50,7 @@ import {
5050import type { DraftThreadEnvMode } from "../composerDraftStore";
5151import type { ComposerSubmissionIntent } from "../composer-logic";
5252import type { TimelineEntry } from "../session-logic";
53import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore";
5354import type { DesktopPreviewOverlay } from "../previewStateStore";
5455import type { RightPanelSurface } from "../rightPanelStore";
5556import {
......@@ -88,16 +89,22 @@ export function agentControlledBrowserCloseConfirmation(
8889 ].join("\n");
8990}
9091
92/** The floating player hides only while the same source is rendered in the panel. */
9193export function shouldRenderPreviewMiniPlayer(
92 miniPlayerTabId: string | null,
94 source: PreviewMiniPlayerSource | null,
9395 renderedRightPanelSurface: RightPanelSurface | null,
9496): boolean {
95 return (
96 miniPlayerTabId !== null &&
97 !(
97 if (source === null) return false;
98 if (source.kind === "browser") {
99 return !(
98100 renderedRightPanelSurface?.kind === "preview" &&
99 renderedRightPanelSurface.resourceId === miniPlayerTabId
100 )
101 renderedRightPanelSurface.resourceId === source.tabId
102 );
103 }
104 return !(
105 renderedRightPanelSurface?.kind === "device" &&
106 renderedRightPanelSurface.target?.hostId === source.hostId &&
107 renderedRightPanelSurface.target.deviceId === source.deviceId
101108 );
102109}
103110
apps/web/src/components/ChatView.tsx+61-25
......@@ -195,6 +195,8 @@ import {
195195 useSidebarPendingFileDropStore,
196196} from "../sidebarPendingFileDropStore";
197197import {
198 browserMiniPlayerSource,
199 previewMiniPlayerSourceKey,
198200 selectThreadPreviewMiniPlayer,
199201 usePreviewMiniPlayerStore,
200202} from "../previewMiniPlayerStore";
......@@ -566,6 +568,8 @@ const PreviewPanel = lazy(() =>
566568 import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),
567569);
568570const DiffPanel = lazy(() => import("./DiffPanel"));
571const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) =>
572 settings.browserAutoShowFloatingPreview;
569573const DevicePanel = lazy(() =>
570574 import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })),
571575);
......@@ -1958,7 +1962,7 @@ export default function ChatView(props: ChatViewProps) {
19581962 const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null;
19591963 const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? [];
19601964 const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer(
1961 activePreviewMiniPlayer?.tabId ?? null,
1965 activePreviewMiniPlayer?.source ?? null,
19621966 renderedRightPanelSurface,
19631967 );
19641968 const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet;
......@@ -1974,8 +1978,10 @@ export default function ChatView(props: ChatViewProps) {
19741978 }, [activePreviewState.sessions, activeThreadRef]);
19751979
19761980 useEffect(() => {
1977 if (!activeThreadRef || !activePreviewMiniPlayer) return;
1978 const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]);
1981 if (!activeThreadRef || activePreviewMiniPlayer?.source.kind !== "browser") return;
1982 const miniTabStillExists = Boolean(
1983 activePreviewState.sessions[activePreviewMiniPlayer.source.tabId],
1984 );
19791985 if (!miniTabStillExists) {
19801986 usePreviewMiniPlayerStore.getState().close(activeThreadRef);
19811987 }
......@@ -4215,9 +4221,13 @@ export default function ChatView(props: ChatViewProps) {
42154221 }
42164222 useRightPanelStore.getState().open(activeThreadRef, "device");
42174223 }, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]);
4218 // Reconcile new server sessions into separate tabs, including sessions opened
4219 // by an agent or another client. The first snapshot is a baseline: persisted
4220 // tabs restore themselves, and existing sessions must not resurrect closed tabs.
4224 // A device the agent opens floats over chat like an agent-driven browser,
4225 // or becomes a panel tab when floating previews are off. Sessions opened by
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);
42214231 const previousDeviceSessions = useRef(new Map<string, Set<string>>());
42224232 useEffect(() => {
42234233 if (!activeThreadRef || !deviceStateLoaded) return;
......@@ -4226,11 +4236,30 @@ export default function ChatView(props: ChatViewProps) {
42264236 (session) => session.threadId === activeThreadRef.threadId,
42274237 );
42284238 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 );
42294243 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 );
42314248 if (!previous || shouldUseRightPanelSheet) return;
42324249 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 }
42344263 const existing = useRightPanelStore
42354264 .getState()
42364265 .byThreadKey[scopedThreadKey(activeThreadRef)]?.surfaces.some(
......@@ -4240,28 +4269,30 @@ export default function ChatView(props: ChatViewProps) {
42404269 surface.target.deviceId === session.deviceId,
42414270 );
42424271 if (existing) continue;
4243 const device = deviceState.devices.find(
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 );
4272 useRightPanelStore.getState().openDevice(activeThreadRef, target, true);
42574273 }
42584274 }, [
42594275 activeThreadRef,
4276 autoShowFloatingPreview,
42604277 deviceStateLoaded,
42614278 shouldUseRightPanelSheet,
42624279 deviceState.sessions,
42634280 deviceState.devices,
42644281 ]);
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]);
42654296 const openFileSurface = useCallback(
42664297 (relativePath: string) => {
42674298 if (!activeThreadRef || !activeProject) return;
......@@ -4421,10 +4452,15 @@ export default function ChatView(props: ChatViewProps) {
44214452 ]);
44224453 const closePreviewPanel = useCallback(() => {
44234454 if (activeThreadRef) {
4455 // Closing the panel on a live browser or device floats it instead of dropping it.
44244456 if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) {
44254457 usePreviewMiniPlayerStore
44264458 .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 });
44284464 }
44294465 setMaximizedRightPanelThreadKey(null);
44304466 useRightPanelStore.getState().close(activeThreadRef);
......@@ -8834,9 +8870,9 @@ export default function ChatView(props: ChatViewProps) {
88348870
88358871 {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? (
88368872 <ThreadPreviewMiniPlayer
8837 key={`${activeThreadKey}:${activePreviewMiniPlayer.tabId}`}
8873 key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}
88388874 threadRef={activeThreadRef}
8839 tabId={activePreviewMiniPlayer.tabId}
8875 miniPlayer={activePreviewMiniPlayer}
88408876 bottomInset={isDraftHeroState ? 0 : composerOverlayHeight}
88418877 />
88428878 ) : null}
apps/web/src/components/device/DevicePanel.tsx+18
......@@ -7,6 +7,7 @@ import type {
77import {
88 ChevronLeft,
99 Home,
10 PictureInPicture2,
1011 Power,
1112 RotateCcw,
1213 SlidersHorizontal,
......@@ -16,6 +17,7 @@ import {
1617} from "lucide-react";
1718import { useEffect, useMemo, useState } from "react";
1819
20import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
1921import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore";
2022import { Button } from "~/components/ui/button";
2123import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list";
......@@ -116,6 +118,19 @@ export function DevicePanel(props: {
116118 }
117119 };
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
119134 const closeActive = (powerOff: boolean) => {
120135 if (!powerOff) {
121136 useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id);
......@@ -218,6 +233,9 @@ export function DevicePanel(props: {
218233 >
219234 <SlidersHorizontal />
220235 </Toggle>
236 <DeviceButton label="Float device over chat" onClick={floatActive}>
237 <PictureInPicture2 />
238 </DeviceButton>
221239 <DeviceButton label="Power off" onClick={() => closeActive(true)}>
222240 <Power />
223241 </DeviceButton>
apps/web/src/components/preview/PreviewAutomationHosts.tsx+13-5
......@@ -29,7 +29,11 @@ import {
2929 reconcilePreviewServerSessions,
3030 updatePreviewServerSnapshot,
3131} from "~/previewStateStore";
32import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
32import {
33 browserMiniPlayerSource,
34 selectThreadPreviewMiniPlayerTabId,
35 usePreviewMiniPlayerStore,
36} from "~/previewMiniPlayerStore";
3337import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver";
3438import {
3539 readActiveBrowserRecordingTargets,
......@@ -378,7 +382,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
378382 ?.has(runtimeTabId) ?? false,
379383 })
380384 ) {
381 usePreviewMiniPlayerStore.getState().open(threadRef, readyTabId);
385 usePreviewMiniPlayerStore
386 .getState()
387 .open(threadRef, browserMiniPlayerSource(readyTabId));
382388 }
383389 }
384390 browserActivity.release ??= acquireBrowserSurfaceActivity(runtimeTabId);
......@@ -493,11 +499,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
493499 new Set([activeRuntimeTabId]),
494500 );
495501 }
496 const miniPlayer = selectThreadPreviewMiniPlayer(
502 const miniPlayerTabId = selectThreadPreviewMiniPlayerTabId(
497503 usePreviewMiniPlayerStore.getState().byThreadKey,
498504 threadRef,
499505 );
500 if (miniPlayer?.tabId === activeTabId) {
506 if (miniPlayerTabId === activeTabId) {
501507 usePreviewMiniPlayerStore.getState().close(threadRef);
502508 }
503509 } else if (shouldPresentPreview) {
......@@ -507,7 +513,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
507513 }
508514 }
509515 if (shouldPresentPreview) {
510 usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId);
516 usePreviewMiniPlayerStore
517 .getState()
518 .open(threadRef, browserMiniPlayerSource(activeTabId));
511519 }
512520 if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) {
513521 await requireReadyTab();
apps/web/src/components/preview/PreviewView.test.tsx+9-5
......@@ -171,7 +171,7 @@ vi.mock("~/previewMiniPlayerStore", () => {
171171 byThreadKey: mocks.miniPlayerTabId
172172 ? {
173173 "environment-1:thread-1": {
174 tabId: mocks.miniPlayerTabId,
174 source: { kind: "browser", tabId: mocks.miniPlayerTabId },
175175 position: null,
176176 },
177177 }
......@@ -185,9 +185,10 @@ vi.mock("~/previewMiniPlayerStore", () => {
185185 },
186186 );
187187 return {
188 selectThreadPreviewMiniPlayer: (
189 byThreadKey: Record<string, { tabId: string; position: null }>,
190 ) => byThreadKey["environment-1:thread-1"] ?? null,
188 browserMiniPlayerSource: (tabId: string) => ({ kind: "browser", tabId }),
189 selectThreadPreviewMiniPlayerTabId: (
190 byThreadKey: Record<string, { source: { tabId: string }; position: null }>,
191 ) => byThreadKey["environment-1:thread-1"]?.source.tabId ?? null,
191192 usePreviewMiniPlayerStore,
192193 };
193194});
......@@ -485,7 +486,10 @@ describe("PreviewView navigation", () => {
485486 renderToStaticMarkup(<PreviewView {...props} />);
486487 expect(mocks.pictureInPicturePressed).toBe(false);
487488 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 });
489493 expect(mocks.closeRightPanel).toHaveBeenCalledWith(props.threadRef);
490494
491495 mocks.miniPlayerTabId = "tab-1";
apps/web/src/components/preview/PreviewView.tsx+11-7
......@@ -34,7 +34,11 @@ import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
3434import { useEnvironmentHttpBaseUrl } from "~/state/environments";
3535import { previewEnvironment } from "~/state/preview";
3636import { useAtomCommand } from "~/state/use-atom-command";
37import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
37import {
38 browserMiniPlayerSource,
39 selectThreadPreviewMiniPlayerTabId,
40 usePreviewMiniPlayerStore,
41} from "~/previewMiniPlayerStore";
3842import { useRightPanelStore } from "~/rightPanelStore";
3943
4044import { previewBridge } from "./previewBridge";
......@@ -114,8 +118,8 @@ export function PreviewView({
114118 threadRef,
115119 BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT,
116120 );
117 const miniPlayer = usePreviewMiniPlayerStore((state) =>
118 selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef),
121 const miniPlayerTabId = usePreviewMiniPlayerStore((state) =>
122 selectThreadPreviewMiniPlayerTabId(state.byThreadKey, threadRef),
119123 );
120124 const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation);
121125 const addImage = useComposerDraftStore((store) => store.addImage);
......@@ -311,13 +315,13 @@ export function PreviewView({
311315
312316 const handlePictureInPicture = useCallback(() => {
313317 if (!tabId) return;
314 if (miniPlayer?.tabId === tabId) {
318 if (miniPlayerTabId === tabId) {
315319 usePreviewMiniPlayerStore.getState().close(threadRef);
316320 return;
317321 }
318 usePreviewMiniPlayerStore.getState().open(threadRef, tabId);
322 usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(tabId));
319323 useRightPanelStore.getState().close(threadRef);
320 }, [miniPlayer?.tabId, tabId, threadRef]);
324 }, [miniPlayerTabId, tabId, threadRef]);
321325
322326 const handleNativePictureInPicture = useCallback(() => {
323327 if (!previewBridge || !runtimeTabId) return;
......@@ -722,7 +726,7 @@ export function PreviewView({
722726 captureDisabled={!desktopOverlay || isUnreachable}
723727 recording={recordingRuntimeTabId !== null}
724728 onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined}
725 pictureInPicture={miniPlayer?.tabId === tabId}
729 pictureInPicture={miniPlayerTabId === tabId}
726730 pictureInPictureDisabled={!desktopOverlay?.hasWebContents || isUnreachable}
727731 onPickElement={previewBridge && tabId ? handlePickElement : undefined}
728732 pickActive={pickActive}
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+231-79
......@@ -2,7 +2,14 @@
22
33import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts";
44import { 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
714import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
815import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
......@@ -15,17 +22,25 @@ import { cn } from "~/lib/utils";
1522import { useThreadPreviewState } from "~/previewStateStore";
1623import {
1724 type PreviewMiniPlayerSize,
18 selectThreadPreviewMiniPlayer,
25 type PreviewMiniPlayerSource,
26 type PreviewMiniPlayerState,
27 previewMiniPlayerSourceKey,
1928 usePreviewMiniPlayerStore,
2029} from "~/previewMiniPlayerStore";
2130import { useRightPanelStore } from "~/rightPanelStore";
31import { useDeviceState } from "~/state/device";
2232
33import { DeviceStreamView } from "../device/DeviceStreamView";
34import type { DeviceScreenSize } from "../device/deviceStream";
2335import { previewBridge } from "./previewBridge";
2436import {
2537 clampPreviewMiniPlayerPosition,
38 PREVIEW_MINI_PLAYER_CORNER_RADIUS,
2639 PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX,
2740 type PreviewMiniPlayerFrame,
2841 resizePreviewMiniPlayer,
42 resolveDeviceMiniPlayerCornerRadius,
43 resolveDeviceMiniPlayerSourceSize,
2944 resolvePreviewMiniPlayerFrame,
3045 resolvePreviewMiniPlayerSourceSize,
3146} from "./previewMiniPlayerLayout";
......@@ -40,11 +55,11 @@ interface PointerGesture {
4055
4156interface Props {
4257 readonly threadRef: ScopedThreadRef;
43 readonly tabId: string;
58 readonly miniPlayer: PreviewMiniPlayerState;
4459 readonly bottomInset: number;
4560}
4661
47const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12;
62const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS;
4863
4964// Invisible grab zones straddling each edge; the cursor is the only affordance.
5065const RESIZE_HANDLES: ReadonlyArray<{
......@@ -61,17 +76,34 @@ const RESIZE_HANDLES: ReadonlyArray<{
6176 { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" },
6277];
6378
64/**
65 * Floats the thread's browser surface over chat. Native clipping and the DOM
66 * frame use the same radius so their separately composited edges stay aligned.
67 */
68export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props) {
69 const containerRef = useRef<HTMLDivElement | null>(null);
70 const gestureRef = useRef<PointerGesture | null>(null);
71 const [container, setContainer] = useState<PreviewMiniPlayerSize | null>(null);
72 const miniPlayer = usePreviewMiniPlayerStore((state) =>
73 selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef),
79/** Floats the thread's browser tab or device stream over chat. */
80export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, bottomInset }: Props) {
81 const { source } = miniPlayer;
82 return source.kind === "browser" ? (
83 <BrowserMiniPlayer
84 key={source.tabId}
85 threadRef={threadRef}
86 tabId={source.tabId}
87 miniPlayer={miniPlayer}
88 bottomInset={bottomInset}
89 />
90 ) : (
91 <DeviceMiniPlayer
92 key={previewMiniPlayerSourceKey(source)}
93 threadRef={threadRef}
94 source={source}
95 miniPlayer={miniPlayer}
96 bottomInset={bottomInset}
97 />
7498 );
99}
100
101function BrowserMiniPlayer({
102 threadRef,
103 tabId,
104 miniPlayer,
105 bottomInset,
106}: Props & { readonly tabId: string }) {
75107 const previewState = useThreadPreviewState(threadRef);
76108 const snapshot = previewState.sessions[tabId] ?? null;
77109 const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId);
......@@ -79,25 +111,11 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
79111 const fittedSourceContent = useBrowserSurfaceStore(
80112 (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null,
81113 );
82 const source = resolvePreviewMiniPlayerSourceSize(
114 const sourceSize = resolvePreviewMiniPlayerSourceSize(
83115 snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT,
84116 fittedSourceContent,
85117 desktopOverlay?.zoomFactor ?? 1,
86118 );
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
102120 const openInPanel = () => {
103121 usePreviewMiniPlayerStore.getState().close(threadRef);
......@@ -118,6 +136,176 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
118136 });
119137 };
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
121309 useLayoutEffect(() => {
122310 const element = containerRef.current;
123311 if (!element) return;
......@@ -160,7 +348,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
160348 if (gesture.direction === null) {
161349 store.move(
162350 threadRef,
163 tabId,
351 sourceKey,
164352 clampPreviewMiniPlayerPosition(
165353 { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y },
166354 container,
......@@ -174,12 +362,12 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
174362 start: gesture.frame,
175363 direction: gesture.direction,
176364 delta,
177 source,
365 source: sourceSize,
178366 container,
179367 bottomInset,
180368 });
181 store.resize(threadRef, tabId, next.width);
182 store.move(threadRef, tabId, { x: next.x, y: next.y });
369 store.resize(threadRef, sourceKey, next.width);
370 store.move(threadRef, sourceKey, { x: next.x, y: next.y });
183371 };
184372
185373 const endGesture = (event: ReactPointerEvent<HTMLElement>) => {
......@@ -190,24 +378,25 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
190378 }
191379 };
192380
193 if (!snapshot || miniPlayer?.tabId !== tabId) return null;
194
195381 return (
196382 <div ref={containerRef} className="pointer-events-none absolute inset-0">
197383 {frame ? (
198384 <section
199 aria-label="Floating browser preview"
200 data-preview-mini-player={tabId}
385 aria-label={label}
386 data-preview-mini-player={sourceKey}
201387 className="pointer-events-none absolute select-none"
202388 style={{
203389 left: frame.x,
204390 top: frame.y,
205391 width: frame.width,
206392 height: frame.height,
207 borderRadius: PREVIEW_MINI_PLAYER_CORNER_RADIUS,
393 borderRadius: radius,
208394 }}
209395 >
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 >
211400 <div
212401 aria-hidden="true"
213402 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
227416 size="icon-xs"
228417 aria-label="Open preview in right panel"
229418 onPointerDown={(event) => event.stopPropagation()}
230 onClick={openInPanel}
419 onClick={onOpenInPanel}
231420 />
232421 }
233422 >
......@@ -235,31 +424,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
235424 </TooltipTrigger>
236425 <TooltipPopup side="top">Open in right panel</TooltipPopup>
237426 </Tooltip>
238 <Tooltip>
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>
427 {pillActions}
263428 <Tooltip>
264429 <TooltipTrigger
265430 render={
......@@ -280,21 +445,8 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
280445 </div>
281446
282447 <div className="absolute inset-0 z-[47] rounded-[inherit] bg-muted shadow-2xl/35" />
283 <BrowserSurfaceSlot
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 />
448 {children(frame)}
292449 <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}
298450 {RESIZE_HANDLES.map(({ direction, className }) => (
299451 <div
300452 key={direction}
apps/web/src/components/preview/previewMiniPlayerLayout.test.ts+46
......@@ -5,6 +5,8 @@ import {
55 clampPreviewMiniPlayerPosition,
66 PREVIEW_MINI_PLAYER_EDGE_GAP,
77 resizePreviewMiniPlayer,
8 resolveDeviceMiniPlayerCornerRadius,
9 resolveDeviceMiniPlayerSourceSize,
810 resolvePreviewMiniPlayerFrame,
911 resolvePreviewMiniPlayerSourceSize,
1012} from "./previewMiniPlayerLayout";
......@@ -30,6 +32,50 @@ describe("resolvePreviewMiniPlayerSourceSize", () => {
3032 });
3133});
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
3379describe("resolvePreviewMiniPlayerFrame", () => {
3480 it("opens at the source aspect ratio in the top-right corner", () => {
3581 expect(
apps/web/src/components/preview/previewMiniPlayerLayout.ts+44-1
......@@ -1,4 +1,4 @@
1import type { PreviewViewportSetting } from "@t3tools/contracts";
1import type { DevicePlatform, PreviewViewportSetting } from "@t3tools/contracts";
22
33import type { BrowserSurfaceContentPresentation } from "~/browser/browserSurfaceStore";
44import {
......@@ -7,7 +7,10 @@ import {
77} from "~/browser/browserViewportLayout";
88import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore";
99
10import type { DeviceScreenSize } from "../device/deviceStream";
11
1012export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12;
13export const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12;
1114// The mini-player shell straddles this webview at 47 and 49; dialogs begin at 50.
1215export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48;
1316// A fresh player is the largest box at the source aspect ratio that fits here.
......@@ -34,6 +37,46 @@ export function resolvePreviewMiniPlayerSourceSize(
3437 };
3538}
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
3780const availableArea = (
3881 container: PreviewMiniPlayerSize,
3982 bottomInset: number,
apps/web/src/components/settings/IntegrationsSettings.tsx+1-1
......@@ -718,7 +718,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled
718718 return (
719719 <SettingsRow
720720 {...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."
722722 resetAction={
723723 !disabled && autoShow !== DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW ? (
724724 <SettingResetButton
apps/web/src/components/settings/settingsSearch.ts+1-1
......@@ -519,7 +519,7 @@ export const SETTINGS_SEARCH_ITEMS = [
519519 id: "browser-auto-show-floating-preview",
520520 title: "Auto-show floating preview",
521521 to: "/settings/integrations",
522 searchTerms: ["agent opens browser pop into view hide"],
522 searchTerms: ["agent opens browser device simulator pop into view hide"],
523523 },
524524 {
525525 id: "automatic-pull",
apps/web/src/previewMiniPlayerStore.test.ts+51-17
......@@ -2,10 +2,25 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment";
22import { type EnvironmentId, ThreadId } from "@t3tools/contracts";
33import { 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
713const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A"));
814const 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
1025beforeEach(() => {
1126 usePreviewMiniPlayerStore.setState({ byThreadKey: {} });
......@@ -13,52 +28,71 @@ beforeEach(() => {
1328
1429describe("previewMiniPlayerStore", () => {
1530 it("keeps floating previews scoped to their thread", () => {
16 usePreviewMiniPlayerStore.getState().open(refA, "tab-a");
17 usePreviewMiniPlayerStore.getState().open(refB, "tab-b");
31 usePreviewMiniPlayerStore.getState().open(refA, tabA);
32 usePreviewMiniPlayerStore.getState().open(refB, tabB);
1833
1934 expect(
2035 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),
21 ).toMatchObject({ tabId: "tab-a" });
36 ).toMatchObject({ source: tabA });
2237 expect(
2338 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refB),
24 ).toMatchObject({ tabId: "tab-b" });
39 ).toMatchObject({ source: tabB });
2540 });
2641
2742 it("preserves position when switching the floating tab within one thread", () => {
28 usePreviewMiniPlayerStore.getState().open(refA, "tab-a");
29 usePreviewMiniPlayerStore.getState().move(refA, "tab-a", { x: 24, y: 48 });
30 usePreviewMiniPlayerStore.getState().open(refA, "tab-b");
43 usePreviewMiniPlayerStore.getState().open(refA, tabA);
44 usePreviewMiniPlayerStore.getState().move(refA, "browser:tab-a", { x: 24, y: 48 });
45 usePreviewMiniPlayerStore.getState().open(refA, tabB);
3146
3247 expect(
3348 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),
3449 ).toEqual({
35 tabId: "tab-b",
50 source: tabB,
3651 position: { x: 24, y: 48 },
3752 width: null,
3853 });
3954 });
4055
4156 it("ignores stale drag updates after the floating tab changes", () => {
42 usePreviewMiniPlayerStore.getState().open(refA, "tab-a");
43 usePreviewMiniPlayerStore.getState().open(refA, "tab-b");
44 usePreviewMiniPlayerStore.getState().move(refA, "tab-a", { x: 100, y: 100 });
57 usePreviewMiniPlayerStore.getState().open(refA, tabA);
58 usePreviewMiniPlayerStore.getState().open(refA, tabB);
59 usePreviewMiniPlayerStore.getState().move(refA, "browser:tab-a", { x: 100, y: 100 });
4560
4661 expect(
4762 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),
4863 ).toEqual({
49 tabId: "tab-b",
64 source: tabB,
5065 position: null,
5166 width: null,
5267 });
5368 });
5469
5570 it("preserves a thread-bound width while switching tabs", () => {
56 usePreviewMiniPlayerStore.getState().open(refA, "tab-a");
57 usePreviewMiniPlayerStore.getState().resize(refA, "tab-a", 480);
58 usePreviewMiniPlayerStore.getState().open(refA, "tab-b");
71 usePreviewMiniPlayerStore.getState().open(refA, tabA);
72 usePreviewMiniPlayerStore.getState().resize(refA, "browser:tab-a", 480);
73 usePreviewMiniPlayerStore.getState().open(refA, tabB);
5974
6075 expect(
6176 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);
6397 });
6498});
apps/web/src/previewMiniPlayerStore.ts+60-13
......@@ -1,5 +1,5 @@
11import { scopedThreadKey } from "@t3tools/client-runtime/environment";
2import type { ScopedThreadRef } from "@t3tools/contracts";
2import type { DevicePlatform, ScopedThreadRef } from "@t3tools/contracts";
33import { create } from "zustand";
44
55export interface PreviewMiniPlayerPosition {
......@@ -12,34 +12,66 @@ export interface PreviewMiniPlayerSize {
1212 readonly height: number;
1313}
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
1526export interface PreviewMiniPlayerState {
16 readonly tabId: string;
27 readonly source: PreviewMiniPlayerSource;
1728 readonly position: PreviewMiniPlayerPosition | null;
18 /** Height always follows the previewed viewport's aspect ratio. */
29 /** Height always follows the mirrored source's aspect ratio. */
1930 readonly width: number | null;
2031}
2132
2233interface PreviewMiniPlayerStoreState {
2334 readonly byThreadKey: Record<string, PreviewMiniPlayerState>;
24 readonly open: (ref: ScopedThreadRef, tabId: string) => void;
35 readonly open: (ref: ScopedThreadRef, source: PreviewMiniPlayerSource) => void;
2536 readonly close: (ref: ScopedThreadRef) => void;
26 readonly move: (ref: ScopedThreadRef, tabId: string, position: PreviewMiniPlayerPosition) => void;
27 readonly resize: (ref: ScopedThreadRef, tabId: string, width: number) => void;
37 /** `sourceKey` guards against a drag that outlives the source it started on. */
38 readonly move: (
39 ref: ScopedThreadRef,
40 sourceKey: string,
41 position: PreviewMiniPlayerPosition,
42 ) => void;
43 readonly resize: (ref: ScopedThreadRef, sourceKey: string, width: number) => void;
2844 readonly removeThread: (ref: ScopedThreadRef) => void;
2945}
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
3158export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((set) => ({
3259 byThreadKey: {},
33 open: (ref, tabId) =>
60 open: (ref, source) =>
3461 set((state) => {
3562 const threadKey = scopedThreadKey(ref);
3663 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 }
3870 return {
3971 byThreadKey: {
4072 ...state.byThreadKey,
4173 [threadKey]: {
42 tabId,
74 source,
4375 position: current?.position ?? null,
4476 width: current?.width ?? null,
4577 },
......@@ -53,11 +85,11 @@ export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((
5385 const { [threadKey]: _closed, ...byThreadKey } = state.byThreadKey;
5486 return { byThreadKey };
5587 }),
56 move: (ref, tabId, position) =>
88 move: (ref, sourceKey, position) =>
5789 set((state) => {
5890 const threadKey = scopedThreadKey(ref);
5991 const current = state.byThreadKey[threadKey];
60 if (!current || current.tabId !== tabId) return state;
92 if (!current || previewMiniPlayerSourceKey(current.source) !== sourceKey) return state;
6193 if (current.position?.x === position.x && current.position.y === position.y) return state;
6294 return {
6395 byThreadKey: {
......@@ -66,11 +98,17 @@ export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((
6698 },
6799 };
68100 }),
69 resize: (ref, tabId, width) =>
101 resize: (ref, sourceKey, width) =>
70102 set((state) => {
71103 const threadKey = scopedThreadKey(ref);
72104 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 }
74112 return {
75113 byThreadKey: {
76114 ...state.byThreadKey,
......@@ -94,3 +132,12 @@ export function selectThreadPreviewMiniPlayer(
94132 if (!ref) return null;
95133 return byThreadKey[scopedThreadKey(ref)] ?? null;
96134}
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.
1818Each device opens in its own tab. Use **+ → Device** to open another, and
1919double-click a tab name or choose **Rename** from its context menu to rename it.
2020Only 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.
2125Turn off the device hub in **Settings → Integrations → Devices** to stop the
2226helper processes; simulators and emulators keep running until you power them
2327off.
......@@ -48,8 +52,11 @@ back from the device after a change.
4852
4953## Agents and devices
5054
51When an agent opens a device, the panel opens in web and desktop clients connected
52to the thread. Mobile clients show device activity in the thread timeline. Agents drive the device through the `agent-device` command line. T3
55When an agent opens a device, it floats over the chat in web and desktop clients
56connected 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
5360Code installs and starts it only after **Agent device access** is enabled. iOS
5461taps build a small test runner on first use, which takes a couple of minutes
5562once per server. Restart an existing agent session after granting access so it