| author | |
| committer | |
| log | 8bbe2bf660c361e33db9c30f03fc72ab183eef3f |
| tree | 3c4b9df82d89538b95cb059bab235f4804589f0b |
| parent | e22040dfc190d04573238c5db2e69df72e5f1017 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 141 | 141 | ||
| ... | @@ -152,22 +152,61 @@ describe("floating browser preview", () => { | ... | @@ -152,22 +152,61 @@ describe("floating browser preview", () => { |
| 152 | }); | 152 | }); |
| 153 | 153 | ||
| 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 | }); |
| 173 | 212 |
apps/web/src/components/ChatView.logic.ts+13-6| ... | @@ -50,6 +50,7 @@ import { | ... | @@ -50,6 +50,7 @@ import { |
| 50 | import type { DraftThreadEnvMode } from "../composerDraftStore"; | 50 | import type { DraftThreadEnvMode } from "../composerDraftStore"; |
| 51 | import type { ComposerSubmissionIntent } from "../composer-logic"; | 51 | import type { ComposerSubmissionIntent } from "../composer-logic"; |
| 52 | import type { TimelineEntry } from "../session-logic"; | 52 | import type { TimelineEntry } from "../session-logic"; |
| 53 | import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore"; | ||
| 53 | import type { DesktopPreviewOverlay } from "../previewStateStore"; | 54 | import type { DesktopPreviewOverlay } from "../previewStateStore"; |
| 54 | import type { RightPanelSurface } from "../rightPanelStore"; | 55 | import type { RightPanelSurface } from "../rightPanelStore"; |
| 55 | import { | 56 | import { |
| ... | @@ -88,16 +89,22 @@ export function agentControlledBrowserCloseConfirmation( | ... | @@ -88,16 +89,22 @@ export function agentControlledBrowserCloseConfirmation( |
| 88 | ].join("\n"); | 89 | ].join("\n"); |
| 89 | } | 90 | } |
| 90 | 91 | ||
| 92 | /** The floating player hides only while the same source is rendered in the panel. */ | ||
| 91 | export function shouldRenderPreviewMiniPlayer( | 93 | export 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 === miniPlayerTabId | 101 | 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 | } |
| 103 | 110 |
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"; |
| 197 | import { | 197 | import { |
| 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 | ); |
| 568 | const DiffPanel = lazy(() => import("./DiffPanel")); | 570 | const DiffPanel = lazy(() => import("./DiffPanel")); |
| 571 | const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => | ||
| 572 | settings.browserAutoShowFloatingPreview; | ||
| 569 | const DevicePanel = lazy(() => | 573 | const 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]); |
| 1975 | 1979 | ||
| 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 opened | 4224 | // 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: persisted | 4225 | // 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 = useRightPanelStore | 4263 | 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 | usePreviewMiniPlayerStore | 4457 | 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) { |
| 8834 | 8870 | ||
| 8835 | {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? ( | 8871 | {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? ( |
| 8836 | <ThreadPreviewMiniPlayer | 8872 | <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 { |
| 7 | import { | 7 | import { |
| 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"; |
| 17 | import { useEffect, useMemo, useState } from "react"; | 18 | import { useEffect, useMemo, useState } from "react"; |
| 18 | 19 | ||
| 20 | import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; | ||
| 19 | import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore"; | 21 | import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore"; |
| 20 | import { Button } from "~/components/ui/button"; | 22 | import { Button } from "~/components/ui/button"; |
| 21 | import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list"; | 23 | import { 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 | }; |
| 118 | 120 | ||
| 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"; |
| 32 | import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; | 32 | import { |
| 33 | browserMiniPlayerSource, | ||
| 34 | selectThreadPreviewMiniPlayerTabId, | ||
| 35 | usePreviewMiniPlayerStore, | ||
| 36 | } from "~/previewMiniPlayerStore"; | ||
| 33 | import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; | 37 | import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; |
| 34 | import { | 38 | import { |
| 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.miniPlayerTabId | 171 | 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); |
| 490 | 494 | ||
| 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"; |
| 34 | import { useEnvironmentHttpBaseUrl } from "~/state/environments"; | 34 | import { useEnvironmentHttpBaseUrl } from "~/state/environments"; |
| 35 | import { previewEnvironment } from "~/state/preview"; | 35 | import { previewEnvironment } from "~/state/preview"; |
| 36 | import { useAtomCommand } from "~/state/use-atom-command"; | 36 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 37 | import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; | 37 | import { |
| 38 | browserMiniPlayerSource, | ||
| 39 | selectThreadPreviewMiniPlayerTabId, | ||
| 40 | usePreviewMiniPlayerStore, | ||
| 41 | } from "~/previewMiniPlayerStore"; | ||
| 38 | import { useRightPanelStore } from "~/rightPanelStore"; | 42 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 39 | 43 | ||
| 40 | import { previewBridge } from "./previewBridge"; | 44 | import { 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({ |
| 311 | 315 | ||
| 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]); |
| 321 | 325 | ||
| 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 @@ |
| 2 | 2 | ||
| 3 | import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts"; | 3 | import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts"; |
| 4 | import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; | 4 | import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; |
| 5 | import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef, useState } from "react"; | 5 | import { |
| 6 | type PointerEvent as ReactPointerEvent, | ||
| 7 | type ReactNode, | ||
| 8 | useCallback, | ||
| 9 | useLayoutEffect, | ||
| 10 | useRef, | ||
| 11 | useState, | ||
| 12 | } from "react"; | ||
| 6 | 13 | ||
| 7 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; | 14 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; |
| 8 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; | 15 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; |
| ... | @@ -15,17 +22,25 @@ import { cn } from "~/lib/utils"; | ... | @@ -15,17 +22,25 @@ import { cn } from "~/lib/utils"; |
| 15 | import { useThreadPreviewState } from "~/previewStateStore"; | 22 | import { useThreadPreviewState } from "~/previewStateStore"; |
| 16 | import { | 23 | import { |
| 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"; |
| 21 | import { useRightPanelStore } from "~/rightPanelStore"; | 30 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 31 | import { useDeviceState } from "~/state/device"; | ||
| 22 | 32 | ||
| 33 | import { DeviceStreamView } from "../device/DeviceStreamView"; | ||
| 34 | import type { DeviceScreenSize } from "../device/deviceStream"; | ||
| 23 | import { previewBridge } from "./previewBridge"; | 35 | import { previewBridge } from "./previewBridge"; |
| 24 | import { | 36 | import { |
| 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 { |
| 40 | 55 | ||
| 41 | interface Props { | 56 | interface 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 | } |
| 46 | 61 | ||
| 47 | const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12; | 62 | const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS; |
| 48 | 63 | ||
| 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. |
| 50 | const RESIZE_HANDLES: ReadonlyArray<{ | 65 | const 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 | ]; |
| 63 | 78 | ||
| 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 DOM | 80 | export 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" ? ( |
| 68 | export 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 | |||
| 101 | function 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 | }; | ||
| 101 | 119 | ||
| 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 | }; |
| 120 | 138 | ||
| 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 | |||
| 199 | function 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 | */ | ||
| 265 | function 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 | }; |
| 184 | 372 | ||
| 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 | }; |
| 192 | 380 | ||
| 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 | <section | 384 | <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 | <div | 400 | <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 | <TooltipTrigger | 429 | <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> |
| 281 | 446 | ||
| 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 | <BrowserSurfaceSlot | 448 | {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 | <div | 451 | <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 | }); |
| 32 | 34 | ||
| 35 | describe("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 | |||
| 66 | describe("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 | |||
| 33 | describe("resolvePreviewMiniPlayerFrame", () => { | 79 | describe("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 @@ |
| 1 | import type { PreviewViewportSetting } from "@t3tools/contracts"; | 1 | import type { DevicePlatform, PreviewViewportSetting } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import type { BrowserSurfaceContentPresentation } from "~/browser/browserSurfaceStore"; | 3 | import type { BrowserSurfaceContentPresentation } from "~/browser/browserSurfaceStore"; |
| 4 | import { | 4 | import { |
| ... | @@ -7,7 +7,10 @@ import { | ... | @@ -7,7 +7,10 @@ import { |
| 7 | } from "~/browser/browserViewportLayout"; | 7 | } from "~/browser/browserViewportLayout"; |
| 8 | import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; | 8 | import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; |
| 9 | 9 | ||
| 10 | import type { DeviceScreenSize } from "../device/deviceStream"; | ||
| 11 | |||
| 10 | export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12; | 12 | export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12; |
| 13 | export 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. |
| 12 | export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48; | 15 | export 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 | } |
| 36 | 39 | ||
| 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 | */ | ||
| 46 | export 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 | */ | ||
| 69 | export 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 | |||
| 37 | const availableArea = ( | 80 | const 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 | <SettingsRow | 719 | <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 | <SettingResetButton | 724 | <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"; |
| 2 | import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; | 2 | import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | ||
| 5 | import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "./previewMiniPlayerStore"; | 5 | import { |
| 6 | browserMiniPlayerSource, | ||
| 7 | type PreviewMiniPlayerSource, | ||
| 8 | selectThreadPreviewMiniPlayer, | ||
| 9 | selectThreadPreviewMiniPlayerTabId, | ||
| 10 | usePreviewMiniPlayerStore, | ||
| 11 | } from "./previewMiniPlayerStore"; | ||
| 6 | 12 | ||
| 7 | const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")); | 13 | const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")); |
| 8 | const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B")); | 14 | const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B")); |
| 15 | const tabA = browserMiniPlayerSource("tab-a"); | ||
| 16 | const tabB = browserMiniPlayerSource("tab-b"); | ||
| 17 | const pixel: PreviewMiniPlayerSource = { | ||
| 18 | kind: "device", | ||
| 19 | hostId: "nucbox", | ||
| 20 | deviceId: "emulator-5580", | ||
| 21 | platform: "android", | ||
| 22 | name: "Pixel", | ||
| 23 | }; | ||
| 9 | 24 | ||
| 10 | beforeEach(() => { | 25 | beforeEach(() => { |
| 11 | usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); | 26 | usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); |
| ... | @@ -13,52 +28,71 @@ beforeEach(() => { | ... | @@ -13,52 +28,71 @@ beforeEach(() => { |
| 13 | 28 | ||
| 14 | describe("previewMiniPlayerStore", () => { | 29 | describe("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); |
| 18 | 33 | ||
| 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 | }); |
| 26 | 41 | ||
| 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); |
| 31 | 46 | ||
| 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 | }); |
| 40 | 55 | ||
| 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 }); |
| 45 | 60 | ||
| 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 | }); |
| 54 | 69 | ||
| 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); |
| 59 | 74 | ||
| 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 @@ |
| 1 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | 1 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 2 | import type { ScopedThreadRef } from "@t3tools/contracts"; | 2 | import type { DevicePlatform, ScopedThreadRef } from "@t3tools/contracts"; |
| 3 | import { create } from "zustand"; | 3 | import { create } from "zustand"; |
| 4 | 4 | ||
| 5 | export interface PreviewMiniPlayerPosition { | 5 | export 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 | } |
| 14 | 14 | ||
| 15 | /** What the floating player mirrors: a browser tab or a device stream. */ | ||
| 16 | export 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 | |||
| 15 | export interface PreviewMiniPlayerState { | 26 | export 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 | } |
| 21 | 32 | ||
| 22 | interface PreviewMiniPlayerStoreState { | 33 | interface 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 | } |
| 30 | 46 | ||
| 47 | export function previewMiniPlayerSourceKey(source: PreviewMiniPlayerSource): string { | ||
| 48 | return source.kind === "browser" | ||
| 49 | ? `browser:${source.tabId}` | ||
| 50 | : `device:${encodeURIComponent(source.hostId)}:${encodeURIComponent(source.deviceId)}`; | ||
| 51 | } | ||
| 52 | |||
| 53 | export const browserMiniPlayerSource = (tabId: string): PreviewMiniPlayerSource => ({ | ||
| 54 | kind: "browser", | ||
| 55 | tabId, | ||
| 56 | }); | ||
| 57 | |||
| 31 | export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((set) => ({ | 58 | export 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. */ | ||
| 137 | export 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. |
| 18 | Each device opens in its own tab. Use **+ → Device** to open another, and | 18 | Each device opens in its own tab. Use **+ → Device** to open another, and |
| 19 | double-click a tab name or choose **Rename** from its context menu to rename it. | 19 | double-click a tab name or choose **Rename** from its context menu to rename it. |
| 20 | Only the visible tab streams video; switching tabs keeps both devices running. | 20 | Only the visible tab streams video; switching tabs keeps both devices running. |
| 21 | Choose **Float device over chat** in the toolbar to keep watching and tapping the | ||
| 22 | device in a small window while the right panel shows something else; drag the | ||
| 23 | window by its handle, resize it from any edge, and use **Open in right panel** | ||
| 24 | to bring it back. | ||
| 21 | Turn off the device hub in **Settings → Integrations → Devices** to stop the | 25 | Turn off the device hub in **Settings → Integrations → Devices** to stop the |
| 22 | helper processes; simulators and emulators keep running until you power them | 26 | helper processes; simulators and emulators keep running until you power them |
| 23 | off. | 27 | off. |
| ... | @@ -48,8 +52,11 @@ back from the device after a change. | ... | @@ -48,8 +52,11 @@ back from the device after a change. |
| 48 | 52 | ||
| 49 | ## Agents and devices | 53 | ## Agents and devices |
| 50 | 54 | ||
| 51 | When an agent opens a device, the panel opens in web and desktop clients connected | 55 | When an agent opens a device, it floats over the chat in web and desktop clients |
| 52 | to the thread. Mobile clients show device activity in the thread timeline. Agents drive the device through the `agent-device` command line. T3 | 56 | connected 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 | ||
| 58 | right-panel tab instead. Mobile clients show device activity in the thread | ||
| 59 | timeline. Agents drive the device through the `agent-device` command line. T3 | ||
| 53 | Code installs and starts it only after **Agent device access** is enabled. iOS | 60 | Code installs and starts it only after **Agent device access** is enabled. iOS |
| 54 | taps build a small test runner on first use, which takes a couple of minutes | 61 | taps build a small test runner on first use, which takes a couple of minutes |
| 55 | once per server. Restart an existing agent session after granting access so it | 62 | once per server. Restart an existing agent session after granting access so it |