| 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 | 126 | const ref = scopeThreadRef(EnvironmentId.make("env-1"), ThreadId.make("thread-1")); |
| 127 | 127 | const panels = useRightPanelStore.getState(); |
| 128 | 128 | const revision = panels.getUserActionRevision(ref); |
| 129 | usePreviewMiniPlayerStore.getState().open(ref, "agent-tab"); | |
| 129 | usePreviewMiniPlayerStore.getState().open(ref, { kind: "browser", tabId: "agent-tab" }); | |
| 130 | 130 | panels.reconcileBrowserSurfaces(ref, ["agent-tab"]); |
| 131 | 131 | const intent = selectThreadPreviewMiniPlayer( |
| 132 | 132 | usePreviewMiniPlayerStore.getState().byThreadKey, |
| ... | ... | @@ -135,7 +135,7 @@ describe("floating browser preview", () => { |
| 135 | 135 | const isFloating = () => |
| 136 | 136 | shouldRenderPreviewMiniPlayer( |
| 137 | 137 | selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, ref) |
| 138 | ?.tabId ?? null, | |
| 138 | ?.source ?? null, | |
| 139 | 139 | selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref), |
| 140 | 140 | ); |
| 141 | 141 | |
| ... | ... | @@ -152,22 +152,61 @@ describe("floating browser preview", () => { |
| 152 | 152 | }); |
| 153 | 153 | |
| 154 | 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 | 156 | expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false); |
| 156 | 157 | expect( |
| 157 | shouldRenderPreviewMiniPlayer("tab-1", { | |
| 158 | shouldRenderPreviewMiniPlayer(tab, { | |
| 158 | 159 | id: "browser:one", |
| 159 | 160 | kind: "preview", |
| 160 | 161 | resourceId: "tab-1", |
| 161 | 162 | }), |
| 162 | 163 | ).toBe(false); |
| 163 | 164 | expect( |
| 164 | shouldRenderPreviewMiniPlayer("tab-1", { | |
| 165 | shouldRenderPreviewMiniPlayer(tab, { | |
| 165 | 166 | id: "browser:two", |
| 166 | 167 | kind: "preview", |
| 167 | 168 | resourceId: "tab-2", |
| 168 | 169 | }), |
| 169 | 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 | 50 | import type { DraftThreadEnvMode } from "../composerDraftStore"; |
| 51 | 51 | import type { ComposerSubmissionIntent } from "../composer-logic"; |
| 52 | 52 | import type { TimelineEntry } from "../session-logic"; |
| 53 | import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore"; | |
| 53 | 54 | import type { DesktopPreviewOverlay } from "../previewStateStore"; |
| 54 | 55 | import type { RightPanelSurface } from "../rightPanelStore"; |
| 55 | 56 | import { |
| ... | ... | @@ -88,16 +89,22 @@ export function agentControlledBrowserCloseConfirmation( |
| 88 | 89 | ].join("\n"); |
| 89 | 90 | } |
| 90 | 91 | |
| 92 | /** The floating player hides only while the same source is rendered in the panel. */ | |
| 91 | 93 | export function shouldRenderPreviewMiniPlayer( |
| 92 | miniPlayerTabId: string | null, | |
| 94 | source: PreviewMiniPlayerSource | null, | |
| 93 | 95 | renderedRightPanelSurface: RightPanelSurface | null, |
| 94 | 96 | ): boolean { |
| 95 | return ( | |
| 96 | miniPlayerTabId !== null && | |
| 97 | !( | |
| 97 | if (source === null) return false; | |
| 98 | if (source.kind === "browser") { | |
| 99 | return !( | |
| 98 | 100 | 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 | |
| 101 | 108 | ); |
| 102 | 109 | } |
| 103 | 110 |
apps/web/src/components/ChatView.tsx+61-25| ... | ... | @@ -195,6 +195,8 @@ import { |
| 195 | 195 | useSidebarPendingFileDropStore, |
| 196 | 196 | } from "../sidebarPendingFileDropStore"; |
| 197 | 197 | import { |
| 198 | browserMiniPlayerSource, | |
| 199 | previewMiniPlayerSourceKey, | |
| 198 | 200 | selectThreadPreviewMiniPlayer, |
| 199 | 201 | usePreviewMiniPlayerStore, |
| 200 | 202 | } from "../previewMiniPlayerStore"; |
| ... | ... | @@ -566,6 +568,8 @@ const PreviewPanel = lazy(() => |
| 566 | 568 | import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), |
| 567 | 569 | ); |
| 568 | 570 | const DiffPanel = lazy(() => import("./DiffPanel")); |
| 571 | const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => | |
| 572 | settings.browserAutoShowFloatingPreview; | |
| 569 | 573 | const DevicePanel = lazy(() => |
| 570 | 574 | import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), |
| 571 | 575 | ); |
| ... | ... | @@ -1958,7 +1962,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1958 | 1962 | const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; |
| 1959 | 1963 | const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; |
| 1960 | 1964 | const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( |
| 1961 | activePreviewMiniPlayer?.tabId ?? null, | |
| 1965 | activePreviewMiniPlayer?.source ?? null, | |
| 1962 | 1966 | renderedRightPanelSurface, |
| 1963 | 1967 | ); |
| 1964 | 1968 | const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; |
| ... | ... | @@ -1974,8 +1978,10 @@ export default function ChatView(props: ChatViewProps) { |
| 1974 | 1978 | }, [activePreviewState.sessions, activeThreadRef]); |
| 1975 | 1979 | |
| 1976 | 1980 | 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 | ); | |
| 1979 | 1985 | if (!miniTabStillExists) { |
| 1980 | 1986 | usePreviewMiniPlayerStore.getState().close(activeThreadRef); |
| 1981 | 1987 | } |
| ... | ... | @@ -4215,9 +4221,13 @@ export default function ChatView(props: ChatViewProps) { |
| 4215 | 4221 | } |
| 4216 | 4222 | useRightPanelStore.getState().open(activeThreadRef, "device"); |
| 4217 | 4223 | }, [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); | |
| 4221 | 4231 | const previousDeviceSessions = useRef(new Map<string, Set<string>>()); |
| 4222 | 4232 | useEffect(() => { |
| 4223 | 4233 | if (!activeThreadRef || !deviceStateLoaded) return; |
| ... | ... | @@ -4226,11 +4236,30 @@ export default function ChatView(props: ChatViewProps) { |
| 4226 | 4236 | (session) => session.threadId === activeThreadRef.threadId, |
| 4227 | 4237 | ); |
| 4228 | 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 | 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 | 4248 | if (!previous || shouldUseRightPanelSheet) return; |
| 4232 | 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 | 4263 | const existing = useRightPanelStore |
| 4235 | 4264 | .getState() |
| 4236 | 4265 | .byThreadKey[scopedThreadKey(activeThreadRef)]?.surfaces.some( |
| ... | ... | @@ -4240,28 +4269,30 @@ export default function ChatView(props: ChatViewProps) { |
| 4240 | 4269 | surface.target.deviceId === session.deviceId, |
| 4241 | 4270 | ); |
| 4242 | 4271 | 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); | |
| 4257 | 4273 | } |
| 4258 | 4274 | }, [ |
| 4259 | 4275 | activeThreadRef, |
| 4276 | autoShowFloatingPreview, | |
| 4260 | 4277 | deviceStateLoaded, |
| 4261 | 4278 | shouldUseRightPanelSheet, |
| 4262 | 4279 | deviceState.sessions, |
| 4263 | 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 | 4296 | const openFileSurface = useCallback( |
| 4266 | 4297 | (relativePath: string) => { |
| 4267 | 4298 | if (!activeThreadRef || !activeProject) return; |
| ... | ... | @@ -4421,10 +4452,15 @@ export default function ChatView(props: ChatViewProps) { |
| 4421 | 4452 | ]); |
| 4422 | 4453 | const closePreviewPanel = useCallback(() => { |
| 4423 | 4454 | if (activeThreadRef) { |
| 4455 | // Closing the panel on a live browser or device floats it instead of dropping it. | |
| 4424 | 4456 | if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) { |
| 4425 | 4457 | usePreviewMiniPlayerStore |
| 4426 | 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 | 4465 | setMaximizedRightPanelThreadKey(null); |
| 4430 | 4466 | useRightPanelStore.getState().close(activeThreadRef); |
| ... | ... | @@ -8834,9 +8870,9 @@ export default function ChatView(props: ChatViewProps) { |
| 8834 | 8870 | |
| 8835 | 8871 | {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? ( |
| 8836 | 8872 | <ThreadPreviewMiniPlayer |
| 8837 | key={`${activeThreadKey}:${activePreviewMiniPlayer.tabId}`} | |
| 8873 | key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`} | |
| 8838 | 8874 | threadRef={activeThreadRef} |
| 8839 | tabId={activePreviewMiniPlayer.tabId} | |
| 8875 | miniPlayer={activePreviewMiniPlayer} | |
| 8840 | 8876 | bottomInset={isDraftHeroState ? 0 : composerOverlayHeight} |
| 8841 | 8877 | /> |
| 8842 | 8878 | ) : null} |
apps/web/src/components/device/DevicePanel.tsx+18| ... | ... | @@ -7,6 +7,7 @@ import type { |
| 7 | 7 | import { |
| 8 | 8 | ChevronLeft, |
| 9 | 9 | Home, |
| 10 | PictureInPicture2, | |
| 10 | 11 | Power, |
| 11 | 12 | RotateCcw, |
| 12 | 13 | SlidersHorizontal, |
| ... | ... | @@ -16,6 +17,7 @@ import { |
| 16 | 17 | } from "lucide-react"; |
| 17 | 18 | import { useEffect, useMemo, useState } from "react"; |
| 18 | 19 | |
| 20 | import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; | |
| 19 | 21 | import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore"; |
| 20 | 22 | import { Button } from "~/components/ui/button"; |
| 21 | 23 | import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list"; |
| ... | ... | @@ -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 | 134 | const closeActive = (powerOff: boolean) => { |
| 120 | 135 | if (!powerOff) { |
| 121 | 136 | useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); |
| ... | ... | @@ -218,6 +233,9 @@ export function DevicePanel(props: { |
| 218 | 233 | > |
| 219 | 234 | <SlidersHorizontal /> |
| 220 | 235 | </Toggle> |
| 236 | <DeviceButton label="Float device over chat" onClick={floatActive}> | |
| 237 | <PictureInPicture2 /> | |
| 238 | </DeviceButton> | |
| 221 | 239 | <DeviceButton label="Power off" onClick={() => closeActive(true)}> |
| 222 | 240 | <Power /> |
| 223 | 241 | </DeviceButton> |
apps/web/src/components/preview/PreviewAutomationHosts.tsx+13-5| ... | ... | @@ -29,7 +29,11 @@ import { |
| 29 | 29 | reconcilePreviewServerSessions, |
| 30 | 30 | updatePreviewServerSnapshot, |
| 31 | 31 | } from "~/previewStateStore"; |
| 32 | import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; | |
| 32 | import { | |
| 33 | browserMiniPlayerSource, | |
| 34 | selectThreadPreviewMiniPlayerTabId, | |
| 35 | usePreviewMiniPlayerStore, | |
| 36 | } from "~/previewMiniPlayerStore"; | |
| 33 | 37 | import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; |
| 34 | 38 | import { |
| 35 | 39 | readActiveBrowserRecordingTargets, |
| ... | ... | @@ -378,7 +382,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 378 | 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 | 390 | browserActivity.release ??= acquireBrowserSurfaceActivity(runtimeTabId); |
| ... | ... | @@ -493,11 +499,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 493 | 499 | new Set([activeRuntimeTabId]), |
| 494 | 500 | ); |
| 495 | 501 | } |
| 496 | const miniPlayer = selectThreadPreviewMiniPlayer( | |
| 502 | const miniPlayerTabId = selectThreadPreviewMiniPlayerTabId( | |
| 497 | 503 | usePreviewMiniPlayerStore.getState().byThreadKey, |
| 498 | 504 | threadRef, |
| 499 | 505 | ); |
| 500 | if (miniPlayer?.tabId === activeTabId) { | |
| 506 | if (miniPlayerTabId === activeTabId) { | |
| 501 | 507 | usePreviewMiniPlayerStore.getState().close(threadRef); |
| 502 | 508 | } |
| 503 | 509 | } else if (shouldPresentPreview) { |
| ... | ... | @@ -507,7 +513,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 507 | 513 | } |
| 508 | 514 | } |
| 509 | 515 | if (shouldPresentPreview) { |
| 510 | usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId); | |
| 516 | usePreviewMiniPlayerStore | |
| 517 | .getState() | |
| 518 | .open(threadRef, browserMiniPlayerSource(activeTabId)); | |
| 511 | 519 | } |
| 512 | 520 | if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) { |
| 513 | 521 | await requireReadyTab(); |
apps/web/src/components/preview/PreviewView.test.tsx+9-5| ... | ... | @@ -171,7 +171,7 @@ vi.mock("~/previewMiniPlayerStore", () => { |
| 171 | 171 | byThreadKey: mocks.miniPlayerTabId |
| 172 | 172 | ? { |
| 173 | 173 | "environment-1:thread-1": { |
| 174 | tabId: mocks.miniPlayerTabId, | |
| 174 | source: { kind: "browser", tabId: mocks.miniPlayerTabId }, | |
| 175 | 175 | position: null, |
| 176 | 176 | }, |
| 177 | 177 | } |
| ... | ... | @@ -185,9 +185,10 @@ vi.mock("~/previewMiniPlayerStore", () => { |
| 185 | 185 | }, |
| 186 | 186 | ); |
| 187 | 187 | 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, | |
| 191 | 192 | usePreviewMiniPlayerStore, |
| 192 | 193 | }; |
| 193 | 194 | }); |
| ... | ... | @@ -485,7 +486,10 @@ describe("PreviewView navigation", () => { |
| 485 | 486 | renderToStaticMarkup(<PreviewView {...props} />); |
| 486 | 487 | expect(mocks.pictureInPicturePressed).toBe(false); |
| 487 | 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 | 493 | expect(mocks.closeRightPanel).toHaveBeenCalledWith(props.threadRef); |
| 490 | 494 | |
| 491 | 495 | mocks.miniPlayerTabId = "tab-1"; |
apps/web/src/components/preview/PreviewView.tsx+11-7| ... | ... | @@ -34,7 +34,11 @@ import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 34 | 34 | import { useEnvironmentHttpBaseUrl } from "~/state/environments"; |
| 35 | 35 | import { previewEnvironment } from "~/state/preview"; |
| 36 | 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 | 42 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 39 | 43 | |
| 40 | 44 | import { previewBridge } from "./previewBridge"; |
| ... | ... | @@ -114,8 +118,8 @@ export function PreviewView({ |
| 114 | 118 | threadRef, |
| 115 | 119 | BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, |
| 116 | 120 | ); |
| 117 | const miniPlayer = usePreviewMiniPlayerStore((state) => | |
| 118 | selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), | |
| 121 | const miniPlayerTabId = usePreviewMiniPlayerStore((state) => | |
| 122 | selectThreadPreviewMiniPlayerTabId(state.byThreadKey, threadRef), | |
| 119 | 123 | ); |
| 120 | 124 | const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); |
| 121 | 125 | const addImage = useComposerDraftStore((store) => store.addImage); |
| ... | ... | @@ -311,13 +315,13 @@ export function PreviewView({ |
| 311 | 315 | |
| 312 | 316 | const handlePictureInPicture = useCallback(() => { |
| 313 | 317 | if (!tabId) return; |
| 314 | if (miniPlayer?.tabId === tabId) { | |
| 318 | if (miniPlayerTabId === tabId) { | |
| 315 | 319 | usePreviewMiniPlayerStore.getState().close(threadRef); |
| 316 | 320 | return; |
| 317 | 321 | } |
| 318 | usePreviewMiniPlayerStore.getState().open(threadRef, tabId); | |
| 322 | usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(tabId)); | |
| 319 | 323 | useRightPanelStore.getState().close(threadRef); |
| 320 | }, [miniPlayer?.tabId, tabId, threadRef]); | |
| 324 | }, [miniPlayerTabId, tabId, threadRef]); | |
| 321 | 325 | |
| 322 | 326 | const handleNativePictureInPicture = useCallback(() => { |
| 323 | 327 | if (!previewBridge || !runtimeTabId) return; |
| ... | ... | @@ -722,7 +726,7 @@ export function PreviewView({ |
| 722 | 726 | captureDisabled={!desktopOverlay || isUnreachable} |
| 723 | 727 | recording={recordingRuntimeTabId !== null} |
| 724 | 728 | onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined} |
| 725 | pictureInPicture={miniPlayer?.tabId === tabId} | |
| 729 | pictureInPicture={miniPlayerTabId === tabId} | |
| 726 | 730 | pictureInPictureDisabled={!desktopOverlay?.hasWebContents || isUnreachable} |
| 727 | 731 | onPickElement={previewBridge && tabId ? handlePickElement : undefined} |
| 728 | 732 | pickActive={pickActive} |
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+231-79| ... | ... | @@ -2,7 +2,14 @@ |
| 2 | 2 | |
| 3 | 3 | import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts"; |
| 4 | 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 | 14 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; |
| 8 | 15 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; |
| ... | ... | @@ -15,17 +22,25 @@ import { cn } from "~/lib/utils"; |
| 15 | 22 | import { useThreadPreviewState } from "~/previewStateStore"; |
| 16 | 23 | import { |
| 17 | 24 | type PreviewMiniPlayerSize, |
| 18 | selectThreadPreviewMiniPlayer, | |
| 25 | type PreviewMiniPlayerSource, | |
| 26 | type PreviewMiniPlayerState, | |
| 27 | previewMiniPlayerSourceKey, | |
| 19 | 28 | usePreviewMiniPlayerStore, |
| 20 | 29 | } from "~/previewMiniPlayerStore"; |
| 21 | 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 | 35 | import { previewBridge } from "./previewBridge"; |
| 24 | 36 | import { |
| 25 | 37 | clampPreviewMiniPlayerPosition, |
| 38 | PREVIEW_MINI_PLAYER_CORNER_RADIUS, | |
| 26 | 39 | PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, |
| 27 | 40 | type PreviewMiniPlayerFrame, |
| 28 | 41 | resizePreviewMiniPlayer, |
| 42 | resolveDeviceMiniPlayerCornerRadius, | |
| 43 | resolveDeviceMiniPlayerSourceSize, | |
| 29 | 44 | resolvePreviewMiniPlayerFrame, |
| 30 | 45 | resolvePreviewMiniPlayerSourceSize, |
| 31 | 46 | } from "./previewMiniPlayerLayout"; |
| ... | ... | @@ -40,11 +55,11 @@ interface PointerGesture { |
| 40 | 55 | |
| 41 | 56 | interface Props { |
| 42 | 57 | readonly threadRef: ScopedThreadRef; |
| 43 | readonly tabId: string; | |
| 58 | readonly miniPlayer: PreviewMiniPlayerState; | |
| 44 | 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 | 64 | // Invisible grab zones straddling each edge; the cursor is the only affordance. |
| 50 | 65 | const RESIZE_HANDLES: ReadonlyArray<{ |
| ... | ... | @@ -61,17 +76,34 @@ const RESIZE_HANDLES: ReadonlyArray<{ |
| 61 | 76 | { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" }, |
| 62 | 77 | ]; |
| 63 | 78 | |
| 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 | */ | |
| 68 | export 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. */ | |
| 80 | export 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 | /> | |
| 74 | 98 | ); |
| 99 | } | |
| 100 | ||
| 101 | function BrowserMiniPlayer({ | |
| 102 | threadRef, | |
| 103 | tabId, | |
| 104 | miniPlayer, | |
| 105 | bottomInset, | |
| 106 | }: Props & { readonly tabId: string }) { | |
| 75 | 107 | const previewState = useThreadPreviewState(threadRef); |
| 76 | 108 | const snapshot = previewState.sessions[tabId] ?? null; |
| 77 | 109 | const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId); |
| ... | ... | @@ -79,25 +111,11 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props |
| 79 | 111 | const fittedSourceContent = useBrowserSurfaceStore( |
| 80 | 112 | (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null, |
| 81 | 113 | ); |
| 82 | const source = resolvePreviewMiniPlayerSourceSize( | |
| 114 | const sourceSize = resolvePreviewMiniPlayerSourceSize( | |
| 83 | 115 | snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT, |
| 84 | 116 | fittedSourceContent, |
| 85 | 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 | 120 | const openInPanel = () => { |
| 103 | 121 | usePreviewMiniPlayerStore.getState().close(threadRef); |
| ... | ... | @@ -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 | 309 | useLayoutEffect(() => { |
| 122 | 310 | const element = containerRef.current; |
| 123 | 311 | if (!element) return; |
| ... | ... | @@ -160,7 +348,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props |
| 160 | 348 | if (gesture.direction === null) { |
| 161 | 349 | store.move( |
| 162 | 350 | threadRef, |
| 163 | tabId, | |
| 351 | sourceKey, | |
| 164 | 352 | clampPreviewMiniPlayerPosition( |
| 165 | 353 | { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y }, |
| 166 | 354 | container, |
| ... | ... | @@ -174,12 +362,12 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props |
| 174 | 362 | start: gesture.frame, |
| 175 | 363 | direction: gesture.direction, |
| 176 | 364 | delta, |
| 177 | source, | |
| 365 | source: sourceSize, | |
| 178 | 366 | container, |
| 179 | 367 | bottomInset, |
| 180 | 368 | }); |
| 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 }); | |
| 183 | 371 | }; |
| 184 | 372 | |
| 185 | 373 | const endGesture = (event: ReactPointerEvent<HTMLElement>) => { |
| ... | ... | @@ -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 | 381 | return ( |
| 196 | 382 | <div ref={containerRef} className="pointer-events-none absolute inset-0"> |
| 197 | 383 | {frame ? ( |
| 198 | 384 | <section |
| 199 | aria-label="Floating browser preview" | |
| 200 | data-preview-mini-player={tabId} | |
| 385 | aria-label={label} | |
| 386 | data-preview-mini-player={sourceKey} | |
| 201 | 387 | className="pointer-events-none absolute select-none" |
| 202 | 388 | style={{ |
| 203 | 389 | left: frame.x, |
| 204 | 390 | top: frame.y, |
| 205 | 391 | width: frame.width, |
| 206 | 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 | 400 | <div |
| 212 | 401 | aria-hidden="true" |
| 213 | 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 | 416 | size="icon-xs" |
| 228 | 417 | aria-label="Open preview in right panel" |
| 229 | 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 | 424 | </TooltipTrigger> |
| 236 | 425 | <TooltipPopup side="top">Open in right panel</TooltipPopup> |
| 237 | 426 | </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} | |
| 263 | 428 | <Tooltip> |
| 264 | 429 | <TooltipTrigger |
| 265 | 430 | render={ |
| ... | ... | @@ -280,21 +445,8 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props |
| 280 | 445 | </div> |
| 281 | 446 | |
| 282 | 447 | <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)} | |
| 292 | 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 | 450 | {RESIZE_HANDLES.map(({ direction, className }) => ( |
| 299 | 451 | <div |
| 300 | 452 | key={direction} |
apps/web/src/components/preview/previewMiniPlayerLayout.test.ts+46| ... | ... | @@ -5,6 +5,8 @@ import { |
| 5 | 5 | clampPreviewMiniPlayerPosition, |
| 6 | 6 | PREVIEW_MINI_PLAYER_EDGE_GAP, |
| 7 | 7 | resizePreviewMiniPlayer, |
| 8 | resolveDeviceMiniPlayerCornerRadius, | |
| 9 | resolveDeviceMiniPlayerSourceSize, | |
| 8 | 10 | resolvePreviewMiniPlayerFrame, |
| 9 | 11 | resolvePreviewMiniPlayerSourceSize, |
| 10 | 12 | } from "./previewMiniPlayerLayout"; |
| ... | ... | @@ -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 | 79 | describe("resolvePreviewMiniPlayerFrame", () => { |
| 34 | 80 | it("opens at the source aspect ratio in the top-right corner", () => { |
| 35 | 81 | expect( |
apps/web/src/components/preview/previewMiniPlayerLayout.ts+44-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { PreviewViewportSetting } from "@t3tools/contracts"; | |
| 1 | import type { DevicePlatform, PreviewViewportSetting } from "@t3tools/contracts"; | |
| 2 | 2 | |
| 3 | 3 | import type { BrowserSurfaceContentPresentation } from "~/browser/browserSurfaceStore"; |
| 4 | 4 | import { |
| ... | ... | @@ -7,7 +7,10 @@ import { |
| 7 | 7 | } from "~/browser/browserViewportLayout"; |
| 8 | 8 | import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; |
| 9 | 9 | |
| 10 | import type { DeviceScreenSize } from "../device/deviceStream"; | |
| 11 | ||
| 10 | 12 | export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12; |
| 13 | export const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12; | |
| 11 | 14 | // The mini-player shell straddles this webview at 47 and 49; dialogs begin at 50. |
| 12 | 15 | export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48; |
| 13 | 16 | // A fresh player is the largest box at the source aspect ratio that fits here. |
| ... | ... | @@ -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 | 80 | const availableArea = ( |
| 38 | 81 | container: PreviewMiniPlayerSize, |
| 39 | 82 | bottomInset: number, |
apps/web/src/components/settings/IntegrationsSettings.tsx+1-1| ... | ... | @@ -718,7 +718,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled |
| 718 | 718 | return ( |
| 719 | 719 | <SettingsRow |
| 720 | 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 | 722 | resetAction={ |
| 723 | 723 | !disabled && autoShow !== DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW ? ( |
| 724 | 724 | <SettingResetButton |
apps/web/src/components/settings/settingsSearch.ts+1-1| ... | ... | @@ -519,7 +519,7 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 519 | 519 | id: "browser-auto-show-floating-preview", |
| 520 | 520 | title: "Auto-show floating preview", |
| 521 | 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 | 525 | id: "automatic-pull", |
apps/web/src/previewMiniPlayerStore.test.ts+51-17| ... | ... | @@ -2,10 +2,25 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | 2 | import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | 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 | 13 | const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")); |
| 8 | 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 | 25 | beforeEach(() => { |
| 11 | 26 | usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); |
| ... | ... | @@ -13,52 +28,71 @@ beforeEach(() => { |
| 13 | 28 | |
| 14 | 29 | describe("previewMiniPlayerStore", () => { |
| 15 | 30 | 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); | |
| 18 | 33 | |
| 19 | 34 | expect( |
| 20 | 35 | selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA), |
| 21 | ).toMatchObject({ tabId: "tab-a" }); | |
| 36 | ).toMatchObject({ source: tabA }); | |
| 22 | 37 | expect( |
| 23 | 38 | selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refB), |
| 24 | ).toMatchObject({ tabId: "tab-b" }); | |
| 39 | ).toMatchObject({ source: tabB }); | |
| 25 | 40 | }); |
| 26 | 41 | |
| 27 | 42 | 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); | |
| 31 | 46 | |
| 32 | 47 | expect( |
| 33 | 48 | selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA), |
| 34 | 49 | ).toEqual({ |
| 35 | tabId: "tab-b", | |
| 50 | source: tabB, | |
| 36 | 51 | position: { x: 24, y: 48 }, |
| 37 | 52 | width: null, |
| 38 | 53 | }); |
| 39 | 54 | }); |
| 40 | 55 | |
| 41 | 56 | 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 }); | |
| 45 | 60 | |
| 46 | 61 | expect( |
| 47 | 62 | selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA), |
| 48 | 63 | ).toEqual({ |
| 49 | tabId: "tab-b", | |
| 64 | source: tabB, | |
| 50 | 65 | position: null, |
| 51 | 66 | width: null, |
| 52 | 67 | }); |
| 53 | 68 | }); |
| 54 | 69 | |
| 55 | 70 | 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); | |
| 59 | 74 | |
| 60 | 75 | expect( |
| 61 | 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 | 1 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 2 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 2 | import type { DevicePlatform, ScopedThreadRef } from "@t3tools/contracts"; | |
| 3 | 3 | import { create } from "zustand"; |
| 4 | 4 | |
| 5 | 5 | export interface PreviewMiniPlayerPosition { |
| ... | ... | @@ -12,34 +12,66 @@ export interface PreviewMiniPlayerSize { |
| 12 | 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 | 26 | export interface PreviewMiniPlayerState { |
| 16 | readonly tabId: string; | |
| 27 | readonly source: PreviewMiniPlayerSource; | |
| 17 | 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 | 30 | readonly width: number | null; |
| 20 | 31 | } |
| 21 | 32 | |
| 22 | 33 | interface PreviewMiniPlayerStoreState { |
| 23 | 34 | readonly byThreadKey: Record<string, PreviewMiniPlayerState>; |
| 24 | readonly open: (ref: ScopedThreadRef, tabId: string) => void; | |
| 35 | readonly open: (ref: ScopedThreadRef, source: PreviewMiniPlayerSource) => void; | |
| 25 | 36 | 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; | |
| 28 | 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 | 58 | export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((set) => ({ |
| 32 | 59 | byThreadKey: {}, |
| 33 | open: (ref, tabId) => | |
| 60 | open: (ref, source) => | |
| 34 | 61 | set((state) => { |
| 35 | 62 | const threadKey = scopedThreadKey(ref); |
| 36 | 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 | 70 | return { |
| 39 | 71 | byThreadKey: { |
| 40 | 72 | ...state.byThreadKey, |
| 41 | 73 | [threadKey]: { |
| 42 | tabId, | |
| 74 | source, | |
| 43 | 75 | position: current?.position ?? null, |
| 44 | 76 | width: current?.width ?? null, |
| 45 | 77 | }, |
| ... | ... | @@ -53,11 +85,11 @@ export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()(( |
| 53 | 85 | const { [threadKey]: _closed, ...byThreadKey } = state.byThreadKey; |
| 54 | 86 | return { byThreadKey }; |
| 55 | 87 | }), |
| 56 | move: (ref, tabId, position) => | |
| 88 | move: (ref, sourceKey, position) => | |
| 57 | 89 | set((state) => { |
| 58 | 90 | const threadKey = scopedThreadKey(ref); |
| 59 | 91 | const current = state.byThreadKey[threadKey]; |
| 60 | if (!current || current.tabId !== tabId) return state; | |
| 92 | if (!current || previewMiniPlayerSourceKey(current.source) !== sourceKey) return state; | |
| 61 | 93 | if (current.position?.x === position.x && current.position.y === position.y) return state; |
| 62 | 94 | return { |
| 63 | 95 | byThreadKey: { |
| ... | ... | @@ -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 | 102 | set((state) => { |
| 71 | 103 | const threadKey = scopedThreadKey(ref); |
| 72 | 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 | 112 | return { |
| 75 | 113 | byThreadKey: { |
| 76 | 114 | ...state.byThreadKey, |
| ... | ... | @@ -94,3 +132,12 @@ export function selectThreadPreviewMiniPlayer( |
| 94 | 132 | if (!ref) return null; |
| 95 | 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 | 18 | Each device opens in its own tab. Use **+ → Device** to open another, and |
| 19 | 19 | double-click a tab name or choose **Rename** from its context menu to rename it. |
| 20 | 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 | 25 | Turn off the device hub in **Settings → Integrations → Devices** to stop the |
| 22 | 26 | helper processes; simulators and emulators keep running until you power them |
| 23 | 27 | off. |
| ... | ... | @@ -48,8 +52,11 @@ back from the device after a change. |
| 48 | 52 | |
| 49 | 53 | ## Agents and devices |
| 50 | 54 | |
| 51 | When an agent opens a device, the panel opens in web and desktop clients connected | |
| 52 | to the thread. Mobile clients show device activity in the thread timeline. Agents drive the device through the `agent-device` command line. T3 | |
| 55 | When an agent opens a device, it floats over the chat in web and desktop clients | |
| 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 | 60 | Code installs and starts it only after **Agent device access** is enabled. iOS |
| 54 | 61 | taps build a small test runner on first use, which takes a couple of minutes |
| 55 | 62 | once per server. Restart an existing agent session after granting access so it |