authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-27 13:58:17+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-27 13:58:17+02:00
logf4c394323d458224b25898ef784ca0a0cc2969af
tree02accab0d2f9f26c406535d81335f52bbefe99b7
parent2b06c08a4bdc1ffa46a37710d3bf27c80802a949
signature Signed by PGP key B5690EEEBB952194

Add background preview capture and picture-in-picture support (#4397)

Co-authored-by: codex <codex@users.noreply.github.com>

60 files changed, 4103 insertions(+), 594 deletions(-)

apps/desktop/src/electron/ElectronWindow.test.ts+1
......@@ -94,6 +94,7 @@ describe("ElectronWindow", () => {
9494 webPreferences: {
9595 preload: "/tmp/preload.js",
9696 partition: "persist:t3code-preview-test",
97 backgroundThrottling: null,
9798 sandbox: true,
9899 contextIsolation: true,
99100 nodeIntegration: false,
apps/desktop/src/electron/ElectronWindow.ts+2
......@@ -22,6 +22,7 @@ const ElectronWindowCreateOptions = Schema.Struct({
2222 webPreferences: Schema.Struct({
2323 preload: Schema.NullOr(Schema.String),
2424 partition: Schema.NullOr(Schema.String),
25 backgroundThrottling: Schema.NullOr(Schema.Boolean),
2526 sandbox: Schema.NullOr(Schema.Boolean),
2627 contextIsolation: Schema.NullOr(Schema.Boolean),
2728 nodeIntegration: Schema.NullOr(Schema.Boolean),
......@@ -179,6 +180,7 @@ export const make = Effect.gen(function* () {
179180 webPreferences: {
180181 preload: webPreferences?.preload ?? null,
181182 partition: webPreferences?.partition ?? null,
183 backgroundThrottling: webPreferences?.backgroundThrottling ?? null,
182184 sandbox: webPreferences?.sandbox ?? null,
183185 contextIsolation: webPreferences?.contextIsolation ?? null,
184186 nodeIntegration: webPreferences?.nodeIntegration ?? null,
apps/desktop/src/ipc/channels.ts+3
......@@ -61,6 +61,9 @@ export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-
6161export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot";
6262export const PREVIEW_REVEAL_ARTIFACT_CHANNEL = "desktop:preview-reveal-artifact";
6363export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact";
64export const PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL = "desktop:preview-pip-open";
65export const PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL = "desktop:preview-pip-close";
66export const PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL = "desktop:preview-pip-frame";
6467export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status";
6568export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot";
6669export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click";
apps/desktop/src/ipc/methods/preview.ts+12
......@@ -168,6 +168,16 @@ export const stopRecording = tabMethod(
168168 "desktop.ipc.preview.stopRecording",
169169 (manager, tabId) => manager.stopRecording(tabId),
170170);
171export const openPictureInPicture = tabMethod(
172 IpcChannels.PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL,
173 "desktop.ipc.preview.openPictureInPicture",
174 (manager, tabId) => manager.openPictureInPicture(tabId),
175);
176export const closePictureInPicture = tabMethod(
177 IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL,
178 "desktop.ipc.preview.closePictureInPicture",
179 (manager, tabId) => manager.closePictureInPicture(tabId),
180);
171181
172182export const clearCookies = DesktopIpc.makeIpcMethod({
173183 channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL,
......@@ -367,6 +377,8 @@ export const methods = [
367377 captureScreenshot,
368378 revealArtifact,
369379 copyArtifactToClipboard,
380 openPictureInPicture,
381 closePictureInPicture,
370382 automationStatus,
371383 automationSnapshot,
372384 automationClick,
apps/desktop/src/preload.ts+6
......@@ -179,6 +179,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
179179 ipcRenderer.invoke(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, { path }),
180180 copyArtifactToClipboard: (path) =>
181181 ipcRenderer.invoke(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, { path }),
182 pictureInPicture: {
183 open: (tabId) =>
184 ipcRenderer.invoke(IpcChannels.PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL, { tabId }),
185 close: (tabId) =>
186 ipcRenderer.invoke(IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, { tabId }),
187 },
182188 recording: {
183189 startScreencast: (tabId) =>
184190 ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }),
apps/desktop/src/preview-pip-preload.ts created+17
......@@ -0,0 +1,17 @@
1// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime.
2import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts";
3import { contextBridge, ipcRenderer } from "electron";
4
5import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "./ipc/channels.ts";
6
7contextBridge.exposeInMainWorld("previewPictureInPicture", {
8 onFrame: (listener: (frame: DesktopPreviewRecordingFrame) => void) => {
9 const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => {
10 if (typeof frame !== "object" || frame === null) return;
11 listener(frame as DesktopPreviewRecordingFrame);
12 };
13 ipcRenderer.on(PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, wrappedListener);
14 return () =>
15 ipcRenderer.removeListener(PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, wrappedListener);
16 },
17});
apps/desktop/src/preview/Manager.test.ts+765-2
......@@ -1,6 +1,8 @@
11import { it as effectIt } from "@effect/vitest";
2import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts";
23import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
34import * as Cause from "effect/Cause";
5import * as Deferred from "effect/Deferred";
46import * as Effect from "effect/Effect";
57import * as Exit from "effect/Exit";
68import * as FileSystem from "effect/FileSystem";
......@@ -19,7 +21,15 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts";
1921import * as BrowserSession from "./BrowserSession.ts";
2022import * as PreviewManager from "./Manager.ts";
2123
24describe("fitPictureInPictureContentSize", () => {
25 it("fits landscape and portrait viewports without letterboxing the window", () => {
26 expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 16 / 9)).toEqual([480, 270]);
27 expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 9 / 16)).toEqual([240, 427]);
28 });
29});
30
2231const {
32 browserWindowConstructor,
2333 createFromPath,
2434 fromId,
2535 getFocusedWebContents,
......@@ -29,8 +39,9 @@ const {
2939 writeFile,
3040 writeImage,
3141} = vi.hoisted(() => ({
42 browserWindowConstructor: vi.fn(),
3243 createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })),
33 fromId: vi.fn(() => null),
44 fromId: vi.fn((_id?: number) => null),
3445 getFocusedWebContents: vi.fn(() => null),
3546 mkdir: vi.fn((_path: string) => undefined),
3647 showItemInFolder: vi.fn(),
......@@ -40,6 +51,7 @@ const {
4051}));
4152
4253vi.mock("electron", () => ({
54 BrowserWindow: browserWindowConstructor,
4355 clipboard: {
4456 writeImage,
4557 },
......@@ -73,6 +85,10 @@ const environmentLayer = Layer.succeed(
7385 DesktopEnvironment.DesktopEnvironment,
7486 DesktopEnvironment.DesktopEnvironment.of({
7587 browserArtifactsDir: "/tmp/t3/dev/browser-artifacts",
88 dirname: "/tmp/t3/desktop",
89 path: {
90 join: (...parts: ReadonlyArray<string>) => parts.join("/"),
91 },
7692 } as DesktopEnvironment.DesktopEnvironment["Service"]),
7793);
7894
......@@ -92,7 +108,7 @@ const layer = PreviewManager.layer.pipe(
92108 Layer.provideMerge(environmentLayer),
93109 Layer.provideMerge(fileSystemLayer),
94110 Layer.provideMerge(Path.layer),
95 Layer.provideMerge(Layer.succeed(HostProcessPlatform, "linux")),
111 Layer.provideMerge(Layer.succeed(HostProcessPlatform, "darwin")),
96112);
97113const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError);
98114
......@@ -106,8 +122,73 @@ const withManager = <A>(
106122 return yield* use(manager);
107123 }).pipe(Effect.provide(layer), Effect.scoped);
108124
125interface TestCapturedPreviewImage {
126 readonly toJPEG: () => Buffer;
127 readonly getSize: () => { readonly width: number; readonly height: number };
128}
129
130const makeTestPreviewWebContents = (
131 capturePage: () => Promise<TestCapturedPreviewImage>,
132 id = 42,
133) =>
134 ({
135 id,
136 isDestroyed: () => false,
137 getType: () => "webview",
138 getURL: () => "https://example.com",
139 getTitle: () => "Example",
140 isLoading: () => false,
141 getZoomFactor: () => 1,
142 setZoomFactor: vi.fn(),
143 on: vi.fn(),
144 off: vi.fn(),
145 ipc: { on: vi.fn(), off: vi.fn() },
146 send: webviewSend,
147 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
148 setWindowOpenHandler: vi.fn(),
149 debugger: {
150 isAttached: () => false,
151 attach: vi.fn(),
152 sendCommand: vi.fn(async () => undefined),
153 on: vi.fn(),
154 off: vi.fn(),
155 },
156 capturePage,
157 }) as never;
158
159const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () => undefined) => {
160 const listeners = new Map<string, () => void>();
161 const send = vi.fn();
162 let destroyed = false;
163 const pictureInPictureWindow = {
164 isDestroyed: vi.fn(() => destroyed),
165 once: vi.fn((event: string, listener: () => void) => {
166 listeners.set(event, listener);
167 }),
168 setAlwaysOnTop: vi.fn(),
169 setVisibleOnAllWorkspaces: vi.fn(),
170 setAspectRatio: vi.fn(),
171 getContentSize: vi.fn(() => [480, 320]),
172 setContentSize: vi.fn(),
173 loadURL: vi.fn(loadURL),
174 showInactive: vi.fn(() => {
175 if (destroyed) throw new Error("Picture-in-picture window is closed.");
176 }),
177 close: vi.fn(() => {
178 if (destroyed) return;
179 destroyed = true;
180 listeners.get("closed")?.();
181 }),
182 webContents: {
183 send,
184 },
185 };
186 return { pictureInPictureWindow, send };
187};
188
109189describe("PreviewManager", () => {
110190 beforeEach(() => {
191 browserWindowConstructor.mockReset();
111192 fromId.mockClear();
112193 getFocusedWebContents.mockReset();
113194 getFocusedWebContents.mockReturnValue(null);
......@@ -438,6 +519,80 @@ describe("PreviewManager", () => {
438519 ),
439520 );
440521
522 effectIt.effect("blocks late webview and capture starts during tab close", () =>
523 withManager((manager) =>
524 Effect.gen(function* () {
525 const capturePage = vi.fn(async () => ({
526 toJPEG: () => Buffer.from("close-race-frame"),
527 getSize: () => ({ width: 1280, height: 720 }),
528 }));
529 const firstWebContents = makeTestPreviewWebContents(capturePage, 42);
530 const replacementWebContents = makeTestPreviewWebContents(capturePage, 43);
531 const replacementListenerSpies = replacementWebContents as unknown as {
532 readonly on: ReturnType<typeof vi.fn>;
533 readonly off: ReturnType<typeof vi.fn>;
534 readonly ipc: { readonly off: ReturnType<typeof vi.fn> };
535 };
536 fromId.mockImplementation((id) => {
537 if (id === 42) return firstWebContents;
538 if (id === 43) return replacementWebContents;
539 return null;
540 });
541 const { pictureInPictureWindow } = makeTestPictureInPictureWindow();
542 browserWindowConstructor.mockImplementation(function () {
543 return pictureInPictureWindow;
544 });
545
546 yield* manager.createTab("tab_close_register_race");
547 yield* manager.registerWebview("tab_close_register_race", 42);
548 yield* manager.openPictureInPicture("tab_close_register_race");
549
550 const closeCleanupPaused = yield* Deferred.make<void>();
551 const continueCloseCleanup = yield* Deferred.make<void>();
552 yield* manager.subscribeStateChanges((_tabId, state) =>
553 !state.pictureInPicture && state.webContentsId === 42
554 ? Deferred.succeed(closeCleanupPaused, undefined).pipe(
555 Effect.andThen(Deferred.await(continueCloseCleanup)),
556 )
557 : Effect.void,
558 );
559
560 const closeFiber = yield* manager
561 .closeTab("tab_close_register_race")
562 .pipe(Effect.forkChild({ startImmediately: true }));
563 yield* Deferred.await(closeCleanupPaused);
564 const recreateFiber = yield* manager
565 .createTab("tab_close_register_race")
566 .pipe(Effect.forkChild({ startImmediately: true }));
567 const registrationFiber = yield* manager
568 .registerWebview("tab_close_register_race", 43)
569 .pipe(Effect.forkChild({ startImmediately: true }));
570 yield* Effect.yieldNow;
571 expect(replacementListenerSpies.on).not.toHaveBeenCalled();
572 yield* manager.closeTab("tab_close_register_race");
573 const recordingExit = yield* Effect.exit(manager.startRecording("tab_close_register_race"));
574 yield* Deferred.succeed(continueCloseCleanup, undefined);
575 yield* Fiber.join(closeFiber);
576 const recreated = yield* Fiber.join(recreateFiber);
577 const registrationExit = yield* Fiber.await(registrationFiber);
578
579 for (const exit of [registrationExit, recordingExit]) {
580 expect(Exit.isFailure(exit)).toBe(true);
581 if (Exit.isSuccess(exit)) continue;
582 expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({
583 _tag: "PreviewTabNotFoundError",
584 tabId: "tab_close_register_race",
585 });
586 }
587 expect(replacementListenerSpies.on).not.toHaveBeenCalled();
588 expect(replacementListenerSpies.off).not.toHaveBeenCalled();
589 expect(replacementListenerSpies.ipc.off).not.toHaveBeenCalled();
590 expect(capturePage).toHaveBeenCalledOnce();
591 expect(recreated.webContentsId).toBeNull();
592 }),
593 ),
594 );
595
441596 effectIt.effect("keeps a main-frame load failure visible until a retry starts", () =>
442597 withManager((manager) =>
443598 Effect.gen(function* () {
......@@ -603,6 +758,614 @@ describe("PreviewManager", () => {
603758 ),
604759 );
605760
761 effectIt.effect("captures hidden preview recordings independently for concurrent tabs", () =>
762 withManager((manager) =>
763 Effect.gen(function* () {
764 const firstJpeg = Buffer.from("first-recording-frame");
765 const secondJpeg = Buffer.from("second-recording-frame");
766 const firstCapturePage = vi.fn(async () => ({
767 toJPEG: () => firstJpeg,
768 getSize: () => ({ width: 800, height: 600 }),
769 }));
770 const secondCapturePage = vi.fn(async () => ({
771 toJPEG: () => secondJpeg,
772 getSize: () => ({ width: 390, height: 844 }),
773 }));
774 const firstSendCommand = vi.fn(async () => undefined);
775 const secondSendCommand = vi.fn(async () => undefined);
776 const makeWebContents = (
777 id: number,
778 capturePage: typeof firstCapturePage,
779 sendCommand: typeof firstSendCommand,
780 ) =>
781 ({
782 id,
783 isDestroyed: () => false,
784 getType: () => "webview",
785 getURL: () => `https://example.com/${id}`,
786 getTitle: () => `Example ${id}`,
787 isLoading: () => false,
788 getZoomFactor: () => 1,
789 setZoomFactor: vi.fn(),
790 on: vi.fn(),
791 off: vi.fn(),
792 ipc: { on: vi.fn(), off: vi.fn() },
793 send: webviewSend,
794 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
795 setWindowOpenHandler: vi.fn(),
796 debugger: {
797 isAttached: () => false,
798 attach: vi.fn(),
799 sendCommand,
800 on: vi.fn(),
801 off: vi.fn(),
802 },
803 capturePage,
804 }) as never;
805 const webContentsById = new Map([
806 [41, makeWebContents(41, firstCapturePage, firstSendCommand)],
807 [42, makeWebContents(42, secondCapturePage, secondSendCommand)],
808 ]);
809 fromId.mockImplementation((id) =>
810 id === undefined ? null : (webContentsById.get(id) ?? null),
811 );
812 const frames: DesktopPreviewRecordingFrame[] = [];
813
814 yield* manager.subscribeRecordingFrames((frame) =>
815 Effect.sync(() => {
816 frames.push(frame);
817 }),
818 );
819 yield* manager.createTab("tab_1");
820 yield* manager.createTab("tab_2");
821 yield* manager.registerWebview("tab_1", 41);
822 yield* manager.registerWebview("tab_2", 42);
823 yield* Effect.all([manager.startRecording("tab_1"), manager.startRecording("tab_2")], {
824 concurrency: 2,
825 discard: true,
826 });
827
828 expect(firstCapturePage).toHaveBeenCalledOnce();
829 expect(secondCapturePage).toHaveBeenCalledOnce();
830 expect(frames).toHaveLength(2);
831 expect(frames).toEqual(
832 expect.arrayContaining([
833 expect.objectContaining({
834 tabId: "tab_1",
835 data: firstJpeg.toString("base64"),
836 width: 800,
837 height: 600,
838 }),
839 expect.objectContaining({
840 tabId: "tab_2",
841 data: secondJpeg.toString("base64"),
842 width: 390,
843 height: 844,
844 }),
845 ]),
846 );
847 expect(firstSendCommand).not.toHaveBeenCalledWith(
848 "Page.startScreencast",
849 expect.anything(),
850 );
851 expect(secondSendCommand).not.toHaveBeenCalledWith(
852 "Page.startScreencast",
853 expect.anything(),
854 );
855
856 yield* Effect.all([manager.stopRecording("tab_1"), manager.stopRecording("tab_2")], {
857 concurrency: 2,
858 discard: true,
859 });
860 }),
861 ),
862 );
863
864 effectIt.effect("drops a captured frame when the tab webview changes during capture", () =>
865 withManager((manager) =>
866 Effect.gen(function* () {
867 const staleImage: TestCapturedPreviewImage = {
868 toJPEG: vi.fn(() => Buffer.from("stale-recording-frame")),
869 getSize: vi.fn(() => ({ width: 1280, height: 720 })),
870 };
871 let markCaptureStarted!: () => void;
872 const captureStarted = new Promise<void>((resolve) => {
873 markCaptureStarted = resolve;
874 });
875 let resolveCapture: ((image: TestCapturedPreviewImage) => void) | undefined;
876 const staleCapturePage = vi.fn(() => {
877 markCaptureStarted();
878 return new Promise<TestCapturedPreviewImage>((resolve) => {
879 resolveCapture = resolve;
880 });
881 });
882 const replacementCapturePage = vi.fn(async () => ({
883 toJPEG: () => Buffer.from("replacement-recording-frame"),
884 getSize: () => ({ width: 1280, height: 720 }),
885 }));
886 const initialWebContents = makeTestPreviewWebContents(staleCapturePage, 42);
887 const replacementWebContents = makeTestPreviewWebContents(replacementCapturePage, 43);
888 fromId.mockImplementation((webContentsId?: number) => {
889 if (webContentsId === 42) return initialWebContents;
890 if (webContentsId === 43) return replacementWebContents;
891 return null;
892 });
893 const frames: DesktopPreviewRecordingFrame[] = [];
894
895 yield* manager.subscribeRecordingFrames((frame) =>
896 Effect.sync(() => {
897 frames.push(frame);
898 }),
899 );
900 yield* manager.createTab("tab_capture_replaced");
901 yield* manager.registerWebview("tab_capture_replaced", 42);
902 const recordingFiber = yield* manager
903 .startRecording("tab_capture_replaced")
904 .pipe(Effect.forkChild({ startImmediately: true }));
905 yield* Effect.promise(() => captureStarted);
906
907 yield* manager.registerWebview("tab_capture_replaced", 43);
908 resolveCapture?.(staleImage);
909 yield* Fiber.join(recordingFiber);
910
911 expect(staleImage.getSize).not.toHaveBeenCalled();
912 expect(staleImage.toJPEG).not.toHaveBeenCalled();
913 expect(frames).toHaveLength(0);
914 expect(replacementCapturePage).not.toHaveBeenCalled();
915
916 yield* manager.stopRecording("tab_capture_replaced");
917 }),
918 ),
919 );
920
921 effectIt.effect("keeps an in-flight frame when a capture consumer is added", () =>
922 withManager((manager) =>
923 Effect.gen(function* () {
924 const image: TestCapturedPreviewImage = {
925 toJPEG: vi.fn(() => Buffer.from("shared-in-flight-frame")),
926 getSize: vi.fn(() => ({ width: 1280, height: 720 })),
927 };
928 let markCaptureStarted!: () => void;
929 const captureStarted = new Promise<void>((resolve) => {
930 markCaptureStarted = resolve;
931 });
932 let resolveCapture: ((captured: TestCapturedPreviewImage) => void) | undefined;
933 const capturePage = vi.fn(() => {
934 markCaptureStarted();
935 return new Promise<TestCapturedPreviewImage>((resolve) => {
936 resolveCapture = resolve;
937 });
938 });
939 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
940 const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow();
941 browserWindowConstructor.mockImplementation(function () {
942 return pictureInPictureWindow;
943 });
944 const recordingFrames: DesktopPreviewRecordingFrame[] = [];
945 yield* manager.subscribeRecordingFrames((frame) =>
946 Effect.sync(() => {
947 recordingFrames.push(frame);
948 }),
949 );
950
951 yield* manager.createTab("tab_capture_consumer_added");
952 yield* manager.registerWebview("tab_capture_consumer_added", 42);
953 const recordingFiber = yield* manager
954 .startRecording("tab_capture_consumer_added")
955 .pipe(Effect.forkChild({ startImmediately: true }));
956 yield* Effect.promise(() => captureStarted);
957
958 yield* manager.openPictureInPicture("tab_capture_consumer_added");
959 resolveCapture?.(image);
960 yield* Fiber.join(recordingFiber);
961
962 expect(recordingFrames).toHaveLength(1);
963 expect(send).toHaveBeenCalledWith(
964 "desktop:preview-pip-frame",
965 expect.objectContaining({
966 tabId: "tab_capture_consumer_added",
967 data: Buffer.from("shared-in-flight-frame").toString("base64"),
968 }),
969 );
970
971 yield* manager.stopRecording("tab_capture_consumer_added");
972 yield* manager.closePictureInPicture("tab_capture_consumer_added");
973 }),
974 ),
975 );
976
977 effectIt.effect("shares background frame capture between recording and picture-in-picture", () =>
978 withManager((manager) =>
979 Effect.gen(function* () {
980 const jpeg = Buffer.from("shared-preview-frame");
981 const capturePage = vi.fn(async () => ({
982 toJPEG: () => jpeg,
983 getSize: () => ({ width: 1280, height: 720 }),
984 }));
985 fromId.mockReturnValue({
986 id: 42,
987 isDestroyed: () => false,
988 getType: () => "webview",
989 getURL: () => "https://example.com",
990 getTitle: () => "Example",
991 isLoading: () => false,
992 getZoomFactor: () => 1,
993 setZoomFactor: vi.fn(),
994 on: vi.fn(),
995 off: vi.fn(),
996 ipc: { on: vi.fn(), off: vi.fn() },
997 send: webviewSend,
998 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
999 setWindowOpenHandler: vi.fn(),
1000 debugger: {
1001 isAttached: () => false,
1002 attach: vi.fn(),
1003 sendCommand: vi.fn(async () => undefined),
1004 on: vi.fn(),
1005 off: vi.fn(),
1006 },
1007 capturePage,
1008 } as never);
1009
1010 const pictureInPictureListeners = new Map<string, () => void>();
1011 const pictureInPictureSend = vi.fn();
1012 const pictureInPictureWindow = {
1013 isDestroyed: vi.fn(() => false),
1014 once: vi.fn((event: string, listener: () => void) => {
1015 pictureInPictureListeners.set(event, listener);
1016 }),
1017 setAlwaysOnTop: vi.fn(),
1018 setVisibleOnAllWorkspaces: vi.fn(),
1019 setAspectRatio: vi.fn(),
1020 getContentSize: vi.fn(() => [480, 320] as [number, number]),
1021 setContentSize: vi.fn(),
1022 loadURL: vi.fn(async () => undefined),
1023 showInactive: vi.fn(),
1024 close: vi.fn(() => {
1025 pictureInPictureListeners.get("closed")?.();
1026 }),
1027 webContents: {
1028 send: pictureInPictureSend,
1029 },
1030 };
1031 browserWindowConstructor.mockImplementation(function () {
1032 return pictureInPictureWindow;
1033 });
1034 const states: PreviewManager.PreviewTabState[] = [];
1035 const recordingFrames: DesktopPreviewRecordingFrame[] = [];
1036
1037 yield* manager.subscribeStateChanges((_tabId, state) =>
1038 Effect.sync(() => {
1039 states.push(state);
1040 }),
1041 );
1042 yield* manager.subscribeRecordingFrames((frame) =>
1043 Effect.sync(() => {
1044 recordingFrames.push(frame);
1045 }),
1046 );
1047 yield* manager.createTab("tab_pip");
1048 yield* manager.registerWebview("tab_pip", 42);
1049 yield* manager.openPictureInPicture("tab_pip");
1050
1051 expect(browserWindowConstructor).toHaveBeenCalledWith(
1052 expect.objectContaining({
1053 alwaysOnTop: true,
1054 show: false,
1055 skipTaskbar: true,
1056 webPreferences: expect.objectContaining({
1057 preload: "/tmp/t3/desktop/preview-pip-preload.cjs",
1058 backgroundThrottling: false,
1059 }),
1060 }),
1061 );
1062 expect(pictureInPictureWindow.showInactive).toHaveBeenCalledOnce();
1063 expect(pictureInPictureWindow.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, {
1064 visibleOnFullScreen: true,
1065 skipTransformProcessType: true,
1066 });
1067 expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]);
1068 expect(pictureInPictureWindow.setContentSize).toHaveBeenCalledWith(480, 270, false);
1069 expect(pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[0]).toBeLessThan(
1070 pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0] ?? 0,
1071 );
1072 expect(pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0]).toBeLessThan(
1073 pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[1] ?? 0,
1074 );
1075 expect(pictureInPictureSend).toHaveBeenCalledWith(
1076 "desktop:preview-pip-frame",
1077 expect.objectContaining({
1078 tabId: "tab_pip",
1079 data: jpeg.toString("base64"),
1080 width: 1280,
1081 height: 720,
1082 }),
1083 );
1084 expect(states.at(-1)?.pictureInPicture).toBe(true);
1085 expect(capturePage).toHaveBeenCalledOnce();
1086
1087 yield* manager.startRecording("tab_pip");
1088 expect(capturePage).toHaveBeenCalledOnce();
1089 expect(recordingFrames).toHaveLength(0);
1090
1091 yield* TestClock.adjust(100);
1092 expect(capturePage).toHaveBeenCalledTimes(2);
1093 expect(recordingFrames).toHaveLength(1);
1094
1095 yield* manager.stopRecording("tab_pip");
1096 const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length;
1097 yield* TestClock.adjust(100);
1098 expect(capturePage).toHaveBeenCalledTimes(3);
1099 expect(pictureInPictureSend.mock.calls.length).toBeGreaterThan(
1100 framesBeforePictureInPictureOnlyTick,
1101 );
1102 expect(recordingFrames).toHaveLength(1);
1103
1104 yield* manager.closePictureInPicture("tab_pip");
1105 expect(pictureInPictureWindow.close).toHaveBeenCalledOnce();
1106 expect(states.at(-1)?.pictureInPicture).toBe(false);
1107 const capturesAfterClose = capturePage.mock.calls.length;
1108 yield* TestClock.adjust(200);
1109 expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose);
1110 }),
1111 ),
1112 );
1113
1114 effectIt.effect("retries a cold hidden-tab capture without dropping recording", () =>
1115 withManager((manager) =>
1116 Effect.gen(function* () {
1117 const jpeg = Buffer.from("recovered-preview-frame");
1118 const capturePage = vi.fn(async () => ({
1119 toJPEG: () => jpeg,
1120 getSize: () => ({ width: 1280, height: 720 }),
1121 }));
1122 capturePage.mockRejectedValueOnce(new Error("UnknownVizError"));
1123 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
1124 const frames: DesktopPreviewRecordingFrame[] = [];
1125
1126 yield* manager.subscribeRecordingFrames((frame) =>
1127 Effect.sync(() => {
1128 frames.push(frame);
1129 }),
1130 );
1131 yield* manager.createTab("tab_cold_capture");
1132 yield* manager.registerWebview("tab_cold_capture", 42);
1133
1134 yield* manager.startRecording("tab_cold_capture");
1135
1136 expect(capturePage).toHaveBeenCalledOnce();
1137 expect(frames).toHaveLength(0);
1138
1139 yield* TestClock.adjust(100);
1140
1141 expect(capturePage).toHaveBeenCalledTimes(2);
1142 expect(frames).toEqual([
1143 expect.objectContaining({
1144 tabId: "tab_cold_capture",
1145 data: jpeg.toString("base64"),
1146 width: 1280,
1147 height: 720,
1148 }),
1149 ]);
1150
1151 yield* manager.stopRecording("tab_cold_capture");
1152 }),
1153 ),
1154 );
1155
1156 effectIt.effect("drops empty frames before picture-in-picture delivery", () =>
1157 withManager((manager) =>
1158 Effect.gen(function* () {
1159 const validImage: TestCapturedPreviewImage = {
1160 toJPEG: () => Buffer.from("valid-preview-frame"),
1161 getSize: () => ({ width: 1280, height: 720 }),
1162 };
1163 const capturePage = vi.fn(async () => validImage);
1164 capturePage.mockResolvedValueOnce({
1165 toJPEG: () => Buffer.from("empty-preview-frame"),
1166 getSize: () => ({ width: 0, height: 0 }),
1167 });
1168 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
1169 const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow();
1170 browserWindowConstructor.mockImplementation(function () {
1171 return pictureInPictureWindow;
1172 });
1173
1174 yield* manager.createTab("tab_empty_frame");
1175 yield* manager.registerWebview("tab_empty_frame", 42);
1176 yield* manager.openPictureInPicture("tab_empty_frame");
1177
1178 expect(capturePage).toHaveBeenCalledOnce();
1179 expect(pictureInPictureWindow.setAspectRatio).not.toHaveBeenCalled();
1180 expect(send).not.toHaveBeenCalled();
1181
1182 yield* TestClock.adjust(100);
1183
1184 expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]);
1185 expect(send).toHaveBeenCalledOnce();
1186 yield* manager.closePictureInPicture("tab_empty_frame");
1187 }),
1188 ),
1189 );
1190
1191 effectIt.effect("does not publish picture-in-picture readiness after window teardown", () =>
1192 withManager((manager) =>
1193 Effect.gen(function* () {
1194 const capturePage = vi.fn(async () => ({
1195 toJPEG: () => Buffer.from("closing-preview-frame"),
1196 getSize: () => ({ width: 1280, height: 720 }),
1197 }));
1198 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
1199 const { pictureInPictureWindow } = makeTestPictureInPictureWindow();
1200 pictureInPictureWindow.showInactive.mockImplementationOnce(() => {
1201 pictureInPictureWindow.close();
1202 });
1203 browserWindowConstructor.mockImplementation(function () {
1204 return pictureInPictureWindow;
1205 });
1206 const states: PreviewManager.PreviewTabState[] = [];
1207 yield* manager.subscribeStateChanges((_tabId, state) =>
1208 Effect.sync(() => {
1209 states.push(state);
1210 }),
1211 );
1212
1213 yield* manager.createTab("tab_pip_teardown");
1214 yield* manager.registerWebview("tab_pip_teardown", 42);
1215 const openExit = yield* Effect.exit(manager.openPictureInPicture("tab_pip_teardown"));
1216
1217 expect(Exit.hasInterrupts(openExit)).toBe(true);
1218 expect(pictureInPictureWindow.close).toHaveBeenCalledOnce();
1219 expect(states.some((state) => state.pictureInPicture)).toBe(false);
1220 expect(states.at(-1)?.pictureInPicture).toBe(false);
1221 }),
1222 ),
1223 );
1224
1225 effectIt.effect("closes an initializing picture-in-picture without blocking later opens", () =>
1226 withManager((manager) =>
1227 Effect.gen(function* () {
1228 const capturePage = vi.fn(async () => ({
1229 toJPEG: () => Buffer.from("serialized-preview-frame"),
1230 getSize: () => ({ width: 1280, height: 720 }),
1231 }));
1232 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
1233 const { pictureInPictureWindow: initializingWindow } = makeTestPictureInPictureWindow(
1234 () =>
1235 new Promise<void>(() => {
1236 // Simulate a renderer load that never settles.
1237 }),
1238 );
1239 const { pictureInPictureWindow: reopenedWindow } = makeTestPictureInPictureWindow();
1240 browserWindowConstructor
1241 .mockImplementationOnce(function () {
1242 return initializingWindow;
1243 })
1244 .mockImplementationOnce(function () {
1245 return reopenedWindow;
1246 });
1247 const states: PreviewManager.PreviewTabState[] = [];
1248 yield* manager.subscribeStateChanges((_tabId, state) =>
1249 Effect.sync(() => {
1250 states.push(state);
1251 }),
1252 );
1253 yield* manager.createTab("tab_concurrent_pip");
1254 yield* manager.registerWebview("tab_concurrent_pip", 42);
1255
1256 const firstOpen = yield* manager
1257 .openPictureInPicture("tab_concurrent_pip")
1258 .pipe(Effect.forkChild);
1259 yield* Effect.yieldNow;
1260 const secondOpen = yield* manager
1261 .openPictureInPicture("tab_concurrent_pip")
1262 .pipe(Effect.forkChild);
1263 yield* Effect.yieldNow;
1264 const close = yield* manager
1265 .closePictureInPicture("tab_concurrent_pip")
1266 .pipe(Effect.forkChild);
1267 yield* Effect.yieldNow;
1268
1269 expect(browserWindowConstructor).toHaveBeenCalledOnce();
1270 expect(initializingWindow.loadURL).toHaveBeenCalledOnce();
1271 expect(initializingWindow.close).toHaveBeenCalledOnce();
1272 const [firstOpenExit, secondOpenExit] = yield* Effect.all([
1273 Fiber.await(firstOpen),
1274 Fiber.await(secondOpen),
1275 ]);
1276 yield* Fiber.join(close);
1277
1278 expect(Exit.hasInterrupts(firstOpenExit)).toBe(true);
1279 expect(Exit.hasInterrupts(secondOpenExit)).toBe(true);
1280 expect(initializingWindow.showInactive).not.toHaveBeenCalled();
1281 expect(capturePage).not.toHaveBeenCalled();
1282 expect(states.at(-1)?.pictureInPicture).toBe(false);
1283
1284 yield* manager.openPictureInPicture("tab_concurrent_pip");
1285
1286 expect(browserWindowConstructor).toHaveBeenCalledTimes(2);
1287 expect(reopenedWindow.showInactive).toHaveBeenCalledOnce();
1288 expect(capturePage).toHaveBeenCalledOnce();
1289 expect(states.at(-1)?.pictureInPicture).toBe(true);
1290
1291 yield* manager.closePictureInPicture("tab_concurrent_pip");
1292
1293 expect(browserWindowConstructor).toHaveBeenCalledTimes(2);
1294 expect(reopenedWindow.close).toHaveBeenCalledOnce();
1295 expect(states.at(-1)?.pictureInPicture).toBe(false);
1296 const capturesAfterClose = capturePage.mock.calls.length;
1297 yield* TestClock.adjust(200);
1298 expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose);
1299 }),
1300 ),
1301 );
1302
1303 effectIt.effect("rejects picture-in-picture when its webview changes during initialization", () =>
1304 withManager((manager) =>
1305 Effect.gen(function* () {
1306 const initialCapturePage = vi.fn(async () => ({
1307 toJPEG: () => Buffer.from("stale-preview-frame"),
1308 getSize: () => ({ width: 1280, height: 720 }),
1309 }));
1310 const replacementCapturePage = vi.fn(async () => ({
1311 toJPEG: () => Buffer.from("replacement-preview-frame"),
1312 getSize: () => ({ width: 1280, height: 720 }),
1313 }));
1314 const initialWebContents = makeTestPreviewWebContents(initialCapturePage, 42);
1315 const replacementWebContents = makeTestPreviewWebContents(replacementCapturePage, 43);
1316 fromId.mockImplementation((webContentsId?: number) => {
1317 if (webContentsId === 42) return initialWebContents;
1318 if (webContentsId === 43) return replacementWebContents;
1319 return null;
1320 });
1321 let resolveLoad: (() => void) | undefined;
1322 const { pictureInPictureWindow } = makeTestPictureInPictureWindow(
1323 () =>
1324 new Promise<void>((resolve) => {
1325 resolveLoad = resolve;
1326 }),
1327 );
1328 browserWindowConstructor.mockImplementation(function () {
1329 return pictureInPictureWindow;
1330 });
1331
1332 yield* manager.createTab("tab_replaced_webview");
1333 yield* manager.registerWebview("tab_replaced_webview", 42);
1334 const open = yield* manager
1335 .openPictureInPicture("tab_replaced_webview")
1336 .pipe(Effect.forkChild({ startImmediately: true }));
1337 yield* Effect.yieldNow;
1338 expect(pictureInPictureWindow.loadURL).toHaveBeenCalledOnce();
1339 expect(resolveLoad).toBeDefined();
1340 const concurrentOpen = yield* manager
1341 .openPictureInPicture("tab_replaced_webview")
1342 .pipe(Effect.forkChild({ startImmediately: true }));
1343 yield* Effect.yieldNow;
1344
1345 yield* manager.registerWebview("tab_replaced_webview", 43);
1346 resolveLoad?.();
1347
1348 const openExits = yield* Effect.all([Fiber.await(open), Fiber.await(concurrentOpen)]);
1349 for (const openExit of openExits) {
1350 expect(Exit.isFailure(openExit)).toBe(true);
1351 if (Exit.isSuccess(openExit)) continue;
1352 const error = Option.getOrThrow(Cause.findErrorOption(openExit.cause));
1353 expect(error).toMatchObject({
1354 _tag: "PreviewOperationError",
1355 operation: "pictureInPicture.validateWebContents",
1356 tabId: "tab_replaced_webview",
1357 webContentsId: 42,
1358 });
1359 }
1360 expect(browserWindowConstructor).toHaveBeenCalledOnce();
1361 expect(pictureInPictureWindow.close).toHaveBeenCalledOnce();
1362 expect(pictureInPictureWindow.showInactive).not.toHaveBeenCalled();
1363 expect(initialCapturePage).not.toHaveBeenCalled();
1364 expect(replacementCapturePage).not.toHaveBeenCalled();
1365 }),
1366 ),
1367 );
1368
6061369 effectIt.effect("keeps element picking active during subframe navigation", () =>
6071370 withManager((manager) =>
6081371 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+797-165
......@@ -28,21 +28,16 @@ import type {
2828} from "@t3tools/contracts";
2929import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3030import { normalizePreviewUrl } from "@t3tools/shared/preview";
31import {
32 type BrowserWindow,
33 type Session,
34 clipboard,
35 nativeImage,
36 shell,
37 webContents,
38} from "electron";
31import { BrowserWindow, type Session, clipboard, nativeImage, shell, webContents } from "electron";
3932import * as Cause from "effect/Cause";
4033import * as Clock from "effect/Clock";
4134import * as Context from "effect/Context";
4235import * as DateTime from "effect/DateTime";
36import * as Deferred from "effect/Deferred";
4337import * as Effect from "effect/Effect";
4438import * as Exit from "effect/Exit";
4539import * as FileSystem from "effect/FileSystem";
40import * as Fiber from "effect/Fiber";
4641import * as Layer from "effect/Layer";
4742import * as Option from "effect/Option";
4843import * as Path from "effect/Path";
......@@ -53,6 +48,7 @@ import * as Scope from "effect/Scope";
5348import * as SynchronizedRef from "effect/SynchronizedRef";
5449
5550import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
51import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";
5652import * as BrowserSession from "./BrowserSession.ts";
5753import {
5854 ANNOTATION_CAPTURED_CHANNEL,
......@@ -85,6 +81,7 @@ export interface PreviewTabState {
8581 canGoBack: boolean;
8682 canGoForward: boolean;
8783 zoomFactor: number;
84 pictureInPicture: boolean;
8885 colorScheme: DesktopPreviewColorScheme;
8986 controller: "human" | "agent" | "none";
9087 updatedAt: string;
......@@ -101,6 +98,12 @@ const MAX_EVALUATION_BYTES = 64_000;
10198const MAX_VISIBLE_TEXT_LENGTH = 20_000;
10299const MAX_INTERACTIVE_ELEMENTS = 200;
103100const MAX_SCREENSHOT_WIDTH = 1280;
101const RECORDING_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12);
102const RECORDING_JPEG_QUALITY = 80;
103const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480;
104const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320;
105const PICTURE_IN_PICTURE_MIN_WIDTH = 240;
106const PICTURE_IN_PICTURE_MIN_HEIGHT = 160;
104107const DIAGNOSTIC_BUFFER_LIMIT = 200;
105108const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;
106109const AGENT_CURSOR_MOVE_MS = 160;
......@@ -126,6 +129,54 @@ const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = {
126129 fontMono: "ui-monospace, monospace",
127130};
128131
132export const buildPreviewPictureInPictureDataUrl = (): string => {
133 const html = `<!doctype html>
134<html>
135 <head>
136 <meta charset="utf-8">
137 <meta
138 http-equiv="Content-Security-Policy"
139 content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'"
140 >
141 <meta name="color-scheme" content="dark">
142 <style>
143 html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #111; }
144 body { display: grid; place-items: center; }
145 img { width: 100%; height: 100%; object-fit: contain; user-select: none; -webkit-user-drag: none; }
146 </style>
147 </head>
148 <body>
149 <img id="preview-frame" alt="Live browser preview">
150 <script>
151 const frame = document.getElementById("preview-frame");
152 window.previewPictureInPicture.onFrame((next) => {
153 frame.src = "data:image/jpeg;base64," + next.data;
154 });
155 </script>
156 </body>
157</html>`;
158 return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
159};
160
161export const fitPictureInPictureContentSize = (
162 current: ReadonlyArray<number>,
163 aspectRatio: number,
164): readonly [width: number, height: number] => {
165 const currentWidth = Math.max(1, current[0] ?? PICTURE_IN_PICTURE_INITIAL_WIDTH);
166 const currentHeight = Math.max(1, current[1] ?? PICTURE_IN_PICTURE_INITIAL_HEIGHT);
167 let width =
168 currentWidth / currentHeight > aspectRatio ? currentHeight * aspectRatio : currentWidth;
169 let height = width / aspectRatio;
170 const minimumScale = Math.max(
171 1,
172 PICTURE_IN_PICTURE_MIN_WIDTH / width,
173 PICTURE_IN_PICTURE_MIN_HEIGHT / height,
174 );
175 width *= minimumScale;
176 height *= minimumScale;
177 return [Math.round(width), Math.round(height)];
178};
179
129180const artifactSiteSlug = (rawUrl: string): string => {
130181 try {
131182 const url = new URL(rawUrl);
......@@ -296,6 +347,20 @@ interface ManagedListeners {
296347 readonly scope: Scope.Closeable;
297348}
298349
350type FrameCaptureConsumer = "picture-in-picture" | "recording";
351
352interface FrameCaptureSession {
353 readonly scope: Scope.Closeable;
354 readonly consumers: ReadonlySet<FrameCaptureConsumer>;
355}
356
357interface PictureInPictureSession {
358 readonly window: BrowserWindow;
359 readonly webContentsId: number;
360 readonly ready: Deferred.Deferred<void, PreviewManagerError>;
361 readonly initializationScope: Scope.Closeable;
362}
363
299364interface PickSession {
300365 readonly cancel: Effect.Effect<void>;
301366}
......@@ -380,6 +445,7 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal):
380445
381446const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* (
382447 artifactDirectory: string,
448 pictureInPicturePreloadPath: string,
383449) {
384450 const fileSystem = yield* FileSystem.FileSystem;
385451 const hostPlatform = yield* HostProcessPlatform;
......@@ -415,7 +481,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
415481 >(new Map());
416482 const actionSequenceRef = yield* Ref.make(0);
417483 const pointerSequenceRef = yield* Ref.make(0);
418 const recordingTabIdRef = yield* Ref.make<Option.Option<string>>(Option.none());
484 const frameCaptureSessionsRef = yield* SynchronizedRef.make<
485 ReadonlyMap<string, FrameCaptureSession>
486 >(new Map());
487 const pictureInPictureSessionsRef = yield* SynchronizedRef.make<
488 ReadonlyMap<string, PictureInPictureSession>
489 >(new Map());
490 const pictureInPictureAspectRatiosRef = yield* Ref.make<ReadonlyMap<string, number>>(new Map());
491 const pictureInPictureMutationSemaphore = yield* Semaphore.make(1);
492 const closingTabIdsRef = yield* Ref.make<ReadonlySet<string>>(new Set());
493 const tabLifecycleLocks = new Map<
494 string,
495 { readonly semaphore: Semaphore.Semaphore; users: number }
496 >();
497 const tabLifecycleGenerations = new Map<string, number>();
419498
420499 const attempt = <A>(errorContext: PreviewOperationContext, evaluate: () => A) =>
421500 Effect.try({
......@@ -446,6 +525,58 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
446525 update(copy);
447526 return copy;
448527 };
528 const withTabLifecycleLock = <A, E, R>(
529 tabId: string,
530 effect: Effect.Effect<A, E, R>,
531 ): Effect.Effect<A, E, R> =>
532 Effect.suspend(() => {
533 const lifecycle = tabLifecycleLocks.get(tabId) ?? {
534 semaphore: Semaphore.makeUnsafe(1),
535 users: 0,
536 };
537 lifecycle.users += 1;
538 tabLifecycleLocks.set(tabId, lifecycle);
539 return lifecycle.semaphore.withPermit(effect).pipe(
540 Effect.ensuring(
541 Effect.sync(() => {
542 lifecycle.users -= 1;
543 if (lifecycle.users === 0 && tabLifecycleLocks.get(tabId) === lifecycle) {
544 tabLifecycleLocks.delete(tabId);
545 }
546 }),
547 ),
548 );
549 });
550 const stopFrameCapture = Effect.fn("PreviewManager.stopFrameCapture")(function* (
551 tabId: string,
552 consumer: FrameCaptureConsumer,
553 ) {
554 const captureScope = yield* SynchronizedRef.modify(frameCaptureSessionsRef, (sessions) => {
555 const current = sessions.get(tabId);
556 if (!current || !current.consumers.has(consumer)) {
557 return [undefined, sessions] as const;
558 }
559 const consumers = new Set(current.consumers);
560 consumers.delete(consumer);
561 if (consumers.size > 0) {
562 return [
563 undefined,
564 replaceMap(sessions, (copy) => {
565 copy.set(tabId, { ...current, consumers });
566 }),
567 ] as const;
568 }
569 return [
570 current.scope,
571 replaceMap(sessions, (copy) => {
572 copy.delete(tabId);
573 }),
574 ] as const;
575 });
576 if (captureScope) {
577 yield* Scope.close(captureScope, Exit.void).pipe(Effect.ignore);
578 }
579 });
449580
450581 const deliverEvent = (
451582 eventKind: "state-change" | "recording-frame" | "pointer-event",
......@@ -539,15 +670,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
539670 ),
540671 );
541672
542 const tabIdForWebContents = Effect.fn("PreviewManager.tabIdForWebContents")(function* (
543 webContentsId: number,
544 ) {
545 const tabs = yield* SynchronizedRef.get(tabsRef);
546 return (
547 Array.from(tabs.entries()).find(([, tab]) => tab.webContentsId === webContentsId)?.[0] ?? null
548 );
549 });
550
551673 const pushBounded = <A>(buffer: ReadonlyArray<A>, entry: A): ReadonlyArray<A> =>
552674 [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT);
553675
......@@ -731,42 +853,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
731853 const scope = yield* Scope.fork(parentScope, "sequential");
732854 const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")(
733855 function* (method: string, params: Record<string, unknown>) {
734 if (method === "Page.screencastFrame") {
735 const sessionId = params["sessionId"];
736 if (typeof sessionId === "number") {
737 yield* attemptPromise(
738 {
739 operation: "ackScreencastFrame",
740 webContentsId: wc.id,
741 },
742 () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }),
743 ).pipe(Effect.ignore);
744 }
745 const tabId = yield* tabIdForWebContents(wc.id);
746 const metadata =
747 typeof params["metadata"] === "object" && params["metadata"] !== null
748 ? (params["metadata"] as Record<string, unknown>)
749 : {};
750 if (tabId && typeof params["data"] === "string") {
751 const receivedAt = yield* currentIso;
752 const listeners = yield* Ref.get(recordingFrameListenersRef);
753 const frame: DesktopPreviewRecordingFrame = {
754 tabId,
755 data: params["data"],
756 width:
757 typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0,
758 height:
759 typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0,
760 receivedAt,
761 };
762 yield* Effect.forEach(
763 listeners,
764 (listener) =>
765 deliverEvent("recording-frame", frame.tabId, () => listener(frame)),
766 { discard: true },
767 );
768 }
769 }
770856 yield* captureDiagnosticMessage(wc.id, method, params);
771857 },
772858 );
......@@ -1276,71 +1362,132 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12761362 window: BrowserWindow,
12771363 ) {
12781364 yield* Ref.set(mainWindowRef, Option.some(window));
1365 window.once("closed", () => {
1366 runFork(closeAllPictureInPicture());
1367 });
12791368 });
12801369
1281 const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) {
1370 const createTabUnlocked = Effect.fn("PreviewManager.createTabUnlocked")(function* (
1371 tabId: string,
1372 ) {
12821373 const updatedAt = yield* currentIso;
1283 const state = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1284 const existing = tabs.get(tabId);
1285 if (existing) return [existing, tabs] as const;
1286 const initial: PreviewTabState = {
1287 tabId,
1288 webContentsId: null,
1289 navStatus: { kind: "Idle" },
1290 canGoBack: false,
1291 canGoForward: false,
1292 zoomFactor: DEFAULT_ZOOM_FACTOR,
1293 colorScheme: "system",
1294 controller: "none",
1295 updatedAt,
1296 };
1374 const result = yield* SynchronizedRef.modify(
1375 tabsRef,
1376 (
1377 tabs,
1378 ): readonly [
1379 { readonly state: PreviewTabState; readonly created: boolean },
1380 ReadonlyMap<string, PreviewTabState>,
1381 ] => {
1382 const existing = tabs.get(tabId);
1383 if (existing) return [{ state: existing, created: false }, tabs] as const;
1384 const initial: PreviewTabState = {
1385 tabId,
1386 webContentsId: null,
1387 navStatus: { kind: "Idle" },
1388 canGoBack: false,
1389 canGoForward: false,
1390 zoomFactor: DEFAULT_ZOOM_FACTOR,
1391 pictureInPicture: false,
1392 colorScheme: "system",
1393 controller: "none",
1394 updatedAt,
1395 };
1396 return [
1397 { state: initial, created: true },
1398 replaceMap(tabs, (copy) => {
1399 copy.set(tabId, initial);
1400 }),
1401 ] as const;
1402 },
1403 );
1404 if (result.created) {
1405 tabLifecycleGenerations.set(tabId, (tabLifecycleGenerations.get(tabId) ?? 0) + 1);
1406 }
1407 yield* emit(tabId, result.state);
1408 return result.state;
1409 });
1410
1411 const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) {
1412 return yield* withTabLifecycleLock(tabId, createTabUnlocked(tabId));
1413 });
1414
1415 const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) {
1416 if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return;
1417 yield* Effect.all(
1418 [
1419 cancelPickElement(tabId),
1420 closePictureInPicture(tabId),
1421 stopFrameCapture(tabId, "recording"),
1422 ],
1423 {
1424 concurrency: 3,
1425 discard: true,
1426 },
1427 );
1428 const tab = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1429 const current = tabs.get(tabId);
1430 if (!current) return [Option.none<PreviewTabState>(), tabs] as const;
12971431 return [
1298 initial,
1432 Option.some(current),
12991433 replaceMap(tabs, (copy) => {
1300 copy.set(tabId, initial);
1434 copy.delete(tabId);
13011435 }),
13021436 ] as const;
13031437 });
1304 yield* emit(tabId, state);
1305 return state;
1306 });
1307
1308 const closeTab = Effect.fn("PreviewManager.closeTab")(function* (tabId: string) {
1309 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
1310 if (!tab) return;
1311 yield* cancelPickElement(tabId);
1312 if (tab.webContentsId != null) {
1438 if (Option.isNone(tab)) return;
1439 const closedTab = tab.value;
1440 if (closedTab.webContentsId != null) {
13131441 yield* Effect.all(
1314 [detachControlSession(tab.webContentsId), detachListeners(tab.webContentsId)],
1442 [detachControlSession(closedTab.webContentsId), detachListeners(closedTab.webContentsId)],
13151443 { concurrency: 2, discard: true },
13161444 );
13171445 }
13181446 const updatedAt = yield* currentIso;
13191447 const closed: PreviewTabState = {
1320 ...tab,
1448 ...closedTab,
13211449 webContentsId: null,
13221450 navStatus: { kind: "Idle" },
13231451 canGoBack: false,
13241452 canGoForward: false,
13251453 zoomFactor: DEFAULT_ZOOM_FACTOR,
1454 pictureInPicture: false,
13261455 colorScheme: "system",
13271456 controller: "none",
13281457 updatedAt,
13291458 };
1330 yield* SynchronizedRef.update(tabsRef, (tabs) =>
1331 replaceMap(tabs, (copy) => {
1332 copy.delete(tabId);
1333 }),
1334 );
13351459 yield* emit(tabId, closed);
13361460 });
13371461
1338 const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* (
1462 const closeTab = Effect.fn("PreviewManager.closeTab")(function* (tabId: string) {
1463 const claimed = yield* Ref.modify(closingTabIdsRef, (closingTabIds) => {
1464 if (closingTabIds.has(tabId)) return [false, closingTabIds] as const;
1465 return [true, new Set([...closingTabIds, tabId])] as const;
1466 });
1467 if (!claimed) return;
1468 return yield* withTabLifecycleLock(tabId, closeTabUnlocked(tabId)).pipe(
1469 Effect.ensuring(
1470 Ref.update(closingTabIdsRef, (closingTabIds) => {
1471 if (!closingTabIds.has(tabId)) return closingTabIds;
1472 const next = new Set(closingTabIds);
1473 next.delete(tabId);
1474 return next;
1475 }),
1476 ),
1477 );
1478 });
1479
1480 const registerWebviewUnlocked = Effect.fn("PreviewManager.registerWebviewUnlocked")(function* (
13391481 tabId: string,
13401482 webContentsId: number,
1483 expectedGeneration: number | undefined,
13411484 ) {
13421485 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
1343 if (!tab) {
1486 if (
1487 !tab ||
1488 tabLifecycleGenerations.get(tabId) !== expectedGeneration ||
1489 (yield* Ref.get(closingTabIdsRef)).has(tabId)
1490 ) {
13441491 return yield* new PreviewTabNotFoundError({ tabId });
13451492 }
13461493 const wc = webContents.fromId(webContentsId);
......@@ -1377,53 +1524,71 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13771524 { concurrency: 3, discard: true },
13781525 );
13791526 }
1527 const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
1528 if (
1529 !currentTab ||
1530 tabLifecycleGenerations.get(tabId) !== expectedGeneration ||
1531 (yield* Ref.get(closingTabIdsRef)).has(tabId)
1532 ) {
1533 return yield* new PreviewTabNotFoundError({ tabId });
1534 }
13801535 const zoomFactor =
13811536 replacedWebContentsId !== null
13821537 ? yield* attempt(
13831538 { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId },
13841539 () => {
1385 wc.setZoomFactor(tab.zoomFactor);
1386 return tab.zoomFactor;
1540 wc.setZoomFactor(currentTab.zoomFactor);
1541 return currentTab.zoomFactor;
13871542 },
13881543 )
13891544 : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () =>
13901545 wc.getZoomFactor(),
13911546 );
13921547 yield* attachListeners(tabId, wc);
1393 runFork(restoreControlSession(tabId, wc));
13941548 const registeredAt = yield* currentIso;
1395 const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1396 const current = tabs.get(tabId);
1397 if (!current) {
1549 const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) =>
1550 Effect.gen(function* () {
1551 const current = tabs.get(tabId);
1552 if (
1553 !current ||
1554 tabLifecycleGenerations.get(tabId) !== expectedGeneration ||
1555 (yield* Ref.get(closingTabIdsRef)).has(tabId)
1556 ) {
1557 return [
1558 Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(),
1559 tabs,
1560 ] as const;
1561 }
1562 const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null;
1563 const next: PreviewTabState = {
1564 ...current,
1565 webContentsId,
1566 navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,
1567 canGoBack: wc.navigationHistory.canGoBack(),
1568 canGoForward: wc.navigationHistory.canGoForward(),
1569 zoomFactor,
1570 updatedAt: registeredAt,
1571 };
13981572 return [
1399 Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(),
1400 tabs,
1573 Option.some({
1574 state: next,
1575 pendingUrl,
1576 }),
1577 replaceMap(tabs, (copy) => {
1578 copy.set(tabId, next);
1579 }),
14011580 ] as const;
1402 }
1403 const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null;
1404 const next: PreviewTabState = {
1405 ...current,
1406 webContentsId,
1407 navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,
1408 canGoBack: wc.navigationHistory.canGoBack(),
1409 canGoForward: wc.navigationHistory.canGoForward(),
1410 zoomFactor,
1411 updatedAt: registeredAt,
1412 };
1413 return [
1414 Option.some({
1415 state: next,
1416 pendingUrl,
1417 }),
1418 replaceMap(tabs, (copy) => {
1419 copy.set(tabId, next);
1420 }),
1421 ] as const;
1422 });
1581 }),
1582 );
14231583 if (Option.isNone(registration)) {
1584 yield* Effect.all([detachControlSession(webContentsId), detachListeners(webContentsId)], {
1585 concurrency: 2,
1586 discard: true,
1587 });
14241588 return yield* new PreviewTabNotFoundError({ tabId });
14251589 }
14261590 const { state: registered, pendingUrl } = registration.value;
1591 runFork(restoreControlSession(tabId, wc));
14271592 yield* emit(tabId, registered);
14281593 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
14291594 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
......@@ -1443,6 +1608,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14431608 }
14441609 });
14451610
1611 const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* (
1612 tabId: string,
1613 webContentsId: number,
1614 ) {
1615 const expectedGeneration = tabLifecycleGenerations.get(tabId);
1616 return yield* withTabLifecycleLock(
1617 tabId,
1618 registerWebviewUnlocked(tabId, webContentsId, expectedGeneration),
1619 );
1620 });
1621
14461622 const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) {
14471623 const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () =>
14481624 normalizePreviewUrl(rawUrl),
......@@ -1461,6 +1637,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14611637 canGoBack: current?.canGoBack ?? false,
14621638 canGoForward: current?.canGoForward ?? false,
14631639 zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR,
1640 pictureInPicture: current?.pictureInPicture ?? false,
14641641 colorScheme: current?.colorScheme ?? "system",
14651642 controller: current?.controller ?? "none",
14661643 updatedAt,
......@@ -1716,14 +1893,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
17161893 // session attaches so a concurrent setColorScheme is not overwritten with
17171894 // a stale snapshot.
17181895 const restoreControlSession = (tabId: string, wc: Electron.WebContents) =>
1719 ensureControlSession(wc).pipe(
1720 Effect.andThen(SynchronizedRef.get(tabsRef)),
1721 Effect.flatMap((tabs) => {
1722 const colorScheme = tabs.get(tabId)?.colorScheme ?? "system";
1723 return colorScheme === "system" ? Effect.void : applyColorScheme(tabId, wc, colorScheme);
1724 }),
1725 Effect.ignore,
1726 );
1896 Effect.gen(function* () {
1897 const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
1898 if (beforeAttach?.webContentsId !== wc.id) return;
1899 yield* ensureControlSession(wc);
1900 const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
1901 if (afterAttach?.webContentsId !== wc.id) {
1902 yield* detachControlSession(wc.id);
1903 return;
1904 }
1905 if (afterAttach.colorScheme !== "system") {
1906 yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () =>
1907 wc.debugger.sendCommand("Emulation.setEmulatedMedia", {
1908 features: [
1909 {
1910 name: "prefers-color-scheme",
1911 value: afterAttach.colorScheme,
1912 },
1913 ],
1914 }),
1915 );
1916 }
1917 }).pipe(Effect.ignore);
17271918
17281919 const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* (
17291920 tabId: string,
......@@ -1801,40 +1992,485 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
18011992 };
18021993 });
18031994
1804 const startScreencast = Effect.fn("PreviewManager.startScreencast")(function* (
1805 send: SendCommand,
1995 const capturePreviewFrame = Effect.fn("PreviewManager.capturePreviewFrame")(function* (
1996 tabId: string,
18061997 ) {
1807 yield* send("Page.enable");
1808 yield* send("Page.startScreencast", {
1809 format: "jpeg",
1810 quality: 80,
1811 maxWidth: 1600,
1812 maxHeight: 1200,
1813 everyNthFrame: 1,
1998 const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId);
1999 if (!captureSession) return;
2000 const wc = yield* requireWebContents(tabId);
2001 const image = yield* attemptPromise(
2002 {
2003 operation: "frameCapture.capturePage",
2004 tabId,
2005 webContentsId: wc.id,
2006 },
2007 () => wc.capturePage(),
2008 );
2009 const currentCaptureSession = yield* Effect.all(
2010 [SynchronizedRef.get(frameCaptureSessionsRef), SynchronizedRef.get(tabsRef)],
2011 { concurrency: 2 },
2012 ).pipe(
2013 Effect.map(([captureSessions, tabs]) => {
2014 const current = captureSessions.get(tabId);
2015 return current?.scope === captureSession.scope &&
2016 tabs.get(tabId)?.webContentsId === wc.id &&
2017 !wc.isDestroyed()
2018 ? current
2019 : undefined;
2020 }),
2021 );
2022 if (!currentCaptureSession) return;
2023 const size = yield* attempt(
2024 {
2025 operation: "frameCapture.measureFrame",
2026 tabId,
2027 webContentsId: wc.id,
2028 },
2029 () => image.getSize(),
2030 );
2031 if (
2032 !Number.isFinite(size.width) ||
2033 !Number.isFinite(size.height) ||
2034 size.width <= 0 ||
2035 size.height <= 0
2036 ) {
2037 return;
2038 }
2039 const encoded = yield* attempt(
2040 {
2041 operation: "frameCapture.encodeFrame",
2042 tabId,
2043 webContentsId: wc.id,
2044 },
2045 () => image.toJPEG(RECORDING_JPEG_QUALITY).toString("base64"),
2046 );
2047 const receivedAt = yield* currentIso;
2048 const frame: DesktopPreviewRecordingFrame = {
2049 tabId,
2050 data: encoded,
2051 width: size.width,
2052 height: size.height,
2053 receivedAt,
2054 };
2055 const deliveries: Array<Effect.Effect<void>> = [];
2056 if (currentCaptureSession.consumers.has("recording")) {
2057 const listeners = yield* Ref.get(recordingFrameListenersRef);
2058 deliveries.push(
2059 Effect.forEach(
2060 listeners,
2061 (listener) => deliverEvent("recording-frame", frame.tabId, () => listener(frame)),
2062 { discard: true },
2063 ),
2064 );
2065 }
2066 if (currentCaptureSession.consumers.has("picture-in-picture")) {
2067 const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(
2068 tabId,
2069 )?.window;
2070 if (pictureInPictureWindow && !pictureInPictureWindow.isDestroyed()) {
2071 deliveries.push(
2072 Effect.gen(function* () {
2073 const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get(
2074 tabId,
2075 );
2076 const aspectRatio = frame.width / frame.height;
2077 if (previousAspectRatio !== aspectRatio) {
2078 yield* attempt(
2079 {
2080 operation: "pictureInPicture.setAspectRatio",
2081 tabId,
2082 webContentsId: wc.id,
2083 },
2084 () => {
2085 const contentSize = fitPictureInPictureContentSize(
2086 pictureInPictureWindow.getContentSize(),
2087 aspectRatio,
2088 );
2089 pictureInPictureWindow.setAspectRatio(0);
2090 pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false);
2091 pictureInPictureWindow.setAspectRatio(aspectRatio);
2092 },
2093 );
2094 yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) =>
2095 replaceMap(aspectRatios, (copy) => {
2096 copy.set(tabId, aspectRatio);
2097 }),
2098 );
2099 }
2100 yield* attempt(
2101 {
2102 operation: "pictureInPicture.deliverFrame",
2103 tabId,
2104 webContentsId: wc.id,
2105 },
2106 () => {
2107 pictureInPictureWindow.webContents.send(
2108 PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL,
2109 frame,
2110 );
2111 },
2112 );
2113 }).pipe(
2114 Effect.catch((error) =>
2115 Effect.logWarning("Picture-in-picture frame delivery failed.", {
2116 tabId,
2117 error,
2118 }),
2119 ),
2120 ),
2121 );
2122 }
2123 }
2124 yield* Effect.all(deliveries, { concurrency: 2, discard: true });
2125 });
2126
2127 const startFrameCapture = Effect.fn("PreviewManager.startFrameCapture")(function* (
2128 tabId: string,
2129 consumer: FrameCaptureConsumer,
2130 ) {
2131 // Validate the tab synchronously, but treat capturePage failures as
2132 // transient. Chromium can return UnknownVizError while a hidden guest is
2133 // warming its first compositor frame; the scheduled loop should keep the
2134 // consumer alive and recover instead of tearing recording/PiP back down.
2135 yield* requireWebContents(tabId);
2136 const captureNextFrame = Effect.sleep(RECORDING_FRAME_INTERVAL_MS).pipe(
2137 Effect.andThen(capturePreviewFrame(tabId)),
2138 Effect.catch((error) =>
2139 Effect.logWarning("Background preview frame capture failed.", {
2140 tabId,
2141 error,
2142 }),
2143 ),
2144 );
2145 const created = yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => {
2146 return Effect.gen(function* () {
2147 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
2148 if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) {
2149 return yield* new PreviewTabNotFoundError({ tabId });
2150 }
2151 const current = sessions.get(tabId);
2152 if (current) {
2153 if (current.consumers.has(consumer)) {
2154 return [false, sessions] as const;
2155 }
2156 return [
2157 false,
2158 replaceMap(sessions, (copy) => {
2159 copy.set(tabId, {
2160 ...current,
2161 consumers: new Set([...current.consumers, consumer]),
2162 });
2163 }),
2164 ] as const;
2165 }
2166 const scope = yield* Scope.fork(parentScope, "sequential");
2167 yield* Effect.forkIn(Effect.forever(captureNextFrame), scope);
2168 return [
2169 true,
2170 replaceMap(sessions, (copy) => {
2171 copy.set(tabId, {
2172 scope,
2173 consumers: new Set([consumer]),
2174 });
2175 }),
2176 ] as const;
2177 });
2178 });
2179 if (!created) return;
2180 yield* capturePreviewFrame(tabId).pipe(
2181 Effect.catch((error) =>
2182 Effect.logWarning("Initial background preview frame was not ready; capture will retry.", {
2183 tabId,
2184 consumer,
2185 error,
2186 }),
2187 ),
2188 );
2189 });
2190
2191 const releasePictureInPicture = Effect.fn("PreviewManager.releasePictureInPicture")(function* (
2192 tabId: string,
2193 expectedSession: PictureInPictureSession,
2194 closeWindow: boolean,
2195 ) {
2196 const removed = yield* SynchronizedRef.modify(pictureInPictureSessionsRef, (sessions) => {
2197 if (sessions.get(tabId) !== expectedSession) {
2198 return [false, sessions] as const;
2199 }
2200 return [
2201 true,
2202 replaceMap(sessions, (copy) => {
2203 copy.delete(tabId);
2204 }),
2205 ] as const;
18142206 });
2207 if (!removed) return;
2208 yield* Deferred.interrupt(expectedSession.ready);
2209 yield* Scope.close(expectedSession.initializationScope, Exit.void).pipe(Effect.ignore);
2210 yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) =>
2211 replaceMap(aspectRatios, (copy) => {
2212 copy.delete(tabId);
2213 }),
2214 );
2215 yield* stopFrameCapture(tabId, "picture-in-picture");
2216 const tabs = yield* SynchronizedRef.get(tabsRef);
2217 if (tabs.has(tabId)) {
2218 yield* update(tabId, { pictureInPicture: false });
2219 }
2220 if (closeWindow && !expectedSession.window.isDestroyed()) {
2221 yield* attempt({ operation: "pictureInPicture.close", tabId }, () =>
2222 expectedSession.window.close(),
2223 ).pipe(Effect.ignore);
2224 }
18152225 });
18162226
1817 const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) {
1818 const recordingTabId = yield* Ref.get(recordingTabIdRef);
1819 if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) {
1820 return yield* new PreviewRecordingAlreadyActiveError({
1821 requestedTabId: tabId,
1822 activeTabId: recordingTabId.value,
2227 const closePictureInPictureUnlocked = Effect.fn("PreviewManager.closePictureInPictureUnlocked")(
2228 function* (tabId: string) {
2229 const pictureInPictureSession = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(
2230 tabId,
2231 );
2232 if (!pictureInPictureSession) {
2233 yield* stopFrameCapture(tabId, "picture-in-picture");
2234 const tabs = yield* SynchronizedRef.get(tabsRef);
2235 if (tabs.has(tabId)) {
2236 yield* update(tabId, { pictureInPicture: false });
2237 }
2238 return;
2239 }
2240 yield* releasePictureInPicture(tabId, pictureInPictureSession, true);
2241 },
2242 );
2243
2244 const closePictureInPicture = Effect.fn("PreviewManager.closePictureInPicture")(function* (
2245 tabId: string,
2246 ) {
2247 yield* pictureInPictureMutationSemaphore.withPermit(closePictureInPictureUnlocked(tabId));
2248 });
2249
2250 const closeAllPictureInPicture = Effect.fn("PreviewManager.closeAllPictureInPicture")(
2251 function* () {
2252 const sessions = yield* SynchronizedRef.get(pictureInPictureSessionsRef);
2253 yield* Effect.forEach(sessions.keys(), closePictureInPicture, {
2254 concurrency: "unbounded",
2255 discard: true,
18232256 });
2257 },
2258 );
2259
2260 const openPictureInPicture = Effect.fn("PreviewManager.openPictureInPicture")(function* (
2261 tabId: string,
2262 ) {
2263 const claim = yield* pictureInPictureMutationSemaphore.withPermit(
2264 Effect.gen(function* () {
2265 const existing = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId);
2266 if (existing && !existing.window.isDestroyed()) {
2267 return { kind: "existing" as const, session: existing };
2268 }
2269 if (existing) {
2270 yield* releasePictureInPicture(tabId, existing, false);
2271 }
2272 const wc = yield* requireWebContents(tabId);
2273 const title = yield* attempt(
2274 {
2275 operation: "pictureInPicture.readTitle",
2276 tabId,
2277 webContentsId: wc.id,
2278 },
2279 () => wc.getTitle().trim(),
2280 );
2281 const pictureInPictureWindow = yield* attempt(
2282 {
2283 operation: "pictureInPicture.create",
2284 tabId,
2285 webContentsId: wc.id,
2286 },
2287 () =>
2288 new BrowserWindow({
2289 width: PICTURE_IN_PICTURE_INITIAL_WIDTH,
2290 height: PICTURE_IN_PICTURE_INITIAL_HEIGHT,
2291 minWidth: PICTURE_IN_PICTURE_MIN_WIDTH,
2292 minHeight: PICTURE_IN_PICTURE_MIN_HEIGHT,
2293 title: title.length > 0 ? `Preview · ${title}` : "Browser preview",
2294 show: false,
2295 alwaysOnTop: true,
2296 autoHideMenuBar: true,
2297 fullscreenable: false,
2298 maximizable: false,
2299 minimizable: false,
2300 resizable: true,
2301 skipTaskbar: true,
2302 backgroundColor: "#111111",
2303 ...(hostPlatform === "darwin" ? { type: "panel" as const } : {}),
2304 webPreferences: {
2305 preload: pictureInPicturePreloadPath,
2306 backgroundThrottling: false,
2307 contextIsolation: true,
2308 nodeIntegration: false,
2309 sandbox: true,
2310 },
2311 }),
2312 );
2313 const initializationScope = yield* Scope.fork(parentScope, "sequential");
2314 const ready = yield* Deferred.make<void, PreviewManagerError>();
2315 const session: PictureInPictureSession = {
2316 window: pictureInPictureWindow,
2317 webContentsId: wc.id,
2318 ready,
2319 initializationScope,
2320 };
2321 const onClosed = () => {
2322 runFork(
2323 pictureInPictureMutationSemaphore.withPermit(
2324 releasePictureInPicture(tabId, session, false),
2325 ),
2326 );
2327 };
2328 yield* attempt(
2329 {
2330 operation: "pictureInPicture.configure",
2331 tabId,
2332 webContentsId: wc.id,
2333 },
2334 () => {
2335 pictureInPictureWindow.once("closed", onClosed);
2336 pictureInPictureWindow.setAlwaysOnTop(
2337 true,
2338 hostPlatform === "darwin" ? "floating" : "normal",
2339 );
2340 if (hostPlatform === "darwin") {
2341 pictureInPictureWindow.setVisibleOnAllWorkspaces(true, {
2342 visibleOnFullScreen: true,
2343 // Electron otherwise temporarily transforms the entire app into
2344 // a UIElement process, which removes the owning app from the Dock.
2345 skipTransformProcessType: true,
2346 });
2347 }
2348 },
2349 ).pipe(
2350 Effect.onError(() =>
2351 Effect.all(
2352 [
2353 Scope.close(initializationScope, Exit.void).pipe(Effect.ignore),
2354 attempt({ operation: "pictureInPicture.close", tabId }, () =>
2355 pictureInPictureWindow.close(),
2356 ).pipe(Effect.ignore),
2357 ],
2358 { discard: true },
2359 ),
2360 ),
2361 );
2362 yield* SynchronizedRef.update(pictureInPictureSessionsRef, (sessions) =>
2363 replaceMap(sessions, (copy) => {
2364 copy.set(tabId, session);
2365 }),
2366 );
2367 return { kind: "created" as const, session };
2368 }),
2369 );
2370 const pictureInPictureSession = claim.session;
2371 if (claim.kind === "existing") {
2372 yield* Deferred.await(pictureInPictureSession.ready);
2373 return yield* pictureInPictureMutationSemaphore.withPermit(
2374 Effect.gen(function* () {
2375 const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId);
2376 if (current !== pictureInPictureSession || pictureInPictureSession.window.isDestroyed()) {
2377 return yield* new PreviewOperationError({
2378 operation: "pictureInPicture.showExisting",
2379 tabId,
2380 webContentsId: pictureInPictureSession.webContentsId,
2381 cause: new Error("Picture-in-picture session closed before it became visible."),
2382 });
2383 }
2384 yield* attempt(
2385 {
2386 operation: "pictureInPicture.showExisting",
2387 tabId,
2388 webContentsId: pictureInPictureSession.webContentsId,
2389 },
2390 () => pictureInPictureSession.window.showInactive(),
2391 );
2392 }),
2393 );
18242394 }
1825 const wc = yield* requireWebContents(tabId);
1826 yield* withControlSession(tabId, wc, "recording.start", startScreencast);
1827 yield* Ref.set(recordingTabIdRef, Option.some(tabId));
2395
2396 const initialize = Effect.gen(function* () {
2397 yield* attemptPromise(
2398 {
2399 operation: "pictureInPicture.load",
2400 tabId,
2401 webContentsId: pictureInPictureSession.webContentsId,
2402 },
2403 () => pictureInPictureSession.window.loadURL(buildPreviewPictureInPictureDataUrl()),
2404 );
2405 const currentWebContents = yield* requireWebContents(tabId);
2406 if (
2407 currentWebContents.id !== pictureInPictureSession.webContentsId ||
2408 currentWebContents.isDestroyed()
2409 ) {
2410 return yield* new PreviewOperationError({
2411 operation: "pictureInPicture.validateWebContents",
2412 tabId,
2413 webContentsId: pictureInPictureSession.webContentsId,
2414 cause: new Error("Preview webview changed while picture-in-picture was opening."),
2415 });
2416 }
2417 yield* startFrameCapture(tabId, "picture-in-picture");
2418 yield* attempt(
2419 {
2420 operation: "pictureInPicture.show",
2421 tabId,
2422 webContentsId: pictureInPictureSession.webContentsId,
2423 },
2424 () => pictureInPictureSession.window.showInactive(),
2425 );
2426 });
2427 const initializationExit = yield* Effect.gen(function* () {
2428 const initializationFiber = yield* Effect.forkIn(
2429 initialize,
2430 pictureInPictureSession.initializationScope,
2431 );
2432 return yield* Fiber.await(initializationFiber);
2433 }).pipe(
2434 Effect.onInterrupt(() =>
2435 pictureInPictureMutationSemaphore.withPermit(
2436 releasePictureInPicture(tabId, pictureInPictureSession, true),
2437 ),
2438 ),
2439 );
2440 if (Exit.isSuccess(initializationExit)) {
2441 const published = yield* pictureInPictureMutationSemaphore.withPermit(
2442 Effect.gen(function* () {
2443 const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId);
2444 if (current !== pictureInPictureSession || pictureInPictureSession.window.isDestroyed()) {
2445 if (current === pictureInPictureSession) {
2446 yield* releasePictureInPicture(tabId, pictureInPictureSession, false);
2447 }
2448 return false;
2449 }
2450 yield* update(tabId, { pictureInPicture: true });
2451 yield* Deferred.done(pictureInPictureSession.ready, initializationExit);
2452 return true;
2453 }),
2454 );
2455 if (published) return;
2456 return yield* Deferred.await(pictureInPictureSession.ready);
2457 }
2458 yield* Deferred.done(pictureInPictureSession.ready, initializationExit);
2459 const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId);
2460 if (current === pictureInPictureSession) {
2461 yield* pictureInPictureMutationSemaphore.withPermit(
2462 releasePictureInPicture(tabId, pictureInPictureSession, true),
2463 );
2464 }
2465 return yield* Effect.failCause(initializationExit.cause);
2466 });
2467
2468 const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) {
2469 yield* startFrameCapture(tabId, "recording");
18282470 });
18292471
18302472 const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) {
1831 const recordingTabId = yield* Ref.get(recordingTabIdRef);
1832 if (Option.isNone(recordingTabId) || recordingTabId.value !== tabId) return;
1833 const wc = yield* requireWebContents(tabId);
1834 yield* withControlSession(tabId, wc, "recording.stop", (send) =>
1835 send("Page.stopScreencast").pipe(Effect.asVoid),
1836 );
1837 yield* Ref.set(recordingTabIdRef, Option.none());
2473 yield* stopFrameCapture(tabId, "recording");
18382474 });
18392475
18402476 const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* (
......@@ -2582,6 +3218,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
25823218 goForward,
25833219 hardReload,
25843220 navigate,
3221 openPictureInPicture,
25853222 openDevTools,
25863223 pickElement,
25873224 refresh,
......@@ -2593,6 +3230,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
25933230 setColorScheme,
25943231 setMainWindow,
25953232 startRecording,
3233 closePictureInPicture,
25963234 stopRecording,
25973235 subscribePointerEvents: (listener: PointerEventListener) =>
25983236 subscribe(pointerEventListenersRef, listener),
......@@ -2678,18 +3316,6 @@ export class PreviewArtifactImageLoadError extends Schema.TaggedErrorClass<Previ
26783316 }
26793317}
26803318
2681export class PreviewRecordingAlreadyActiveError extends Schema.TaggedErrorClass<PreviewRecordingAlreadyActiveError>()(
2682 "PreviewRecordingAlreadyActiveError",
2683 {
2684 requestedTabId: Schema.String,
2685 activeTabId: Schema.String,
2686 },
2687) {
2688 override get message(): string {
2689 return `Cannot record preview tab ${this.requestedTabId} while tab ${this.activeTabId} is already recording`;
2690 }
2691}
2692
26933319export class PreviewAutomationDevToolsOpenError extends Schema.TaggedErrorClass<PreviewAutomationDevToolsOpenError>()(
26943320 "PreviewAutomationDevToolsOpenError",
26953321 { webContentsId: Schema.Number },
......@@ -2852,7 +3478,6 @@ export const PreviewManagerError = Schema.Union([
28523478 PreviewOperationError,
28533479 PreviewArtifactPathOutsideDirectoryError,
28543480 PreviewArtifactImageLoadError,
2855 PreviewRecordingAlreadyActiveError,
28563481 PreviewAutomationDevToolsOpenError,
28573482 PreviewAutomationDebuggerAttachedError,
28583483 PreviewAutomationEvaluationError,
......@@ -2915,6 +3540,8 @@ export class PreviewManager extends Context.Service<
29153540 ) => Effect.Effect<DesktopPreviewScreenshotArtifact, PreviewManagerError>;
29163541 readonly revealArtifact: (path: string) => Effect.Effect<void, PreviewManagerError>;
29173542 readonly copyArtifactToClipboard: (path: string) => Effect.Effect<void, PreviewManagerError>;
3543 readonly openPictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3544 readonly closePictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
29183545 readonly startRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
29193546 readonly stopRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
29203547 readonly saveRecording: (
......@@ -2965,7 +3592,10 @@ export class PreviewManager extends Context.Service<
29653592export const make = Effect.gen(function* PreviewManagerMake() {
29663593 const environment = yield* DesktopEnvironment.DesktopEnvironment;
29673594 const browserSession = yield* BrowserSession.BrowserSession;
2968 const operations = yield* makeNativeOperations(environment.browserArtifactsDir);
3595 const operations = yield* makeNativeOperations(
3596 environment.browserArtifactsDir,
3597 environment.path.join(environment.dirname, "preview-pip-preload.cjs"),
3598 );
29693599
29703600 return PreviewManager.of({
29713601 setMainWindow: operations.setMainWindow,
......@@ -3023,6 +3653,8 @@ export const make = Effect.gen(function* PreviewManagerMake() {
30233653 captureScreenshot: operations.captureScreenshot,
30243654 revealArtifact: operations.revealArtifact,
30253655 copyArtifactToClipboard: operations.copyArtifactToClipboard,
3656 openPictureInPicture: operations.openPictureInPicture,
3657 closePictureInPicture: operations.closePictureInPicture,
30263658 startRecording: operations.startRecording,
30273659 stopRecording: operations.stopRecording,
30283660 saveRecording: operations.saveRecording,
apps/desktop/src/window/DesktopWindow.test.ts+2
......@@ -315,6 +315,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
315315 webPreferences: {
316316 preload: null,
317317 partition: null,
318 backgroundThrottling: null,
318319 sandbox: null,
319320 contextIsolation: null,
320321 nodeIntegration: null,
......@@ -429,6 +430,7 @@ describe("DesktopWindow", () => {
429430 assert.isUndefined(createdWindowOptions[0]?.x);
430431 assert.isUndefined(createdWindowOptions[0]?.y);
431432 assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor);
433 assert.isFalse(createdWindowOptions[0]?.webPreferences?.backgroundThrottling);
432434 assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]);
433435 assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]);
434436 assert.equal(fakeWindow.openDevTools.mock.calls.length, 1);
apps/desktop/src/window/DesktopWindow.ts+1
......@@ -331,6 +331,7 @@ export const make = Effect.gen(function* () {
331331 ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform),
332332 webPreferences: {
333333 preload: environment.preloadPath,
334 backgroundThrottling: false,
334335 contextIsolation: true,
335336 nodeIntegration: false,
336337 sandbox: true,
apps/desktop/vite.config.ts+7
......@@ -73,5 +73,12 @@ export default defineConfig({
7373 alwaysBundle: (id) => id === "react-grab" || id.startsWith("react-grab/"),
7474 },
7575 },
76 {
77 format: "cjs",
78 outDir: "dist-electron",
79 sourcemap: true,
80 outExtensions: () => ({ js: ".cjs" }),
81 entry: ["src/preview-pip-preload.ts"],
82 },
7683 ],
7784});
apps/server/src/auth/EnvironmentAuth.test.ts+12-15
......@@ -8,9 +8,9 @@ import * as ServerConfig from "../config.ts";
88import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
99import * as PairingGrantStore from "./PairingGrantStore.ts";
1010import * as EnvironmentAuth from "./EnvironmentAuth.ts";
11import { resolveSessionCookieName } from "./utils.ts";
1211
1312import * as ServerSecretStore from "./ServerSecretStore.ts";
13import * as SessionStore from "./SessionStore.ts";
1414
1515/** Pinned so dev-mode cookie tests can assert the port-scoped name. */
1616const TEST_SERVER_PORT = 13_773;
......@@ -23,11 +23,8 @@ const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Se
2323 return {
2424 ...config,
2525 ...overrides,
26 // Last, so the port cannot be overridden out from under
27 // makeCookieRequest — which builds the cookie name from this constant.
28 // An override that changed it would leave the server reading
29 // t3_session_<other> while every request still sent t3_session_13773,
30 // and the tests would fail for a reason unrelated to what they assert.
26 // Keep the test server deterministic even when the default test layer
27 // changes its development port.
3128 port: TEST_SERVER_PORT,
3229 } satisfies ServerConfig.ServerConfig["Service"];
3330 }),
......@@ -41,15 +38,12 @@ const makeEnvironmentAuthLayer = (overrides?: Partial<ServerConfig.ServerConfig[
4138 );
4239
4340const makeCookieRequest = (
41 cookieName: string,
4442 sessionToken: string,
4543): Parameters<EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]>[0] =>
4644 ({
4745 cookies: {
48 // Derived, not hardcoded: the name is port-scoped so concurrent servers
49 // on one hostname don't share a cookie. Mode and devUrl mirror
50 // ServerConfig.layerTest, so this resolves to whatever the server reads.
51 [resolveSessionCookieName({ mode: "web", port: TEST_SERVER_PORT, devUrl: undefined })]:
52 sessionToken,
46 [cookieName]: sessionToken,
5347 },
5448 headers: {},
5549 }) as unknown as Parameters<
......@@ -92,6 +86,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
9286 it.effect("issues standard pairing credentials by default", () =>
9387 Effect.gen(function* () {
9488 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
89 const sessions = yield* SessionStore.SessionStore;
9590
9691 const pairingCredential = yield* serverAuth.issuePairingCredential();
9792 const exchanged = yield* serverAuth.createBrowserSession(
......@@ -99,7 +94,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
9994 requestMetadata,
10095 );
10196 const verified = yield* serverAuth.authenticateHttpRequest(
102 makeCookieRequest(exchanged.sessionToken),
97 makeCookieRequest(sessions.cookieName, exchanged.sessionToken),
10398 );
10499
105100 expect(verified.sessionId.length).toBeGreaterThan(0);
......@@ -165,6 +160,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
165160 it.effect("issues startup pairing URLs that bootstrap administrative sessions", () =>
166161 Effect.gen(function* () {
167162 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
163 const sessions = yield* SessionStore.SessionStore;
168164
169165 const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773");
170166 const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token");
......@@ -178,7 +174,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
178174
179175 const exchanged = yield* serverAuth.createBrowserSession(token ?? "", requestMetadata);
180176 const verified = yield* serverAuth.authenticateHttpRequest(
181 makeCookieRequest(exchanged.sessionToken),
177 makeCookieRequest(sessions.cookieName, exchanged.sessionToken),
182178 );
183179
184180 expect(verified.scopes).toEqual([
......@@ -200,13 +196,14 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
200196 () =>
201197 Effect.gen(function* () {
202198 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
199 const sessions = yield* SessionStore.SessionStore;
203200
204201 const administrativeExchange = yield* serverAuth.createBrowserSession(
205202 "desktop-bootstrap-token",
206203 requestMetadata,
207204 );
208205 const administrativeSession = yield* serverAuth.authenticateHttpRequest(
209 makeCookieRequest(administrativeExchange.sessionToken),
206 makeCookieRequest(sessions.cookieName, administrativeExchange.sessionToken),
210207 );
211208 const pairingCredential = yield* serverAuth.issuePairingCredential({
212209 label: "Julius iPhone",
......@@ -223,7 +220,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
223220 },
224221 );
225222 const clientSession = yield* serverAuth.authenticateHttpRequest(
226 makeCookieRequest(clientExchange.sessionToken),
223 makeCookieRequest(sessions.cookieName, clientExchange.sessionToken),
227224 );
228225 const clientsBeforeRevoke = yield* serverAuth.listClientSessions(
229226 administrativeSession.sessionId,
apps/server/src/auth/EnvironmentAuthPolicy.test.ts+12-8
......@@ -88,47 +88,50 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
8888
8989 expect(descriptor.policy).toBe("loopback-browser");
9090 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
91 expect(descriptor.sessionCookieName).toBe("t3_session");
91 expect(descriptor.sessionCookieName).toMatch(/^t3_session_3773_[a-f0-9]{12}$/);
9292 }).pipe(
9393 Effect.provide(
9494 makeEnvironmentAuthPolicyLayer({
9595 mode: "web",
9696 host: "127.0.0.1",
97 port: 13773,
97 port: 3773,
9898 }),
9999 ),
100100 ),
101101 );
102102
103 it.effect("scopes web session cookies by port only in development", () =>
103 it.effect("uses remote-reachable policy for wildcard web hosts", () =>
104104 Effect.gen(function* () {
105105 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
106106 const descriptor = yield* policy.getDescriptor();
107107
108 expect(descriptor.sessionCookieName).toBe("t3_session_13773");
108 expect(descriptor.policy).toBe("remote-reachable");
109 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
110 expect(descriptor.sessionCookieName).toBe("t3_session");
109111 }).pipe(
110112 Effect.provide(
111113 makeEnvironmentAuthPolicyLayer({
112114 mode: "web",
113 port: 13773,
114 devUrl: new URL("http://127.0.0.1:5733"),
115 host: "0.0.0.0",
115116 }),
116117 ),
117118 ),
118119 );
119120
120 it.effect("uses remote-reachable policy for wildcard web hosts", () =>
121 it.effect("isolates wildcard-bound web development sessions", () =>
121122 Effect.gen(function* () {
122123 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
123124 const descriptor = yield* policy.getDescriptor();
124125
125126 expect(descriptor.policy).toBe("remote-reachable");
126 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
127 expect(descriptor.sessionCookieName).toMatch(/^t3_session_5775_[a-f0-9]{12}$/);
127128 }).pipe(
128129 Effect.provide(
129130 makeEnvironmentAuthPolicyLayer({
130131 mode: "web",
131132 host: "0.0.0.0",
133 port: 5775,
134 devUrl: new URL("http://127.0.0.1:5736"),
132135 }),
133136 ),
134137 ),
......@@ -140,6 +143,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
140143 const descriptor = yield* policy.getDescriptor();
141144
142145 expect(descriptor.policy).toBe("remote-reachable");
146 expect(descriptor.sessionCookieName).toBe("t3_session");
143147 }).pipe(
144148 Effect.provide(
145149 makeEnvironmentAuthPolicyLayer({
apps/server/src/auth/EnvironmentAuthPolicy.ts+5-4
......@@ -4,8 +4,7 @@ import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55
66import * as ServerConfig from "../config.ts";
7import { resolveSessionCookieName } from "./utils.ts";
8import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts";
7import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts";
98
109export class EnvironmentAuthPolicy extends Context.Service<
1110 EnvironmentAuthPolicy,
......@@ -16,7 +15,7 @@ export class EnvironmentAuthPolicy extends Context.Service<
1615
1716export const make = Effect.gen(function* () {
1817 const config = yield* ServerConfig.ServerConfig;
19 const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host);
18 const isRemoteReachable = isRemoteReachableHost(config.host);
2019
2120 const policy =
2221 config.mode === "desktop"
......@@ -41,7 +40,9 @@ export const make = Effect.gen(function* () {
4140 sessionCookieName: resolveSessionCookieName({
4241 mode: config.mode,
4342 port: config.port,
44 devUrl: config.devUrl,
43 host: config.host,
44 instanceKey: config.stateDir,
45 development: config.devUrl !== undefined,
4546 }),
4647 };
4748
apps/server/src/auth/SessionStore.ts+3-1
......@@ -470,7 +470,9 @@ export const make = Effect.gen(function* () {
470470 const cookieName = resolveSessionCookieName({
471471 mode: serverConfig.mode,
472472 port: serverConfig.port,
473 devUrl: serverConfig.devUrl,
473 host: serverConfig.host,
474 instanceKey: serverConfig.stateDir,
475 development: serverConfig.devUrl !== undefined,
474476 });
475477
476478 const emitUpsert = (clientSession: AuthClientSession) =>
apps/server/src/auth/utils.test.ts+82-1
......@@ -1,6 +1,10 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { deriveAuthClientMetadata } from "./utils.ts";
3import {
4 deriveAuthClientMetadata,
5 isRemoteReachableHost,
6 resolveSessionCookieName,
7} from "./utils.ts";
48
59describe("deriveAuthClientMetadata", () => {
610 it("labels Electron user agents as Electron instead of Chrome", () => {
......@@ -52,3 +56,80 @@ describe("deriveAuthClientMetadata", () => {
5256 expect(metadata.userAgent).toContain("Electron/36.3.2");
5357 });
5458});
59
60describe("session cookie isolation", () => {
61 it("isolates loopback web servers by port and server state", () => {
62 const first = resolveSessionCookieName({
63 mode: "web",
64 port: 5775,
65 host: "127.0.0.1",
66 instanceKey: "/tmp/t3-agent-one",
67 development: true,
68 });
69 const second = resolveSessionCookieName({
70 mode: "web",
71 port: 5775,
72 host: "127.0.0.1",
73 instanceKey: "/tmp/t3-agent-two",
74 development: true,
75 });
76
77 expect(first).toMatch(/^t3_session_5775_[a-f0-9]{12}$/);
78 expect(second).toMatch(/^t3_session_5775_[a-f0-9]{12}$/);
79 expect(first).not.toBe(second);
80 });
81
82 it("keeps the hosted web cookie stable across server instances", () => {
83 expect(
84 resolveSessionCookieName({
85 mode: "web",
86 port: 8080,
87 host: "0.0.0.0",
88 instanceKey: "/srv/release-a",
89 development: false,
90 }),
91 ).toBe("t3_session");
92 expect(
93 resolveSessionCookieName({
94 mode: "web",
95 port: 9090,
96 host: "app.example.com",
97 instanceKey: "/srv/release-b",
98 development: false,
99 }),
100 ).toBe("t3_session");
101 });
102
103 it("retains desktop port scoping", () => {
104 expect(
105 resolveSessionCookieName({
106 mode: "desktop",
107 port: 3773,
108 host: "127.0.0.1",
109 instanceKey: "/tmp/desktop",
110 development: true,
111 }),
112 ).toBe("t3_session_3773");
113 });
114
115 it("isolates development servers even when they bind a wildcard host", () => {
116 expect(
117 resolveSessionCookieName({
118 mode: "web",
119 port: 5775,
120 host: "0.0.0.0",
121 instanceKey: "/tmp/t3-wildcard-dev",
122 development: true,
123 }),
124 ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/);
125 });
126
127 it("classifies loopback aliases separately from remotely reachable hosts", () => {
128 expect(isRemoteReachableHost(undefined)).toBe(false);
129 expect(isRemoteReachableHost("localhost")).toBe(false);
130 expect(isRemoteReachableHost("127.12.0.1")).toBe(false);
131 expect(isRemoteReachableHost("[::1]")).toBe(false);
132 expect(isRemoteReachableHost("0.0.0.0")).toBe(true);
133 expect(isRemoteReachableHost("192.168.1.50")).toBe(true);
134 });
135});
apps/server/src/auth/utils.ts+35-4
......@@ -28,11 +28,42 @@ const SESSION_COOKIE_NAME = "t3_session";
2828export function resolveSessionCookieName(input: {
2929 readonly mode: "web" | "desktop";
3030 readonly port: number;
31 readonly devUrl: URL | undefined;
31 readonly host: string | undefined;
32 readonly instanceKey: string;
33 readonly development: boolean;
3234}): string {
33 return input.devUrl === undefined && input.mode !== "desktop"
34 ? SESSION_COOKIE_NAME
35 : `${SESSION_COOKIE_NAME}_${input.port}`;
35 if (input.mode === "desktop") {
36 return `${SESSION_COOKIE_NAME}_${input.port}`;
37 }
38
39 if (!input.development && isRemoteReachableHost(input.host)) {
40 return SESSION_COOKIE_NAME;
41 }
42
43 // Cookies are scoped by host, not port. Loopback development servers need an
44 // instance-specific name or parallel agents overwrite each other's session,
45 // and a server that later reuses the port receives a token signed elsewhere.
46 const instanceHash = NodeCrypto.createHash("sha256")
47 .update(input.instanceKey)
48 .digest("hex")
49 .slice(0, 12);
50 return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`;
51}
52
53export function isRemoteReachableHost(host: string | undefined): boolean {
54 if (host === "0.0.0.0" || host === "::" || host === "[::]") {
55 return true;
56 }
57 if (!host || host.length === 0) {
58 return false;
59 }
60 return !(
61 host === "localhost" ||
62 host === "127.0.0.1" ||
63 host === "::1" ||
64 host === "[::1]" ||
65 host.startsWith("127.")
66 );
3667}
3768
3869export function base64UrlEncode(input: string | Uint8Array): string {
apps/server/src/mcp/PreviewAutomationBroker.test.ts+2-2
......@@ -174,7 +174,7 @@ it.effect("does not let an older response replace a newer explicit tab target",
174174 ),
175175);
176176
177it.effect("does not replace the default tab with a globally stopped recording tab", () =>
177it.effect("tracks the tab returned by a targeted recording stop", () =>
178178 Effect.scoped(
179179 Effect.gen(function* () {
180180 const broker = yield* makeBroker;
......@@ -203,7 +203,7 @@ it.effect("does not replace the default tab with a globally stopped recording ta
203203 yield* broker.invoke({ scope, operation: "recordingStop", input: {} });
204204 yield* broker.invoke({ scope, operation: "snapshot", input: {} });
205205
206 expect(routedRequests.at(-1)?.tabId).toBe(browsingTabId);
206 expect(routedRequests.at(-1)?.tabId).toBe(recordingTabId);
207207 }),
208208 ),
209209);
apps/server/src/mcp/PreviewAutomationBroker.ts+1-2
......@@ -548,8 +548,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
548548 });
549549 });
550550 const result = yield* awaitResponse().pipe(Effect.ensuring(removePending));
551 // A stop artifact identifies the globally recorded tab, not the caller's browsing target.
552 const responseTabId = input.operation === "recordingStop" ? undefined : readResultTabId(result);
551 const responseTabId = readResultTabId(result);
553552 const resultTabId = responseTabId === undefined ? input.tabId : responseTabId;
554553 if (resultTabId === undefined) return result;
555554 const assignmentKey = hostAssignmentKey(input.scope);
apps/server/src/mcp/toolkits/preview/handlers.test.ts created+34
......@@ -0,0 +1,34 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { normalizePreviewOpenInput } from "./handlers.ts";
4
5describe("normalizePreviewOpenInput", () => {
6 it("opens the inline preview and reuses the current tab by default", () => {
7 expect(normalizePreviewOpenInput({})).toEqual({
8 open: true,
9 reuseExistingTab: true,
10 show: true,
11 });
12 });
13
14 it("preserves an explicit background-only opt-out", () => {
15 expect(normalizePreviewOpenInput({ open: false })).toEqual({
16 open: false,
17 reuseExistingTab: true,
18 show: false,
19 });
20 });
21
22 it("supports show as a legacy alias while preferring open", () => {
23 expect(normalizePreviewOpenInput({ show: false })).toEqual({
24 open: false,
25 reuseExistingTab: true,
26 show: false,
27 });
28 expect(normalizePreviewOpenInput({ open: true, show: false })).toEqual({
29 open: true,
30 reuseExistingTab: true,
31 show: true,
32 });
33 });
34});
apps/server/src/mcp/toolkits/preview/handlers.ts+14-5
......@@ -1,6 +1,7 @@
11import * as Effect from "effect/Effect";
22import type {
33 PreviewAutomationOperation,
4 PreviewAutomationOpenInput,
45 PreviewAutomationRecordingArtifact,
56 PreviewAutomationRecordingStatus,
67 PreviewAutomationResizeResult,
......@@ -14,6 +15,18 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";
1415import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
1516import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts";
1617
18export function normalizePreviewOpenInput(
19 input: PreviewAutomationOpenInput,
20): PreviewAutomationOpenInput {
21 const open = input.open ?? input.show ?? true;
22 return {
23 ...input,
24 open,
25 show: open,
26 reuseExistingTab: input.reuseExistingTab ?? true,
27 };
28}
29
1730const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(
1831 operation: PreviewAutomationOperation,
1932 input: unknown,
......@@ -50,11 +63,7 @@ const invokeTargeted = <A>(
5063const handlers = {
5164 preview_status: (input) => invokeTargeted<PreviewAutomationStatus>("status", input ?? {}),
5265 preview_open: (input) =>
53 invokeTargeted<PreviewAutomationStatus>("open", {
54 ...input,
55 show: input.show ?? true,
56 reuseExistingTab: input.reuseExistingTab ?? true,
57 }),
66 invokeTargeted<PreviewAutomationStatus>("open", normalizePreviewOpenInput(input)),
5867 preview_navigate: (input) =>
5968 invokeTargeted<PreviewAutomationStatus>("navigate", input, input.timeoutMs),
6069 preview_resize: (input) =>
apps/server/src/mcp/toolkits/preview/tools.ts+3-2
......@@ -54,7 +54,7 @@ export const PreviewStatusTool = Tool.make("preview_status", {
5454export const PreviewOpenTool = browserTool(
5555 Tool.make("preview_open", {
5656 description:
57 "Show and initialize a collaborative browser tab. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab.",
57 "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab.",
5858 parameters: PreviewAutomationOpenInput,
5959 success: PreviewAutomationStatus,
6060 failure: PreviewAutomationError,
......@@ -191,7 +191,8 @@ export const PreviewRecordingStartTool = safeBrowserTool(
191191
192192export const PreviewRecordingStopTool = safeBrowserTool(
193193 Tool.make("preview_recording_stop", {
194 description: "Stop the active browser recording and save it as a local evidence artifact.",
194 description:
195 "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and save it as a local evidence artifact.",
195196 parameters: PreviewAutomationTabTargetInput,
196197 success: PreviewAutomationRecordingArtifact,
197198 failure: PreviewAutomationError,
apps/server/src/preview/Manager.test.ts+46
......@@ -67,6 +67,32 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => {
6767 }),
6868 );
6969
70 it.effect("orders list snapshots and events with one monotonic revision", () =>
71 Effect.gen(function* () {
72 const threadId = freshThreadId();
73 const manager = yield* PreviewManager.PreviewManager;
74 const collector = yield* collectEvents;
75 const before = yield* manager.list({ threadId });
76
77 const opened = yield* manager.open({ threadId, url: "http://localhost:5173" });
78 yield* manager.navigate({
79 threadId,
80 tabId: opened.tabId,
81 url: "http://localhost:5173/ready",
82 });
83
84 const events = yield* collector.drain;
85 const listed = yield* manager.list({ threadId });
86 expect(events).toHaveLength(2);
87 expect(events[0]!.serverEpoch).toBe(listed.serverEpoch);
88 expect(events[1]!.serverEpoch).toBe(listed.serverEpoch);
89 expect(events[0]!.revision).toBeGreaterThan(before.revision);
90 expect(events[1]!.revision).toBeGreaterThan(events[0]!.revision);
91 expect(listed.revision).toBe(events[1]!.revision);
92 expect(listed.sessions).toHaveLength(1);
93 }),
94 );
95
7096 it.effect("treats bare hosts as https", () =>
7197 Effect.gen(function* () {
7298 const threadId = freshThreadId();
......@@ -254,6 +280,26 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => {
254280 }),
255281 );
256282
283 it.effect("gives every tab in a batch close its own monotonic revision", () =>
284 Effect.gen(function* () {
285 const threadId = freshThreadId();
286 const manager = yield* PreviewManager.PreviewManager;
287 yield* manager.open({ threadId, url: "http://localhost:5173" });
288 yield* manager.open({ threadId, url: "http://localhost:3000" });
289 const collector = yield* collectEvents;
290
291 yield* manager.close({ threadId });
292
293 const events = yield* collector.drain;
294 const listed = yield* manager.list({ threadId });
295 expect(events).toHaveLength(2);
296 expect(events.every((event) => event.type === "closed")).toBe(true);
297 expect(events[1]!.revision).toBeGreaterThan(events[0]!.revision);
298 expect(listed.revision).toBe(events[1]!.revision);
299 expect(listed.sessions).toHaveLength(0);
300 }),
301 );
302
257303 it.effect("close is idempotent for unknown threads", () =>
258304 Effect.gen(function* () {
259305 const threadId = freshThreadId();
apps/server/src/preview/Manager.ts+57-29
......@@ -30,6 +30,7 @@ import {
3030 newPreviewTabId,
3131 normalizePreviewUrl,
3232} from "@t3tools/shared/preview";
33import * as NodeCrypto from "node:crypto";
3334import * as Context from "effect/Context";
3435import * as DateTime from "effect/DateTime";
3536import * as Effect from "effect/Effect";
......@@ -67,9 +68,17 @@ interface PreviewSessionState {
6768interface ManagerState {
6869 /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */
6970 readonly sessions: ReadonlyMap<string, PreviewSessionState>;
71 /** Global monotonic revision establishing list/event ordering. */
72 readonly revision: number;
7073}
7174
72const initialState: ManagerState = { sessions: new Map() };
75const initialState: ManagerState = { sessions: new Map(), revision: 0 };
76
77type PreviewEventDraft = PreviewEvent extends infer Event
78 ? Event extends { readonly revision: number }
79 ? Omit<Event, "revision" | "serverEpoch">
80 : never
81 : never;
7382
7483const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`;
7584
......@@ -138,6 +147,7 @@ const buildIdleSnapshot = (input: {
138147});
139148
140149export const make = Effect.gen(function* PreviewManagerMake() {
150 const serverEpoch = NodeCrypto.randomUUID();
141151 const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState);
142152 // Unbounded PubSub is fine here — events are tiny and we don't want to
143153 // block publishers if a subscriber is slow. WS clients backpressure on
......@@ -160,7 +170,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
160170 tabId: string,
161171 mutator: (
162172 session: PreviewSessionState,
163 ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEvent | null; result: R }, E>,
173 ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEventDraft | null; result: R }, E>,
164174 ): Effect.Effect<R, E | PreviewSessionLookupError> => {
165175 type ModifyResult =
166176 | { kind: "fail"; error: PreviewSessionLookupError }
......@@ -177,10 +187,17 @@ export const make = Effect.gen(function* PreviewManagerMake() {
177187 return mutator(session).pipe(
178188 Effect.flatMap(
179189 Effect.fn("PreviewManager.commitMutation")(function* ({ next, emit, result }) {
180 if (emit) yield* PubSub.publish(eventsPubSub, emit);
190 const revision = emit ? state.revision + 1 : state.revision;
191 if (emit) {
192 yield* PubSub.publish(eventsPubSub, {
193 ...emit,
194 revision,
195 serverEpoch,
196 } as PreviewEvent);
197 }
181198 const sessions = new Map(state.sessions);
182199 sessions.set(compositeKey(threadId, tabId), next);
183 return [{ kind: "ok", result } as ModifyResult, { sessions }] as readonly [
200 return [{ kind: "ok", result } as ModifyResult, { sessions, revision }] as readonly [
184201 ModifyResult,
185202 ManagerState,
186203 ];
......@@ -207,22 +224,27 @@ export const make = Effect.gen(function* PreviewManagerMake() {
207224 updatedAt,
208225 })
209226 : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt });
210 yield* SynchronizedRef.update(stateRef, (state) => {
211 const sessions = new Map(state.sessions);
212 sessions.set(compositeKey(input.threadId, tabId), {
213 threadId: input.threadId,
214 tabId,
215 snapshot,
216 });
217 return { sessions };
218 });
219 yield* PubSub.publish(eventsPubSub, {
220 type: "opened",
221 threadId: input.threadId,
222 tabId,
223 createdAt: snapshot.updatedAt,
224 snapshot,
225 });
227 yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
228 Effect.gen(function* () {
229 const revision = state.revision + 1;
230 const sessions = new Map(state.sessions);
231 sessions.set(compositeKey(input.threadId, tabId), {
232 threadId: input.threadId,
233 tabId,
234 snapshot,
235 });
236 yield* PubSub.publish(eventsPubSub, {
237 type: "opened",
238 threadId: input.threadId,
239 tabId,
240 createdAt: snapshot.updatedAt,
241 serverEpoch,
242 revision,
243 snapshot,
244 });
245 return [snapshot, { sessions, revision }] as const;
246 }),
247 );
226248 return snapshot;
227249 },
228250 );
......@@ -280,7 +302,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
280302 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
281303 updatedAt,
282304 };
283 const emit: PreviewEvent =
305 const emit: PreviewEventDraft =
284306 input.navStatus._tag === "LoadFailed"
285307 ? {
286308 type: "failed",
......@@ -349,7 +371,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
349371 const close: PreviewManager["Service"]["close"] = Effect.fn("PreviewManager.close")(
350372 function* (input) {
351373 const createdAt = yield* currentIsoTimestamp;
352 const events = yield* SynchronizedRef.modify(stateRef, (state) => {
374 yield* SynchronizedRef.modifyEffect(stateRef, (state) => {
353375 const eventsToEmit: PreviewEvent[] = [];
354376 const sessions = new Map(state.sessions);
355377 const targets = input.tabId
......@@ -357,25 +379,29 @@ export const make = Effect.gen(function* PreviewManagerMake() {
357379 (entry): entry is PreviewSessionState => entry !== undefined,
358380 )
359381 : sessionsForThread(state, input.threadId);
382 let revision = state.revision;
360383 for (const target of targets) {
384 revision += 1;
361385 sessions.delete(compositeKey(target.threadId, target.tabId));
362386 eventsToEmit.push({
363387 type: "closed",
364388 threadId: target.threadId,
365389 tabId: target.tabId,
366390 createdAt,
391 serverEpoch,
392 revision,
367393 });
368394 }
369395 if (eventsToEmit.length === 0) {
370 return [eventsToEmit, state] as const;
396 return Effect.succeed([undefined, state] as const);
371397 }
372 return [eventsToEmit, { sessions }] as const;
398 return Effect.as(
399 Effect.forEach(eventsToEmit, (event) => PubSub.publish(eventsPubSub, event), {
400 discard: true,
401 }),
402 [undefined, { sessions, revision }] as const,
403 );
373404 });
374 if (events.length > 0) {
375 yield* Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), {
376 discard: true,
377 });
378 }
379405 },
380406 );
381407
......@@ -387,6 +413,8 @@ export const make = Effect.gen(function* PreviewManagerMake() {
387413 sessions: sessionsForThread(state, input.threadId)
388414 .map((s) => s.snapshot)
389415 .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)),
416 serverEpoch,
417 revision: state.revision,
390418 }),
391419 ),
392420 );
apps/server/src/server.test.ts+1-1
......@@ -667,7 +667,7 @@ const buildAppUnderTest = (options?: {
667667 reportStatus: () => Effect.void,
668668 refresh: () => Effect.void,
669669 close: () => Effect.void,
670 list: () => Effect.succeed({ sessions: [] }),
670 list: () => Effect.succeed({ sessions: [], serverEpoch: "test-server", revision: 0 }),
671671 events: Stream.empty,
672672 subscribeEvents: Effect.flatMap(PubSub.unbounded<PreviewEvent>(), (pubsub) =>
673673 PubSub.subscribe(pubsub),
apps/web/src/browser/BrowserSurfaceSlot.tsx+40-5
......@@ -7,27 +7,56 @@ import { acquireBrowserSurface } from "./browserSurfaceStore";
77export function BrowserSurfaceSlot(props: {
88 readonly tabId: string;
99 readonly visible: boolean;
10 readonly cornerRadius?: number;
11 readonly layoutVersion?: string | number;
1012 readonly className?: string;
13 readonly fitSourceContent?: boolean;
1114}) {
12 const { tabId, visible, className } = props;
15 const {
16 tabId,
17 visible,
18 cornerRadius = 0,
19 layoutVersion,
20 className,
21 fitSourceContent = false,
22 } = props;
1323 const elementRef = useRef<HTMLDivElement | null>(null);
24 const presentationRef = useRef({ visible, cornerRadius });
25 const updateRef = useRef<(() => void) | null>(null);
1426
1527 useLayoutEffect(() => {
1628 const element = elementRef.current;
1729 if (!element) return;
18 const lease = acquireBrowserSurface(tabId);
30 let lease = acquireBrowserSurface(tabId, fitSourceContent);
1931 const update = () => {
2032 const rect = element.getBoundingClientRect();
21 lease.present(
33 const presentation = presentationRef.current;
34 const presented = lease.present(
2235 {
2336 x: Math.round(rect.x),
2437 y: Math.round(rect.y),
2538 width: Math.max(1, Math.round(rect.width)),
2639 height: Math.max(1, Math.round(rect.height)),
2740 },
28 visible && rect.width > 0 && rect.height > 0,
41 presentation.visible && rect.width > 0 && rect.height > 0,
42 presentation.cornerRadius,
2943 );
44 if (presentation.visible && !presented) {
45 lease.release();
46 lease = acquireBrowserSurface(tabId, fitSourceContent);
47 lease.present(
48 {
49 x: Math.round(rect.x),
50 y: Math.round(rect.y),
51 width: Math.max(1, Math.round(rect.width)),
52 height: Math.max(1, Math.round(rect.height)),
53 },
54 rect.width > 0 && rect.height > 0,
55 presentation.cornerRadius,
56 );
57 }
3058 };
59 updateRef.current = update;
3160 update();
3261 const observer = new ResizeObserver(update);
3362 observer.observe(element);
......@@ -37,9 +66,15 @@ export function BrowserSurfaceSlot(props: {
3766 observer.disconnect();
3867 window.removeEventListener("resize", update);
3968 window.removeEventListener("scroll", update, true);
69 if (updateRef.current === update) updateRef.current = null;
4070 lease.release();
4171 };
42 }, [tabId, visible]);
72 }, [fitSourceContent, tabId]);
73
74 useLayoutEffect(() => {
75 presentationRef.current = { visible, cornerRadius };
76 updateRef.current?.();
77 }, [cornerRadius, layoutVersion, visible]);
4378
4479 return <div ref={elementRef} className={className} data-browser-surface-slot={tabId} />;
4580}
apps/web/src/browser/HostedBrowserWebview.tsx+85-19
......@@ -8,15 +8,19 @@ import { previewBridge } from "~/components/preview/previewBridge";
88import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
99import { cn } from "~/lib/utils";
1010
11import { stopBrowserRecording, useActiveBrowserRecordingTabId } from "./browserRecording";
1211import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
13import { browserViewportSettingKey } from "./browserViewportLayout";
12import { browserViewportSettingKey, resolveBrowserViewportLayout } from "./browserViewportLayout";
1413import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";
1514import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";
1615import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";
1716import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle";
1817import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
1918import { useBrowserViewportResize } from "./useBrowserViewportResize";
19import {
20 INITIAL_WEBVIEW_CRASH_RECOVERY_STATE,
21 planWebviewCrashRecovery,
22 type WebviewCrashRecoveryState,
23} from "./webviewCrashRecovery";
2024
2125interface ElectronWebview extends HTMLElement {
2226 src: string;
......@@ -46,12 +50,15 @@ export function HostedBrowserWebview(props: {
4650 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
4751 const wrapperRef = useRef<HTMLDivElement | null>(null);
4852 const webviewRef = useRef<ElectronWebview | null>(null);
53 const crashRecoveryRef = useRef<WebviewCrashRecoveryState>(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE);
4954 const [aspectRatioLocked, setAspectRatioLocked] = useState(false);
50 const activeRecordingTabId = useActiveBrowserRecordingTabId();
5155 const presentation = useBrowserSurfaceStore(
5256 useShallow((state) => {
5357 const current = state.byTabId[tabId];
5458 return {
59 cornerRadius: current?.cornerRadius ?? 0,
60 fitSourceContent: current?.fitSourceContent ?? false,
61 fittedSourceContent: current?.fittedSourceContent ?? null,
5562 rect: resolveBrowserSurfacePanelRect(state.byTabId, tabId),
5663 visible: current?.visible ?? false,
5764 };
......@@ -60,11 +67,7 @@ export function HostedBrowserWebview(props: {
6067 usePreviewBridge({ threadRef, tabId });
6168
6269 useEffect(() => {
63 if (presentation.visible || activeRecordingTabId !== tabId) return;
64 void stopBrowserRecording(tabId).catch(() => undefined);
65 }, [activeRecordingTabId, presentation.visible, tabId]);
66
67 useEffect(() => {
70 crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE;
6871 const lease = acquireDesktopTab(tabId);
6972 tabLeaseRef.current = lease;
7073 return () => {
......@@ -73,6 +76,14 @@ export function HostedBrowserWebview(props: {
7376 };
7477 }, [tabId]);
7578
79 const [webviewGeneration, setWebviewGeneration] = useState(0);
80 const [recoverySrc, setRecoverySrc] = useState(initialSrc);
81 const latestUrlRef = useRef(initialUrl);
82
83 useEffect(() => {
84 latestUrlRef.current = initialUrl;
85 }, [initialUrl]);
86
7687 const setWebviewRef = useCallback((node: HTMLElement | null) => {
7788 webviewRef.current = node as ElectronWebview | null;
7889 if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true");
......@@ -83,6 +94,7 @@ export function HostedBrowserWebview(props: {
8394 const bridge = previewBridge;
8495 if (!webview || !config || !bridge) return;
8596 let disposed = false;
97 let recoveryTimeout: ReturnType<typeof setTimeout> | null = null;
8698 const register = () => {
8799 const lease = tabLeaseRef.current;
88100 if (!lease) return;
......@@ -102,15 +114,31 @@ export function HostedBrowserWebview(props: {
102114 }
103115 })();
104116 };
117 const recoverGuest = () => {
118 if (disposed || recoveryTimeout !== null) return;
119 const recovery = planWebviewCrashRecovery(crashRecoveryRef.current, Date.now());
120 if (!recovery) return;
121 crashRecoveryRef.current = recovery.state;
122 recoveryTimeout = setTimeout(() => {
123 recoveryTimeout = null;
124 if (!disposed) {
125 setRecoverySrc(latestUrlRef.current ?? initialSrc);
126 setWebviewGeneration((generation) => generation + 1);
127 }
128 }, recovery.delayMs);
129 };
105130 webview.addEventListener("did-attach", register);
106131 webview.addEventListener("dom-ready", register);
132 webview.addEventListener("render-process-gone", recoverGuest);
107133 register();
108134 return () => {
109135 disposed = true;
136 if (recoveryTimeout !== null) clearTimeout(recoveryTimeout);
110137 webview.removeEventListener("did-attach", register);
111138 webview.removeEventListener("dom-ready", register);
139 webview.removeEventListener("render-process-gone", recoverGuest);
112140 };
113 }, [config, tabId]);
141 }, [config, initialSrc, tabId, webviewGeneration]);
114142
115143 const active = presentation.visible && presentation.rect !== null;
116144 const lastRect = presentation.rect;
......@@ -132,14 +160,14 @@ export function HostedBrowserWebview(props: {
132160 }
133161 : { width: lastRect?.width ?? 1280, height: lastRect?.height ?? 800 };
134162 const containerSize = active && lastRect ? lastRect : hiddenSize;
135 const deviceToolbarVisible = active && viewport._tag !== "fill";
163 const deviceToolbarVisible = active && viewport._tag !== "fill" && !presentation.fitSourceContent;
136164 const {
137165 activeDrag,
138166 commitViewportChange,
139167 effectiveViewport,
140168 handleResizeKeyDown,
141169 handleResizePointerDown,
142 layout,
170 layout: viewportLayout,
143171 } = useBrowserViewportResize({
144172 tabId,
145173 viewport,
......@@ -148,6 +176,38 @@ export function HostedBrowserWebview(props: {
148176 deviceToolbarVisible,
149177 aspectRatio: lockedAspectRatio,
150178 });
179 const fittedSourceViewport =
180 presentation.fitSourceContent && lastRect
181 ? presentation.fittedSourceContent
182 ? {
183 _tag: "freeform" as const,
184 width: Math.max(
185 1,
186 Math.round(
187 presentation.fittedSourceContent.width /
188 presentation.fittedSourceContent.scale /
189 normalizedZoomFactor,
190 ),
191 ),
192 height: Math.max(
193 1,
194 Math.round(
195 presentation.fittedSourceContent.height /
196 presentation.fittedSourceContent.scale /
197 normalizedZoomFactor,
198 ),
199 ),
200 }
201 : {
202 _tag: "freeform" as const,
203 width: viewport._tag === "fill" ? 1280 : viewport.width,
204 height: viewport._tag === "fill" ? 800 : viewport.height,
205 }
206 : null;
207 const layout =
208 fittedSourceViewport && lastRect
209 ? resolveBrowserViewportLayout(lastRect, fittedSourceViewport, normalizedZoomFactor)
210 : viewportLayout;
151211
152212 const syncContentPresentation = useCallback(() => {
153213 const wrapper = wrapperRef.current;
......@@ -178,6 +238,7 @@ export function HostedBrowserWebview(props: {
178238
179239 const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({
180240 active,
241 cornerRadius: presentation.cornerRadius,
181242 rect: lastRect,
182243 hiddenSize,
183244 });
......@@ -201,8 +262,9 @@ export function HostedBrowserWebview(props: {
201262 />
202263 ) : null}
203264 <webview
265 key={webviewGeneration}
204266 ref={setWebviewRef}
205 src={initialSrc}
267 src={webviewGeneration === 0 ? initialSrc : recoverySrc}
206268 partition={config.partition}
207269 webpreferences={config.webPreferences}
208270 {...(config.preloadUrl ? { preload: config.preloadUrl } : {})}
......@@ -210,14 +272,18 @@ export function HostedBrowserWebview(props: {
210272 data-preview-viewport-mode={effectiveViewport._tag}
211273 data-preview-viewport-key={browserViewportSettingKey(effectiveViewport)}
212274 data-preview-css-width={
213 effectiveViewport._tag === "fill"
214 ? Math.max(1, Math.round(layout.viewportWidth / normalizedZoomFactor))
215 : effectiveViewport.width
275 fittedSourceViewport
276 ? fittedSourceViewport.width
277 : effectiveViewport._tag === "fill"
278 ? Math.max(1, Math.round(layout.viewportWidth / normalizedZoomFactor))
279 : effectiveViewport.width
216280 }
217281 data-preview-css-height={
218 effectiveViewport._tag === "fill"
219 ? Math.max(1, Math.round(layout.viewportHeight / normalizedZoomFactor))
220 : effectiveViewport.height
282 fittedSourceViewport
283 ? fittedSourceViewport.height
284 : effectiveViewport._tag === "fill"
285 ? Math.max(1, Math.round(layout.viewportHeight / normalizedZoomFactor))
286 : effectiveViewport.height
221287 }
222288 aria-hidden={active ? undefined : true}
223289 className={cn(
......@@ -233,7 +299,7 @@ export function HostedBrowserWebview(props: {
233299 transformOrigin: "top left",
234300 }}
235301 />
236 {active && effectiveViewport._tag !== "fill" ? (
302 {active && effectiveViewport._tag !== "fill" && !fittedSourceViewport ? (
237303 <>
238304 <BrowserViewportResizeHandles
239305 layout={layout}
apps/web/src/browser/browserRecording.test.ts+319-53
......@@ -1,32 +1,73 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
12import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
23
3const { events, onFrame, registrySet, save, startScreencast, stopScreencast, surfaceState } =
4 vi.hoisted(() => {
5 const events: string[] = [];
6 const surfaceState = {
7 byTabId: {} as Record<string, unknown>,
8 };
9 return {
10 events,
11 onFrame: vi.fn(() => vi.fn()),
12 registrySet: vi.fn((_atom: unknown, value: string | null) => {
13 events.push(value === null ? "clear" : `publish:${value}`);
14 }),
15 save: vi.fn(async () => ({
16 id: "recording-test",
17 tabId: "recording-tab",
18 path: "/tmp/recording-test.webm",
19 mimeType: "video/webm" as const,
20 sizeBytes: 0,
21 createdAt: "2026-06-26T00:00:00.000Z",
22 })),
23 startScreencast: vi.fn(async () => {
24 events.push("start-screencast");
25 }),
26 stopScreencast: vi.fn(async () => undefined),
27 surfaceState,
28 };
29 });
4const {
5 events,
6 frameSubscription,
7 onFrame,
8 registrySet,
9 save,
10 startScreencast,
11 stopScreencast,
12 surfaceState,
13} = vi.hoisted(() => {
14 const events: string[] = [];
15 type Frame = {
16 readonly tabId: string;
17 readonly data: string;
18 readonly width: number;
19 readonly height: number;
20 readonly receivedAt: string;
21 };
22 const frameSubscription: { listener: ((frame: Frame) => void) | null } = {
23 listener: null,
24 };
25 const surfaceState = {
26 byTabId: {} as Record<string, unknown>,
27 };
28 return {
29 events,
30 frameSubscription,
31 onFrame: vi.fn((listener: (frame: Frame) => void) => {
32 frameSubscription.listener = listener;
33 return () => {
34 if (frameSubscription.listener === listener) frameSubscription.listener = null;
35 };
36 }),
37 registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet<string> }) => {
38 events.push(
39 value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`,
40 );
41 }),
42 save: vi.fn(async (tabId: string) => ({
43 id: "recording-test",
44 tabId,
45 path: "/tmp/recording-test.webm",
46 mimeType: "video/webm" as const,
47 sizeBytes: 0,
48 createdAt: "2026-06-26T00:00:00.000Z",
49 })),
50 startScreencast: vi.fn(async (tabId: string) => {
51 events.push("start-screencast");
52 const surface = surfaceState.byTabId[tabId] as
53 | {
54 readonly content?: { readonly width: number; readonly height: number };
55 readonly rect?: { readonly width: number; readonly height: number };
56 }
57 | undefined;
58 const size = surface?.content ?? surface?.rect;
59 frameSubscription.listener?.({
60 tabId,
61 data: "initial-frame",
62 width: size?.width ?? 1280,
63 height: size?.height ?? 800,
64 receivedAt: "2026-06-26T00:00:00.000Z",
65 });
66 }),
67 stopScreencast: vi.fn(async () => undefined),
68 surfaceState,
69 };
70});
3071
3172vi.mock("~/components/preview/previewBridge", () => ({
3273 previewBridge: {
......@@ -45,10 +86,10 @@ vi.mock("./browserSurfaceStore", () => ({
4586}));
4687
4788import {
89 BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS,
4890 BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS,
4991 BrowserRecordingConflictError,
50 BrowserRecordingOperationError,
51 BrowserRecordingRequiresVisibleTabError,
92 readActiveBrowserRecordingTabIds,
5293 startBrowserRecording,
5394 stopBrowserRecording,
5495} from "./browserRecording";
......@@ -80,9 +121,20 @@ class FakeMediaRecorder {
80121 }
81122}
82123
124const emitRecordingFrame = () => {
125 frameSubscription.listener?.({
126 tabId: "recording-tab",
127 data: "startup-frame",
128 width: 800,
129 height: 600,
130 receivedAt: "2026-06-26T00:00:00.000Z",
131 });
132};
133
83134describe("browser recording", () => {
84135 beforeEach(() => {
85136 events.length = 0;
137 frameSubscription.listener = null;
86138 surfaceState.byTabId = {
87139 "recording-tab": {
88140 visible: true,
......@@ -93,12 +145,26 @@ describe("browser recording", () => {
93145 vi.clearAllMocks();
94146 vi.stubGlobal("window", globalThis);
95147 vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder);
148 class ImmediateImage {
149 private loadListener: EventListenerOrEventListenerObject | undefined;
150
151 addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
152 if (type === "load") this.loadListener = listener;
153 }
154
155 set src(_value: string) {
156 const event = new Event("load");
157 if (typeof this.loadListener === "function") this.loadListener(event);
158 else this.loadListener?.handleEvent(event);
159 }
160 }
161 vi.stubGlobal("Image", ImmediateImage as unknown as typeof Image);
96162 vi.stubGlobal("document", {
97163 createElement: () => ({
98164 width: 0,
99165 height: 0,
100166 captureStream: () => ({}),
101 getContext: () => ({ drawImage: vi.fn() }),
167 getContext: () => ({ drawImage: vi.fn(), fillRect: vi.fn(), fillStyle: "" }),
102168 }),
103169 });
104170 });
......@@ -116,7 +182,7 @@ describe("browser recording", () => {
116182 await stopBrowserRecording("recording-tab");
117183 });
118184
119 it("rejects recording for a hidden tab before starting screencast", async () => {
185 it("records a hidden tab without requiring it to become visible", async () => {
120186 surfaceState.byTabId = {
121187 "recording-tab": {
122188 visible: false,
......@@ -125,18 +191,189 @@ describe("browser recording", () => {
125191 },
126192 };
127193
128 await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
129 BrowserRecordingRequiresVisibleTabError,
194 await startBrowserRecording("recording-tab");
195
196 expect(startScreencast).toHaveBeenCalledWith("recording-tab");
197 expect(events).toEqual(["start-screencast", "publish:recording-tab"]);
198
199 await stopBrowserRecording("recording-tab");
200 });
201
202 it("fails startup instead of locking a fallback size when no frame arrives", async () => {
203 vi.useFakeTimers();
204 startScreencast.mockImplementationOnce(async () => {
205 events.push("start-screencast");
206 });
207
208 const startPromise = startBrowserRecording("recording-tab");
209 const rejection = expect(startPromise).rejects.toMatchObject({
210 operation: "wait-first-frame",
211 tabId: "recording-tab",
212 });
213 await Promise.resolve();
214 await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS);
215
216 await rejection;
217 expect(stopScreencast).toHaveBeenCalledWith("recording-tab");
218 expect(events.at(-1)).toBe("clear");
219 });
220
221 it("fixes hidden recording dimensions before MediaRecorder starts", async () => {
222 const drawImage = vi.fn();
223 const fillRect = vi.fn();
224 let capturedStreamSize: { readonly width: number; readonly height: number } | undefined;
225 const canvas = {
226 width: 0,
227 height: 0,
228 captureStream: () => {
229 capturedStreamSize = { width: canvas.width, height: canvas.height };
230 return {};
231 },
232 getContext: () => ({ drawImage, fillRect, fillStyle: "" }),
233 };
234 vi.stubGlobal("document", {
235 createElement: () => canvas,
236 });
237 surfaceState.byTabId = {};
238 startScreencast.mockImplementationOnce(async (tabId: string) => {
239 events.push("start-screencast");
240 frameSubscription.listener?.({
241 tabId,
242 data: "captured-frame",
243 width: 390,
244 height: 844,
245 receivedAt: "2026-06-26T00:00:00.000Z",
246 });
247 });
248
249 await startBrowserRecording("recording-tab");
250
251 expect(canvas).toMatchObject({ width: 390, height: 844 });
252 expect(capturedStreamSize).toEqual({ width: 390, height: 844 });
253 expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 390, 844);
254
255 frameSubscription.listener?.({
256 tabId: "recording-tab",
257 data: "different-sized-frame",
258 width: 1280,
259 height: 720,
260 receivedAt: "2026-06-26T00:00:01.000Z",
261 });
262
263 expect(canvas).toMatchObject({ width: 390, height: 844 });
264 expect(fillRect).toHaveBeenLastCalledWith(0, 0, 390, 844);
265
266 await stopBrowserRecording("recording-tab");
267 });
268
269 it("draws the newest decoded frames without starving behind decode latency", async () => {
270 const drawImage = vi.fn();
271 class DeferredImage {
272 static readonly instances: DeferredImage[] = [];
273 private loadListener: EventListenerOrEventListenerObject | undefined;
274
275 constructor() {
276 DeferredImage.instances.push(this);
277 }
278
279 addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
280 if (type === "load") this.loadListener = listener;
281 }
282
283 set src(_value: string) {}
284
285 finishLoading(): void {
286 const event = new Event("load");
287 if (typeof this.loadListener === "function") this.loadListener(event);
288 else this.loadListener?.handleEvent(event);
289 }
290 }
291 vi.stubGlobal("Image", DeferredImage as unknown as typeof Image);
292 vi.stubGlobal("document", {
293 createElement: () => ({
294 width: 0,
295 height: 0,
296 captureStream: () => ({}),
297 getContext: () => ({ drawImage, fillRect: vi.fn(), fillStyle: "" }),
298 }),
299 });
300
301 await startBrowserRecording("recording-tab");
302 frameSubscription.listener?.({
303 tabId: "recording-tab",
304 data: "second-frame",
305 width: 800,
306 height: 600,
307 receivedAt: "2026-06-26T00:00:01.000Z",
308 });
309 frameSubscription.listener?.({
310 tabId: "recording-tab",
311 data: "third-frame",
312 width: 800,
313 height: 600,
314 receivedAt: "2026-06-26T00:00:02.000Z",
315 });
316
317 DeferredImage.instances[1]?.finishLoading();
318 expect(drawImage).toHaveBeenCalledOnce();
319 DeferredImage.instances[2]?.finishLoading();
320 expect(drawImage).toHaveBeenCalledTimes(2);
321 DeferredImage.instances[0]?.finishLoading();
322 expect(drawImage).toHaveBeenCalledTimes(2);
323
324 await stopBrowserRecording("recording-tab");
325 });
326
327 it("records separate tabs concurrently", async () => {
328 const firstThreadRef = {
329 environmentId: EnvironmentId.make("environment-recording"),
330 threadId: ThreadId.make("thread-recording-first"),
331 };
332 const secondThreadRef = {
333 environmentId: EnvironmentId.make("environment-recording"),
334 threadId: ThreadId.make("thread-recording-second"),
335 };
336 surfaceState.byTabId = {
337 ...surfaceState.byTabId,
338 "recording-tab-2": {
339 visible: false,
340 rect: { x: 0, y: 0, width: 390, height: 844 },
341 content: { x: 0, y: 0, width: 390, height: 844, scale: 1, scrollLeft: 0, scrollTop: 0 },
342 },
343 };
344
345 await Promise.all([
346 startBrowserRecording("recording-tab", firstThreadRef),
347 startBrowserRecording("recording-tab-2", secondThreadRef),
348 ]);
349
350 expect(startScreencast).toHaveBeenCalledTimes(2);
351 expect(onFrame).toHaveBeenCalledOnce();
352 expect(events).toContain("publish:recording-tab,recording-tab-2");
353 expect(readActiveBrowserRecordingTabIds()).toEqual(
354 new Set(["recording-tab", "recording-tab-2"]),
130355 );
356 expect(readActiveBrowserRecordingTabIds(firstThreadRef)).toEqual(new Set(["recording-tab"]));
357 expect(readActiveBrowserRecordingTabIds(secondThreadRef)).toEqual(new Set(["recording-tab-2"]));
131358
132 expect(startScreencast).not.toHaveBeenCalled();
133 expect(registrySet).not.toHaveBeenCalled();
359 await stopBrowserRecording("recording-tab");
360 expect(readActiveBrowserRecordingTabIds()).toEqual(new Set(["recording-tab-2"]));
361 await stopBrowserRecording("recording-tab-2");
362 expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
363 expect(save).toHaveBeenCalledTimes(2);
134364 });
135365
136366 it("does not report success for a second start while the first is still starting", async () => {
137367 let finishStartingScreencast: (() => void) | undefined;
138 startScreencast.mockImplementationOnce(async () => {
368 startScreencast.mockImplementationOnce(async (tabId: string) => {
139369 events.push("start-screencast");
370 frameSubscription.listener?.({
371 tabId,
372 data: "initial-frame",
373 width: 800,
374 height: 600,
375 receivedAt: "2026-06-26T00:00:00.000Z",
376 });
140377 await new Promise<void>((resolve) => {
141378 finishStartingScreencast = resolve;
142379 });
......@@ -197,28 +434,27 @@ describe("browser recording", () => {
197434 expect(save).toHaveBeenCalledOnce();
198435 });
199436
200 it("stops a screencast that finishes starting after cancellation", async () => {
437 it("finishes startup before stopping so an active recording yields an artifact", async () => {
201438 let finishStartingScreencast: (() => void) | undefined;
202439 startScreencast.mockImplementationOnce(async () => {
203440 events.push("start-screencast");
204441 await new Promise<void>((resolve) => {
205442 finishStartingScreencast = resolve;
206443 });
444 emitRecordingFrame();
207445 });
208446
209447 const startPromise = startBrowserRecording("recording-tab");
210 const rejectedStart = expect(startPromise).rejects.toBeInstanceOf(
211 BrowserRecordingOperationError,
212 );
213448 await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());
214449
215450 const stopPromise = stopBrowserRecording("recording-tab");
216 await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce());
451 expect(stopScreencast).not.toHaveBeenCalled();
217452 finishStartingScreencast?.();
218453
219 await rejectedStart;
220 await stopPromise;
221 expect(stopScreencast).toHaveBeenCalledTimes(2);
454 await startPromise;
455 await expect(stopPromise).resolves.toMatchObject({ tabId: "recording-tab" });
456 expect(stopScreencast).toHaveBeenCalledOnce();
457 expect(save).toHaveBeenCalledOnce();
222458 expect(events.at(-1)).toBe("clear");
223459 });
224460
......@@ -229,12 +465,10 @@ describe("browser recording", () => {
229465 await new Promise<void>((resolve) => {
230466 finishStartingScreencast = resolve;
231467 });
468 emitRecordingFrame();
232469 });
233470
234471 const firstStart = startBrowserRecording("recording-tab");
235 const rejectedFirstStart = expect(firstStart).rejects.toBeInstanceOf(
236 BrowserRecordingOperationError,
237 );
238472 await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());
239473
240474 const stopPromise = stopBrowserRecording("recording-tab");
......@@ -243,7 +477,7 @@ describe("browser recording", () => {
243477 const startCallsBeforeFirstSettled = startScreencast.mock.calls.length;
244478
245479 finishStartingScreencast?.();
246 await rejectedFirstStart;
480 await firstStart;
247481 await stopPromise;
248482 await restartAfterStop;
249483 await stopBrowserRecording("recording-tab");
......@@ -251,6 +485,39 @@ describe("browser recording", () => {
251485 expect(startCallsBeforeFirstSettled).toBe(1);
252486 });
253487
488 it("keeps the recording slot while a failed stop waits for startup", async () => {
489 let finishStartingScreencast: (() => void) | undefined;
490 startScreencast.mockImplementationOnce(async () => {
491 events.push("start-screencast");
492 await new Promise<void>((resolve) => {
493 finishStartingScreencast = resolve;
494 });
495 emitRecordingFrame();
496 });
497 stopScreencast.mockRejectedValueOnce(new Error("initial stop failed"));
498
499 const firstStart = startBrowserRecording("recording-tab");
500 await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());
501
502 const stopPromise = stopBrowserRecording("recording-tab");
503 const rejectedStop = expect(stopPromise).rejects.toMatchObject({
504 operation: "stop-screencast",
505 tabId: "recording-tab",
506 });
507 expect(stopScreencast).not.toHaveBeenCalled();
508 await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
509 BrowserRecordingConflictError,
510 );
511
512 finishStartingScreencast?.();
513 await firstStart;
514 await rejectedStop;
515 expect(stopScreencast).toHaveBeenCalledOnce();
516
517 await startBrowserRecording("recording-tab");
518 await stopBrowserRecording("recording-tab");
519 });
520
254521 it("fails a stop that waits too long for startup without freeing the recording slot", async () => {
255522 vi.useFakeTimers();
256523 let finishStartingScreencast: (() => void) | undefined;
......@@ -259,18 +526,16 @@ describe("browser recording", () => {
259526 await new Promise<void>((resolve) => {
260527 finishStartingScreencast = resolve;
261528 });
529 emitRecordingFrame();
262530 });
263531
264532 const startPromise = startBrowserRecording("recording-tab");
265 const rejectedStart = expect(startPromise).rejects.toBeInstanceOf(
266 BrowserRecordingOperationError,
267 );
268533 expect(startScreencast).toHaveBeenCalledOnce();
269534
270535 const stopPromise = stopBrowserRecording("recording-tab");
271536 await Promise.resolve();
272537 await Promise.resolve();
273 expect(stopScreencast).toHaveBeenCalledOnce();
538 expect(stopScreencast).not.toHaveBeenCalled();
274539
275540 const rejection = expect(stopPromise).rejects.toMatchObject({
276541 operation: "wait-startup",
......@@ -285,9 +550,10 @@ describe("browser recording", () => {
285550 );
286551
287552 finishStartingScreencast?.();
288 await rejectedStart;
553 await startPromise;
289554 const cleanupResult = await stopBrowserRecording("recording-tab");
290555 expect(cleanupResult).toBeNull();
556 expect(stopScreencast).toHaveBeenCalledOnce();
291557 expect(save).not.toHaveBeenCalled();
292558 expect(events.at(-1)).toBe("clear");
293559 });
apps/web/src/browser/browserRecording.ts+253-121
......@@ -1,6 +1,7 @@
11import type {
22 DesktopPreviewRecordingArtifact,
33 DesktopPreviewRecordingFrame,
4 ScopedThreadRef,
45} from "@t3tools/contracts";
56import { useAtomValue } from "@effect/atom-react";
67import * as Schema from "effect/Schema";
......@@ -46,17 +47,6 @@ export class BrowserRecordingCanvasUnavailableError extends Schema.TaggedErrorCl
4647 }
4748}
4849
49export class BrowserRecordingRequiresVisibleTabError extends Schema.TaggedErrorClass<BrowserRecordingRequiresVisibleTabError>()(
50 "BrowserRecordingRequiresVisibleTabError",
51 {
52 tabId: Schema.String,
53 },
54) {
55 override get message(): string {
56 return `Browser recording requires tab ${this.tabId} to be visible.`;
57 }
58}
59
6050export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<BrowserRecordingOperationError>()(
6151 "BrowserRecordingOperationError",
6252 {
......@@ -66,6 +56,7 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<Brow
6656 "start-media-recorder",
6757 "start-screencast",
6858 "stop-screencast",
59 "wait-first-frame",
6960 "wait-startup",
7061 "stop-media-recorder",
7162 "save-artifact",
......@@ -92,32 +83,52 @@ type BrowserRecordingLifecycle =
9283
9384interface ActiveRecording {
9485 readonly tabId: string;
86 readonly threadRef: ScopedThreadRef | null;
9587 readonly canvas: HTMLCanvasElement;
9688 readonly context: CanvasRenderingContext2D;
97 readonly recorder: MediaRecorder;
9889 readonly chunks: Blob[];
99 readonly mimeType: string;
10090 readonly startedAt: string;
10191 readonly startupSettled: Promise<void>;
92 readonly firstFrameSize: Promise<"frame" | "cancelled">;
93 readonly settleFirstFrameSize: (outcome: "frame" | "cancelled") => void;
94 recorder: MediaRecorder | null;
95 mimeType: string | null;
96 frameSizeEstablished: boolean;
97 frameSequence: number;
98 lastDrawnFrameSequence: number;
10299 lifecycle: BrowserRecordingLifecycle;
103100}
104101
105const activeBrowserRecordingTabIdAtom = Atom.make<string | null>(null).pipe(
106 Atom.keepAlive,
107 Atom.withLabel("preview:active-browser-recording-tab"),
108);
102interface ActiveBrowserRecordingIndex {
103 readonly tabIds: ReadonlySet<string>;
104}
109105
110export function useActiveBrowserRecordingTabId(): string | null {
111 return useAtomValue(activeBrowserRecordingTabIdAtom);
106const activeBrowserRecordingTabIdsAtom = Atom.make<ActiveBrowserRecordingIndex>({
107 tabIds: new Set<string>(),
108}).pipe(Atom.keepAlive, Atom.withLabel("preview:active-browser-recording-tabs"));
109
110export function useActiveBrowserRecordingTabIds(): ReadonlySet<string> {
111 return useAtomValue(activeBrowserRecordingTabIdsAtom).tabIds;
112112}
113113
114let active: ActiveRecording | null = null;
114const activeRecordings = new Map<string, ActiveRecording>();
115115let unsubscribeFrames: (() => void) | null = null;
116116
117117export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000;
118export const BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS = 5_000;
118119
119export function readActiveBrowserRecordingTabId(): string | null {
120 return active?.tabId ?? null;
120export function readActiveBrowserRecordingTabIds(threadRef?: ScopedThreadRef): ReadonlySet<string> {
121 const tabIds = new Set<string>();
122 for (const recording of activeRecordings.values()) {
123 if (
124 threadRef === undefined ||
125 (recording.threadRef?.environmentId === threadRef.environmentId &&
126 recording.threadRef.threadId === threadRef.threadId)
127 ) {
128 tabIds.add(recording.tabId);
129 }
130 }
131 return tabIds;
121132}
122133
123134const preferredMimeType = (): string => {
......@@ -126,22 +137,52 @@ const preferredMimeType = (): string => {
126137};
127138
128139const drawFrame = (frame: DesktopPreviewRecordingFrame): void => {
129 const recording = active;
130 if (!recording || recording.tabId !== frame.tabId) return;
140 const recording = activeRecordings.get(frame.tabId);
141 if (!recording) return;
142 if (
143 !Number.isFinite(frame.width) ||
144 !Number.isFinite(frame.height) ||
145 frame.width <= 0 ||
146 frame.height <= 0
147 ) {
148 return;
149 }
150 const width = Math.max(1, Math.round(frame.width));
151 const height = Math.max(1, Math.round(frame.height));
152 if (!recording.frameSizeEstablished) {
153 recording.canvas.width = width;
154 recording.canvas.height = height;
155 recording.frameSizeEstablished = true;
156 recording.settleFirstFrameSize("frame");
157 }
158 const frameSequence = ++recording.frameSequence;
131159 const image = new Image();
132160 image.addEventListener(
133161 "load",
134162 () => {
135 if (active !== recording) return;
136 recording.context.drawImage(image, 0, 0, recording.canvas.width, recording.canvas.height);
163 if (
164 activeRecordings.get(frame.tabId) !== recording ||
165 frameSequence <= recording.lastDrawnFrameSequence
166 ) {
167 return;
168 }
169 recording.lastDrawnFrameSequence = frameSequence;
170 const scale = Math.min(recording.canvas.width / width, recording.canvas.height / height);
171 const targetWidth = width * scale;
172 const targetHeight = height * scale;
173 const targetX = (recording.canvas.width - targetWidth) / 2;
174 const targetY = (recording.canvas.height - targetHeight) / 2;
175 recording.context.fillStyle = "#000000";
176 recording.context.fillRect(0, 0, recording.canvas.width, recording.canvas.height);
177 recording.context.drawImage(image, targetX, targetY, targetWidth, targetHeight);
137178 },
138179 { once: true },
139180 );
140181 image.src = `data:image/jpeg;base64,${frame.data}`;
141182};
142183
143const stopMediaRecorder = async (recorder: MediaRecorder): Promise<void> => {
144 if (recorder.state === "inactive") return;
184const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise<void> => {
185 if (!recorder || recorder.state === "inactive") return;
145186 const stopped = new Promise<void>((resolve) =>
146187 recorder.addEventListener("stop", () => resolve(), { once: true }),
147188 );
......@@ -150,11 +191,42 @@ const stopMediaRecorder = async (recorder: MediaRecorder): Promise<void> => {
150191};
151192
152193const clearActiveRecording = (recording: ActiveRecording): void => {
153 if (active !== recording) return;
154 active = null;
155 unsubscribeFrames?.();
156 unsubscribeFrames = null;
157 appAtomRegistry.set(activeBrowserRecordingTabIdAtom, null);
194 if (activeRecordings.get(recording.tabId) !== recording) return;
195 recording.settleFirstFrameSize("cancelled");
196 activeRecordings.delete(recording.tabId);
197 if (activeRecordings.size === 0) {
198 unsubscribeFrames?.();
199 unsubscribeFrames = null;
200 }
201 appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, {
202 tabIds: new Set(activeRecordings.keys()),
203 });
204};
205
206const cleanupFailedRecordingStart = async (
207 bridge: NonNullable<typeof previewBridge>,
208 recording: ActiveRecording,
209): Promise<unknown | undefined> => {
210 const errors: unknown[] = [];
211 try {
212 await bridge.recording.stopScreencast(recording.tabId);
213 } catch (error) {
214 errors.push(error);
215 }
216 try {
217 await stopMediaRecorder(recording.recorder);
218 } catch (error) {
219 errors.push(error);
220 } finally {
221 clearActiveRecording(recording);
222 }
223 if (errors.length === 0) return undefined;
224 if (errors.length === 1) return errors[0];
225 return new AggregateError(
226 errors,
227 `Browser recording startup cleanup failed for tab ${recording.tabId}.`,
228 { cause: errors[0] },
229 );
158230};
159231
160232const recordingStartupCancelledError = (
......@@ -168,7 +240,20 @@ const recordingStartupCancelledError = (
168240 });
169241
170242const isRecordingStarting = (recording: ActiveRecording): boolean =>
171 active === recording && recording.lifecycle.phase === "starting";
243 activeRecordings.get(recording.tabId) === recording && recording.lifecycle.phase === "starting";
244
245const waitForFirstFrameSize = async (recording: ActiveRecording): Promise<boolean> => {
246 if (recording.frameSizeEstablished) return true;
247 let timeout: ReturnType<typeof setTimeout> | null = null;
248 const outcome = await Promise.race([
249 recording.firstFrameSize,
250 new Promise<"timeout">((resolve) => {
251 timeout = setTimeout(() => resolve("timeout"), BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS);
252 }),
253 ]);
254 if (timeout !== null) clearTimeout(timeout);
255 return outcome === "frame";
256};
172257
173258const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Promise<void> => {
174259 let timeout: ReturnType<typeof setTimeout> | null = null;
......@@ -195,20 +280,23 @@ const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Prom
195280const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperationError =>
196281 isBrowserRecordingOperationError(error) && error.operation === "wait-startup";
197282
198export async function startBrowserRecording(tabId: string): Promise<string> {
283export async function startBrowserRecording(
284 tabId: string,
285 threadRef: ScopedThreadRef | null = null,
286): Promise<string> {
199287 const bridge = previewBridge;
200288 if (!bridge) throw new BrowserRecordingUnavailableError({ tabId });
201 if (active) {
202 if (active.tabId === tabId && active.lifecycle.phase === "recording") {
203 return active.startedAt;
289 const activeRecording = activeRecordings.get(tabId);
290 if (activeRecording) {
291 if (activeRecording.lifecycle.phase === "recording") {
292 return activeRecording.startedAt;
204293 }
205294 throw new BrowserRecordingConflictError({
206295 requestedTabId: tabId,
207 activeTabId: active.tabId,
296 activeTabId: activeRecording.tabId,
208297 });
209298 }
210299 const surface = useBrowserSurfaceStore.getState().byTabId[tabId];
211 if (!surface?.visible) throw new BrowserRecordingRequiresVisibleTabError({ tabId });
212300 const recordingSize = surface?.content ?? surface?.rect;
213301 const canvas = document.createElement("canvas");
214302 canvas.width = Math.max(1, recordingSize?.width ?? 1280);
......@@ -221,42 +309,34 @@ export async function startBrowserRecording(tabId: string): Promise<string> {
221309 height: canvas.height,
222310 });
223311 }
224 let mimeType: string;
225 let recorder: MediaRecorder;
226 try {
227 mimeType = preferredMimeType();
228 recorder = new MediaRecorder(canvas.captureStream(12), {
229 mimeType,
230 videoBitsPerSecond: 4_000_000,
231 });
232 } catch (cause) {
233 throw new BrowserRecordingOperationError({
234 operation: "initialize-media-recorder",
235 tabId,
236 cause,
237 });
238 }
239312 const startedAt = new Date().toISOString();
240313 const chunks: Blob[] = [];
241314 let settleStartup: (() => void) | undefined;
242315 const startupSettled = new Promise<void>((resolve) => {
243316 settleStartup = resolve;
244317 });
245 recorder.addEventListener("dataavailable", (event) => {
246 if (event.data.size > 0) chunks.push(event.data);
318 let settleFirstFrameSize: ((outcome: "frame" | "cancelled") => void) | undefined;
319 const firstFrameSize = new Promise<"frame" | "cancelled">((resolve) => {
320 settleFirstFrameSize = resolve;
247321 });
248322 const recording: ActiveRecording = {
249323 tabId,
324 threadRef,
250325 canvas,
251326 context,
252 recorder,
253327 chunks,
254 mimeType,
255328 startedAt,
256329 startupSettled,
330 firstFrameSize,
331 settleFirstFrameSize: (outcome) => settleFirstFrameSize?.(outcome),
332 recorder: null,
333 mimeType: null,
334 frameSizeEstablished: false,
335 frameSequence: 0,
336 lastDrawnFrameSequence: 0,
257337 lifecycle: { phase: "starting" },
258338 };
259 active = recording;
339 activeRecordings.set(tabId, recording);
260340 try {
261341 try {
262342 unsubscribeFrames ??= bridge.recording.onFrame(drawFrame);
......@@ -268,47 +348,23 @@ export async function startBrowserRecording(tabId: string): Promise<string> {
268348 cause,
269349 });
270350 }
271 try {
272 recorder.start(1_000);
273 } catch (cause) {
274 clearActiveRecording(recording);
275 throw new BrowserRecordingOperationError({
276 operation: "start-media-recorder",
277 tabId,
278 cause,
279 });
280 }
281 if (!isRecordingStarting(recording)) {
282 throw recordingStartupCancelledError(recording);
283 }
284351 try {
285352 await bridge.recording.startScreencast(tabId);
286353 } catch (cause) {
287354 if (!isRecordingStarting(recording)) {
288355 throw recordingStartupCancelledError(recording, cause);
289356 }
290 let cleanupCause: unknown;
291 try {
292 await stopMediaRecorder(recorder);
293 } catch (error) {
294 cleanupCause = error;
295 } finally {
296 clearActiveRecording(recording);
297 }
357 clearActiveRecording(recording);
298358 throw new BrowserRecordingOperationError({
299359 operation: "start-screencast",
300360 tabId,
301 cause:
302 cleanupCause === undefined
303 ? cause
304 : new AggregateError(
305 [cause, cleanupCause],
306 `Browser recording start and cleanup failed for tab ${tabId}.`,
307 { cause },
308 ),
361 cause,
309362 });
310363 }
311 if (!isRecordingStarting(recording)) {
364 const throwIfStartupCancelled = async (): Promise<void> => {
365 // A stop requested during startup should let startup finish so the
366 // caller receives a real artifact. Only replacement/removal cancels it.
367 if (activeRecordings.get(tabId) === recording) return;
312368 try {
313369 await bridge.recording.stopScreencast(tabId);
314370 } catch (cause) {
......@@ -322,9 +378,78 @@ export async function startBrowserRecording(tabId: string): Promise<string> {
322378 );
323379 }
324380 throw recordingStartupCancelledError(recording);
381 };
382 await throwIfStartupCancelled();
383 const hasFirstFrame = await waitForFirstFrameSize(recording);
384 await throwIfStartupCancelled();
385 if (!hasFirstFrame) {
386 const cause = new Error(`No valid recording frame arrived for tab ${tabId}.`);
387 const cleanupCause = await cleanupFailedRecordingStart(bridge, recording);
388 throw new BrowserRecordingOperationError({
389 operation: "wait-first-frame",
390 tabId,
391 cause:
392 cleanupCause === undefined
393 ? cause
394 : new AggregateError(
395 [cause, cleanupCause],
396 `Browser recording frame wait and cleanup failed for tab ${tabId}.`,
397 { cause },
398 ),
399 });
400 }
401
402 let mimeType: string;
403 let recorder: MediaRecorder;
404 try {
405 mimeType = preferredMimeType();
406 recorder = new MediaRecorder(canvas.captureStream(12), {
407 mimeType,
408 videoBitsPerSecond: 4_000_000,
409 });
410 recording.mimeType = mimeType;
411 recording.recorder = recorder;
412 recorder.addEventListener("dataavailable", (event) => {
413 if (event.data.size > 0) chunks.push(event.data);
414 });
415 } catch (cause) {
416 const cleanupCause = await cleanupFailedRecordingStart(bridge, recording);
417 throw new BrowserRecordingOperationError({
418 operation: "initialize-media-recorder",
419 tabId,
420 cause:
421 cleanupCause === undefined
422 ? cause
423 : new AggregateError(
424 [cause, cleanupCause],
425 `Browser recording initialization and cleanup failed for tab ${tabId}.`,
426 { cause },
427 ),
428 });
325429 }
326 recording.lifecycle = { phase: "recording" };
327 appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId);
430 try {
431 recorder.start(1_000);
432 } catch (cause) {
433 const cleanupCause = await cleanupFailedRecordingStart(bridge, recording);
434 throw new BrowserRecordingOperationError({
435 operation: "start-media-recorder",
436 tabId,
437 cause:
438 cleanupCause === undefined
439 ? cause
440 : new AggregateError(
441 [cause, cleanupCause],
442 `Browser media recorder start and cleanup failed for tab ${tabId}.`,
443 { cause },
444 ),
445 });
446 }
447 if (recording.lifecycle.phase === "starting") {
448 recording.lifecycle = { phase: "recording" };
449 }
450 appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, {
451 tabIds: new Set(activeRecordings.keys()),
452 });
328453 return startedAt;
329454 } finally {
330455 settleStartup?.();
......@@ -334,12 +459,16 @@ export async function startBrowserRecording(tabId: string): Promise<string> {
334459const finalizeBrowserRecording = async (
335460 bridge: NonNullable<typeof previewBridge>,
336461 recording: ActiveRecording,
337): Promise<DesktopPreviewRecordingArtifact> => {
462): Promise<DesktopPreviewRecordingArtifact | null> => {
338463 const { tabId } = recording;
339464 let result:
340 | { readonly _tag: "Success"; readonly artifact: DesktopPreviewRecordingArtifact }
465 | {
466 readonly _tag: "Success";
467 readonly artifact: DesktopPreviewRecordingArtifact | null;
468 }
341469 | { readonly _tag: "Failure"; readonly error: unknown };
342470 try {
471 await waitForRecordingStartupToSettle(recording);
343472 try {
344473 await bridge.recording.stopScreencast(tabId);
345474 } catch (cause) {
......@@ -349,30 +478,33 @@ const finalizeBrowserRecording = async (
349478 cause,
350479 });
351480 }
352 await waitForRecordingStartupToSettle(recording);
353 try {
354 await stopMediaRecorder(recording.recorder);
355 } catch (cause) {
356 throw new BrowserRecordingOperationError({
357 operation: "stop-media-recorder",
358 tabId,
359 cause,
360 });
361 }
362 try {
363 const blob = new Blob(recording.chunks, { type: recording.mimeType });
364 const artifact = await bridge.recording.save(
365 tabId,
366 recording.mimeType,
367 new Uint8Array(await blob.arrayBuffer()),
368 );
369 result = { _tag: "Success", artifact };
370 } catch (cause) {
371 throw new BrowserRecordingOperationError({
372 operation: "save-artifact",
373 tabId,
374 cause,
375 });
481 if (!recording.recorder || !recording.mimeType) {
482 result = { _tag: "Success", artifact: null };
483 } else {
484 try {
485 await stopMediaRecorder(recording.recorder);
486 } catch (cause) {
487 throw new BrowserRecordingOperationError({
488 operation: "stop-media-recorder",
489 tabId,
490 cause,
491 });
492 }
493 try {
494 const blob = new Blob(recording.chunks, { type: recording.mimeType });
495 const artifact = await bridge.recording.save(
496 tabId,
497 recording.mimeType,
498 new Uint8Array(await blob.arrayBuffer()),
499 );
500 result = { _tag: "Success", artifact };
501 } catch (cause) {
502 throw new BrowserRecordingOperationError({
503 operation: "save-artifact",
504 tabId,
505 cause,
506 });
507 }
376508 }
377509 } catch (error) {
378510 result = { _tag: "Failure", error };
......@@ -434,14 +566,14 @@ export function stopBrowserRecording(
434566 tabId: string,
435567): Promise<DesktopPreviewRecordingArtifact | null> {
436568 const bridge = previewBridge;
437 const recording = active;
438 if (!bridge || !recording || recording.tabId !== tabId) return Promise.resolve(null);
569 const recording = activeRecordings.get(tabId);
570 if (!bridge || !recording) return Promise.resolve(null);
439571 if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise;
440572
441573 const stopPromise = Promise.resolve()
442574 .then(() => finalizeBrowserRecording(bridge, recording))
443575 .catch((error) => {
444 if (isStartupWaitTimeout(error) && active === recording) {
576 if (isStartupWaitTimeout(error) && activeRecordings.get(recording.tabId) === recording) {
445577 const cleanupAfterStartup = recording.startupSettled.then(() =>
446578 discardBrowserRecording(bridge, recording),
447579 );
apps/web/src/browser/browserRecordingScope.test.ts+34-7
......@@ -3,14 +3,41 @@ import { describe, expect, it } from "vite-plus/test";
33import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope";
44
55describe("resolveBrowserRecordingStopTarget", () => {
6 it("stops the active recording when no explicit tab was requested", () => {
7 expect(resolveBrowserRecordingStopTarget("tab-a")).toBe("tab-a");
8 expect(resolveBrowserRecordingStopTarget("tab-b")).toBe("tab-b");
9 expect(resolveBrowserRecordingStopTarget(null)).toBeNull();
6 it("stops the only active recording when the implicit browser target changed", () => {
7 expect(resolveBrowserRecordingStopTarget(new Set(["tab-recording"]), "tab-browsing")).toBe(
8 "tab-recording",
9 );
1010 });
1111
12 it("only stops an explicitly requested tab when it owns the recording", () => {
13 expect(resolveBrowserRecordingStopTarget("tab-a", "tab-a")).toBe("tab-a");
14 expect(resolveBrowserRecordingStopTarget("tab-a", "tab-b")).toBeNull();
12 it("prefers an implicit target that is actively recording", () => {
13 expect(
14 resolveBrowserRecordingStopTarget(
15 new Set(["tab-recording-a", "tab-recording-b"]),
16 "tab-recording-b",
17 ),
18 ).toBe("tab-recording-b");
19 });
20
21 it("does not guess when multiple recordings are active and the implicit target is not one", () => {
22 expect(
23 resolveBrowserRecordingStopTarget(
24 new Set(["tab-recording-a", "tab-recording-b"]),
25 "tab-browsing",
26 ),
27 ).toBeNull();
28 });
29
30 it("only stops an explicitly requested tab when that tab is recording", () => {
31 const activeTabIds = new Set(["tab-recording"]);
32 expect(resolveBrowserRecordingStopTarget(activeTabIds, "tab-browsing", "tab-recording")).toBe(
33 "tab-recording",
34 );
35 expect(resolveBrowserRecordingStopTarget(activeTabIds, "tab-recording", "tab-browsing")).toBe(
36 null,
37 );
38 });
39
40 it("returns null when no matching recording is active", () => {
41 expect(resolveBrowserRecordingStopTarget(new Set(), "tab-browsing")).toBeNull();
1542 });
1643});
apps/web/src/browser/browserRecordingScope.ts+11-4
......@@ -1,7 +1,14 @@
11export function resolveBrowserRecordingStopTarget(
2 activeTabId: string | null,
3 requestedTabId?: string,
2 activeTabIds: ReadonlySet<string>,
3 implicitTabId: string | null,
4 explicitTabId?: string,
45): string | null {
5 if (activeTabId === null) return null;
6 return requestedTabId === undefined || requestedTabId === activeTabId ? activeTabId : null;
6 if (explicitTabId !== undefined) {
7 return activeTabIds.has(explicitTabId) ? explicitTabId : null;
8 }
9 if (implicitTabId !== null && activeTabIds.has(implicitTabId)) {
10 return implicitTabId;
11 }
12 if (activeTabIds.size !== 1) return null;
13 return activeTabIds.values().next().value ?? null;
714}
apps/web/src/browser/browserSurfaceStore.test.ts+100-3
......@@ -11,6 +11,62 @@ describe("browserSurfaceStore", () => {
1111 useBrowserSurfaceStore.setState({ byTabId: {} });
1212 });
1313
14 it("freezes the source content dimensions for a fitted presentation", () => {
15 const tabId = "fitted-browser-surface";
16 const sourceOwner = Symbol("source");
17 const sourceContent = {
18 x: 10,
19 y: 20,
20 width: 1_280,
21 height: 720,
22 scale: 1,
23 scrollLeft: 0,
24 scrollTop: 0,
25 };
26 useBrowserSurfaceStore.getState().claim(tabId, sourceOwner, false);
27 useBrowserSurfaceStore.getState().presentContent(tabId, sourceContent);
28
29 const fittedLease = acquireBrowserSurface(tabId, true);
30 useBrowserSurfaceStore.getState().presentContent(tabId, {
31 ...sourceContent,
32 width: 360,
33 height: 203,
34 scale: 0.28125,
35 });
36
37 expect(useBrowserSurfaceStore.getState().byTabId[tabId]?.fittedSourceContent).toEqual(
38 sourceContent,
39 );
40 fittedLease.release();
41 });
42
43 it("freezes the first content dimensions when fitting starts before the browser is measured", () => {
44 const tabId = "pending-fitted-browser-surface";
45 const fittedLease = acquireBrowserSurface(tabId, true);
46 const sourceContent = {
47 x: 0,
48 y: 0,
49 width: 1_280,
50 height: 720,
51 scale: 1,
52 scrollLeft: 0,
53 scrollTop: 0,
54 };
55
56 useBrowserSurfaceStore.getState().presentContent(tabId, sourceContent);
57 useBrowserSurfaceStore.getState().presentContent(tabId, {
58 ...sourceContent,
59 width: 320,
60 height: 180,
61 scale: 0.25,
62 });
63
64 expect(useBrowserSurfaceStore.getState().byTabId[tabId]?.fittedSourceContent).toEqual(
65 sourceContent,
66 );
67 fittedLease.release();
68 });
69
1470 it("tracks content dimensions for a browser that has never been visible", () => {
1571 const tabId = "hidden-browser-surface-content-test";
1672 useBrowserSurfaceStore.getState().presentContent(tabId, {
......@@ -36,8 +92,26 @@ describe("browserSurfaceStore", () => {
3692 expect(
3793 resolveBrowserSurfacePanelRect(
3894 {
39 hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1, owner: null },
40 active: { rect: liveRect, visible: true, content: null, updatedAt: 2, owner: null },
95 hidden: {
96 rect: staleRect,
97 visible: false,
98 content: null,
99 fittedSourceContent: null,
100 fitSourceContent: false,
101 cornerRadius: 0,
102 updatedAt: 1,
103 owner: null,
104 },
105 active: {
106 rect: liveRect,
107 visible: true,
108 content: null,
109 fittedSourceContent: null,
110 fitSourceContent: false,
111 cornerRadius: 0,
112 updatedAt: 2,
113 owner: null,
114 },
41115 },
42116 "hidden",
43117 ),
......@@ -53,7 +127,7 @@ describe("browserSurfaceStore", () => {
53127
54128 const liveLease = acquireBrowserSurface(tabId);
55129 liveLease.present(liveRect, true);
56 staleLease.present(staleRect, true);
130 expect(staleLease.present(staleRect, true)).toBe(false);
57131 staleLease.release();
58132
59133 expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({
......@@ -75,4 +149,27 @@ describe("browserSurfaceStore", () => {
75149 owner: null,
76150 });
77151 });
152
153 it("clears fitted presentation state when its lease is released", () => {
154 const tabId = "released-fitted-browser-surface";
155 const fittedLease = acquireBrowserSurface(tabId, true);
156 useBrowserSurfaceStore.getState().presentContent(tabId, {
157 x: 0,
158 y: 0,
159 width: 1_280,
160 height: 800,
161 scale: 1,
162 scrollLeft: 0,
163 scrollTop: 0,
164 });
165
166 fittedLease.release();
167
168 expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({
169 fittedSourceContent: null,
170 fitSourceContent: false,
171 owner: null,
172 visible: false,
173 });
174 });
78175});
apps/web/src/browser/browserSurfaceStore.ts+50-13
......@@ -11,6 +11,9 @@ export interface BrowserSurfacePresentation {
1111 readonly rect: BrowserSurfaceRect | null;
1212 readonly visible: boolean;
1313 readonly content: BrowserSurfaceContentPresentation | null;
14 readonly fittedSourceContent: BrowserSurfaceContentPresentation | null;
15 readonly fitSourceContent: boolean;
16 readonly cornerRadius: number;
1417 readonly updatedAt: number;
1518 readonly owner: symbol | null;
1619}
......@@ -27,19 +30,20 @@ export interface BrowserSurfaceContentPresentation {
2730
2831interface BrowserSurfaceStoreState {
2932 readonly byTabId: Record<string, BrowserSurfacePresentation>;
30 readonly claim: (tabId: string, owner: symbol) => void;
33 readonly claim: (tabId: string, owner: symbol, fitSourceContent: boolean) => void;
3134 readonly present: (
3235 tabId: string,
3336 owner: symbol,
3437 rect: BrowserSurfaceRect,
3538 visible: boolean,
39 cornerRadius: number,
3640 ) => void;
3741 readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void;
3842 readonly release: (tabId: string, owner: symbol) => void;
3943}
4044
4145export interface BrowserSurfaceLease {
42 readonly present: (rect: BrowserSurfaceRect, visible: boolean) => void;
46 readonly present: (rect: BrowserSurfaceRect, visible: boolean, cornerRadius?: number) => boolean;
4347 readonly release: () => void;
4448}
4549
......@@ -72,7 +76,7 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect):
7276
7377export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({
7478 byTabId: {},
75 claim: (tabId, owner) =>
79 claim: (tabId, owner, fitSourceContent) =>
7680 set((state) => {
7781 const current = state.byTabId[tabId];
7882 if (current?.owner === owner) return state;
......@@ -83,21 +87,31 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =
8387 rect: current?.rect ?? null,
8488 visible: false,
8589 content: current?.content ?? null,
90 fittedSourceContent: fitSourceContent ? (current?.content ?? null) : null,
91 fitSourceContent,
92 cornerRadius: current?.cornerRadius ?? 0,
8693 updatedAt: Date.now(),
8794 owner,
8895 },
8996 },
9097 };
9198 }),
92 present: (tabId, owner, rect, visible) =>
99 present: (tabId, owner, rect, visible, cornerRadius) =>
93100 set((state) => {
94101 const current = state.byTabId[tabId];
95102 if (current?.owner !== owner) return state;
96 if (current && current.visible === visible && rectEquals(current.rect, rect)) return state;
103 if (
104 current &&
105 current.visible === visible &&
106 current.cornerRadius === cornerRadius &&
107 rectEquals(current.rect, rect)
108 ) {
109 return state;
110 }
97111 return {
98112 byTabId: {
99113 ...state.byTabId,
100 [tabId]: { ...current, rect, visible, updatedAt: Date.now() },
114 [tabId]: { ...current, rect, visible, cornerRadius, updatedAt: Date.now() },
101115 },
102116 };
103117 }),
......@@ -112,6 +126,9 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =
112126 rect: null,
113127 visible: false,
114128 content,
129 fittedSourceContent: null,
130 fitSourceContent: false,
131 cornerRadius: 0,
115132 updatedAt: Date.now(),
116133 owner: null,
117134 },
......@@ -134,7 +151,15 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =
134151 return {
135152 byTabId: {
136153 ...state.byTabId,
137 [tabId]: { ...current, content, updatedAt: Date.now() },
154 [tabId]: {
155 ...current,
156 content,
157 fittedSourceContent:
158 current.fitSourceContent && current.fittedSourceContent === null
159 ? content
160 : current.fittedSourceContent,
161 updatedAt: Date.now(),
162 },
138163 },
139164 };
140165 }),
......@@ -145,21 +170,33 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) =
145170 return {
146171 byTabId: {
147172 ...state.byTabId,
148 [tabId]: { ...current, visible: false, updatedAt: Date.now(), owner: null },
173 [tabId]: {
174 ...current,
175 visible: false,
176 fittedSourceContent: null,
177 fitSourceContent: false,
178 updatedAt: Date.now(),
179 owner: null,
180 },
149181 },
150182 };
151183 }),
152184}));
153185
154export function acquireBrowserSurface(tabId: string): BrowserSurfaceLease {
186export function acquireBrowserSurface(
187 tabId: string,
188 fitSourceContent = false,
189): BrowserSurfaceLease {
155190 const owner = Symbol(`browser-surface:${tabId}`);
156191 let released = false;
157 useBrowserSurfaceStore.getState().claim(tabId, owner);
192 useBrowserSurfaceStore.getState().claim(tabId, owner, fitSourceContent);
158193
159194 return {
160 present: (rect, visible) => {
161 if (released) return;
162 useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible);
195 present: (rect, visible, cornerRadius = 0) => {
196 if (released) return false;
197 if (useBrowserSurfaceStore.getState().byTabId[tabId]?.owner !== owner) return false;
198 useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius);
199 return true;
163200 },
164201 release: () => {
165202 if (released) return;
apps/web/src/browser/desktopTabLifetime.test.ts+64-3
......@@ -1,20 +1,32 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
22
3const { closeTab, createTab } = vi.hoisted(() => ({
4 closeTab: vi.fn(async () => undefined),
3const { closeTab, createTab, stopBrowserRecording } = vi.hoisted(() => ({
4 closeTab: vi.fn<(tabId: string) => Promise<void>>(async () => undefined),
55 createTab: vi.fn<() => Promise<void>>(),
6 stopBrowserRecording: vi.fn(async () => null),
67}));
78
89vi.mock("~/components/preview/previewBridge", () => ({
910 previewBridge: { closeTab, createTab },
1011}));
1112
13vi.mock("./browserRecording", () => ({
14 stopBrowserRecording,
15}));
16
1217import { acquireDesktopTab } from "./desktopTabLifetime";
1318
1419describe("desktopTabLifetime", () => {
1520 beforeEach(() => {
1621 closeTab.mockClear();
1722 createTab.mockClear();
23 stopBrowserRecording.mockClear();
24 vi.stubGlobal("window", globalThis);
25 });
26
27 afterEach(() => {
28 vi.useRealTimers();
29 vi.unstubAllGlobals();
1830 });
1931
2032 it("shares tab creation readiness across concurrent leases", async () => {
......@@ -42,4 +54,53 @@ describe("desktopTabLifetime", () => {
4254 await first.ready;
4355 expect(ready).toBe(true);
4456 });
57
58 it("stops recording before closing the final desktop tab lease", async () => {
59 vi.useFakeTimers();
60 let resolveStop: (() => void) | undefined;
61 stopBrowserRecording.mockReturnValueOnce(
62 new Promise<null>((resolve) => {
63 resolveStop = () => resolve(null);
64 }),
65 );
66 createTab.mockResolvedValueOnce(undefined);
67
68 const lease = acquireDesktopTab("tab_recording_cleanup");
69 await lease.ready;
70 lease.release();
71 await vi.advanceTimersByTimeAsync(0);
72
73 expect(stopBrowserRecording).toHaveBeenCalledWith("tab_recording_cleanup");
74 expect(closeTab).not.toHaveBeenCalled();
75
76 resolveStop?.();
77 await Promise.resolve();
78 await Promise.resolve();
79 expect(closeTab).toHaveBeenCalledWith("tab_recording_cleanup");
80 });
81
82 it("waits for an in-flight close before recreating a reacquired tab", async () => {
83 vi.useFakeTimers();
84 let resolveClose: (() => void) | undefined;
85 createTab.mockResolvedValue(undefined);
86 closeTab.mockReturnValueOnce(
87 new Promise<void>((resolve) => {
88 resolveClose = resolve;
89 }),
90 );
91
92 const initial = acquireDesktopTab("tab_close_reacquire");
93 await initial.ready;
94 initial.release();
95 await vi.advanceTimersByTimeAsync(0);
96
97 expect(closeTab).toHaveBeenCalledWith("tab_close_reacquire");
98
99 const reacquired = acquireDesktopTab("tab_close_reacquire");
100 expect(createTab).toHaveBeenCalledTimes(1);
101
102 resolveClose?.();
103 await reacquired.ready;
104 expect(createTab).toHaveBeenCalledTimes(2);
105 });
45106});
apps/web/src/browser/desktopTabLifetime.ts+27-2
......@@ -1,5 +1,7 @@
11import { previewBridge } from "~/components/preview/previewBridge";
22
3import { stopBrowserRecording } from "./browserRecording";
4
35interface DesktopTabLease {
46 references: number;
57 closeTimer: number | null;
......@@ -7,6 +9,26 @@ interface DesktopTabLease {
79}
810
911const leases = new Map<string, DesktopTabLease>();
12const pendingTabOperations = new Map<string, Promise<void>>();
13
14const enqueueDesktopTabOperation = (
15 tabId: string,
16 operation: () => Promise<void> | void,
17): Promise<void> => {
18 const previous = pendingTabOperations.get(tabId);
19 const pending = previous
20 ? previous.catch(() => undefined).then(operation)
21 : Promise.resolve(operation());
22 pendingTabOperations.set(tabId, pending);
23 void pending
24 .finally(() => {
25 if (pendingTabOperations.get(tabId) === pending) {
26 pendingTabOperations.delete(tabId);
27 }
28 })
29 .catch(() => undefined);
30 return pending;
31};
1032
1133export interface AcquiredDesktopTab {
1234 readonly ready: Promise<void>;
......@@ -19,7 +41,7 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab {
1941 ({
2042 references: 0,
2143 closeTimer: null,
22 ready: previewBridge?.createTab(tabId) ?? Promise.resolve(),
44 ready: enqueueDesktopTabOperation(tabId, () => previewBridge?.createTab(tabId)),
2345 } satisfies DesktopTabLease);
2446 if (current.closeTimer !== null) window.clearTimeout(current.closeTimer);
2547 current.references += 1;
......@@ -37,7 +59,10 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab {
3759 const latest = leases.get(tabId);
3860 if (!latest || latest.references > 0) return;
3961 leases.delete(tabId);
40 void previewBridge?.closeTab(tabId);
62 void enqueueDesktopTabOperation(tabId, async () => {
63 await stopBrowserRecording(tabId).catch(() => null);
64 await previewBridge?.closeTab(tabId);
65 }).catch(() => undefined);
4166 }, 0);
4267 },
4368 };
apps/web/src/browser/hostedBrowserWebviewStyle.test.ts+17
......@@ -23,6 +23,23 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => {
2323 });
2424 });
2525
26 it("clips a floating webview to the mini-player frame", () => {
27 expect(
28 resolveHostedBrowserWebviewWrapperStyle({
29 active: true,
30 cornerRadius: 12,
31 rect: { x: 12, y: 34, width: 360, height: 203 },
32 hiddenSize: { width: 1280, height: 800 },
33 }),
34 ).toMatchObject({
35 left: 12,
36 top: 34,
37 width: 360,
38 height: 203,
39 borderRadius: 12,
40 });
41 });
42
2643 it("keeps an inactive webview paintable while moving it offscreen", () => {
2744 const style = resolveHostedBrowserWebviewWrapperStyle({
2845 active: false,
apps/web/src/browser/hostedBrowserWebviewStyle.ts+4-1
......@@ -12,6 +12,7 @@ export interface HostedBrowserWebviewWrapperStyle {
1212 readonly height: number;
1313 readonly zIndex: number;
1414 readonly pointerEvents: "auto" | "none";
15 readonly borderRadius?: number;
1516 readonly visibility?: "visible";
1617}
1718
......@@ -19,10 +20,11 @@ export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000;
1920
2021export function resolveHostedBrowserWebviewWrapperStyle(input: {
2122 readonly active: boolean;
23 readonly cornerRadius?: number;
2224 readonly rect: BrowserSurfaceRect | null;
2325 readonly hiddenSize: HostedBrowserWebviewSize;
2426}): HostedBrowserWebviewWrapperStyle {
25 const { active, hiddenSize, rect } = input;
27 const { active, cornerRadius = 0, hiddenSize, rect } = input;
2628 if (active && rect) {
2729 return {
2830 left: rect.x,
......@@ -31,6 +33,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: {
3133 height: rect.height,
3234 zIndex: 30,
3335 pointerEvents: "auto",
36 ...(cornerRadius > 0 ? { borderRadius: cornerRadius } : {}),
3437 };
3538 }
3639
apps/web/src/browser/webviewCrashRecovery.test.ts created+41
......@@ -0,0 +1,41 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 INITIAL_WEBVIEW_CRASH_RECOVERY_STATE,
5 planWebviewCrashRecovery,
6 WEBVIEW_CRASH_RECOVERY_WINDOW_MS,
7} from "./webviewCrashRecovery";
8
9describe("planWebviewCrashRecovery", () => {
10 it("backs off and stops after a bounded number of rapid crashes", () => {
11 const first = planWebviewCrashRecovery(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, 1_000);
12 expect(first).not.toBeNull();
13 expect(first?.delayMs).toBe(250);
14
15 const second = planWebviewCrashRecovery(first!.state, 1_100);
16 expect(second).not.toBeNull();
17 expect(second?.delayMs).toBe(500);
18
19 const third = planWebviewCrashRecovery(second!.state, 1_200);
20 expect(third).not.toBeNull();
21 expect(third?.delayMs).toBe(1_000);
22
23 expect(planWebviewCrashRecovery(third!.state, 1_300)).toBeNull();
24 });
25
26 it("allows recovery again after the crash window expires", () => {
27 const first = planWebviewCrashRecovery(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, 1_000)!;
28 const second = planWebviewCrashRecovery(first.state, 1_100)!;
29 const third = planWebviewCrashRecovery(second.state, 1_200)!;
30
31 expect(planWebviewCrashRecovery(third.state, 1_000 + WEBVIEW_CRASH_RECOVERY_WINDOW_MS)).toEqual(
32 {
33 delayMs: 250,
34 state: {
35 attempts: 1,
36 windowStartedAt: 1_000 + WEBVIEW_CRASH_RECOVERY_WINDOW_MS,
37 },
38 },
39 );
40 });
41});
apps/web/src/browser/webviewCrashRecovery.ts created+38
......@@ -0,0 +1,38 @@
1export const WEBVIEW_CRASH_RECOVERY_WINDOW_MS = 30_000;
2export const WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS = 3;
3export const WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS = 250;
4
5export interface WebviewCrashRecoveryState {
6 readonly attempts: number;
7 readonly windowStartedAt: number | null;
8}
9
10export interface WebviewCrashRecoveryPlan {
11 readonly delayMs: number;
12 readonly state: WebviewCrashRecoveryState;
13}
14
15export const INITIAL_WEBVIEW_CRASH_RECOVERY_STATE: WebviewCrashRecoveryState = {
16 attempts: 0,
17 windowStartedAt: null,
18};
19
20export function planWebviewCrashRecovery(
21 state: WebviewCrashRecoveryState,
22 now: number,
23): WebviewCrashRecoveryPlan | null {
24 const startsNewWindow =
25 state.windowStartedAt === null ||
26 now - state.windowStartedAt >= WEBVIEW_CRASH_RECOVERY_WINDOW_MS;
27 const attempts = startsNewWindow ? 0 : state.attempts;
28 if (attempts >= WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS) return null;
29
30 const nextAttempts = attempts + 1;
31 return {
32 delayMs: WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS * 2 ** attempts,
33 state: {
34 attempts: nextAttempts,
35 windowStartedAt: startsNewWindow ? now : state.windowStartedAt,
36 },
37 };
38}
apps/web/src/components/ChatView.tsx+35
......@@ -131,8 +131,13 @@ import {
131131} from "../previewStateStore";
132132import { addBrowserSurface } from "./preview/addBrowserSurface";
133133import { closePreviewSession } from "./preview/closePreviewSession";
134import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
134135import { subscribePreviewAction } from "./preview/previewActionBus";
135136import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
137import {
138 selectThreadPreviewMiniPlayer,
139 usePreviewMiniPlayerStore,
140} from "../previewMiniPlayerStore";
136141import { RightPanelTabs } from "./RightPanelTabs";
137142import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
138143import { BranchToolbar } from "./BranchToolbar";
......@@ -1484,6 +1489,9 @@ function ChatViewContent(props: ChatViewProps) {
14841489 const activeFileSurface =
14851490 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;
14861491 const activePreviewState = useThreadPreviewState(activeThreadRef);
1492 const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) =>
1493 selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef),
1494 );
14871495 const panelTerminalIds = useMemo(
14881496 () =>
14891497 new Set(
......@@ -1507,6 +1515,24 @@ function ChatViewContent(props: ChatViewProps) {
15071515 .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions));
15081516 }, [activePreviewState.sessions, activeThreadRef]);
15091517
1518 useEffect(() => {
1519 if (!activeThreadRef || !activePreviewMiniPlayer) return;
1520 const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]);
1521 const sameTabOpenInPanel =
1522 previewPanelOpen &&
1523 activeRightPanelSurface?.kind === "preview" &&
1524 activeRightPanelSurface.resourceId === activePreviewMiniPlayer.tabId;
1525 if (!miniTabStillExists || sameTabOpenInPanel) {
1526 usePreviewMiniPlayerStore.getState().close(activeThreadRef);
1527 }
1528 }, [
1529 activePreviewMiniPlayer,
1530 activePreviewState.sessions,
1531 activeRightPanelSurface,
1532 activeThreadRef,
1533 previewPanelOpen,
1534 ]);
1535
15101536 const planSidebarOpen = activeRightPanelKind === "plan";
15111537
15121538 const existingOpenTerminalThreadKeys = useMemo(() => {
......@@ -5907,6 +5933,15 @@ function ChatViewContent(props: ChatViewProps) {
59075933 </div>
59085934 </div>
59095935
5936 {activeThreadRef && activePreviewMiniPlayer ? (
5937 <ThreadPreviewMiniPlayer
5938 key={`${activeThreadKey}:${activePreviewMiniPlayer.tabId}`}
5939 threadRef={activeThreadRef}
5940 tabId={activePreviewMiniPlayer.tabId}
5941 bottomInset={isDraftHeroState ? 0 : composerOverlayHeight}
5942 />
5943 ) : null}
5944
59105945 <AlertDialog open={branchRestoreConfirmOpen} onOpenChange={setBranchRestoreConfirmOpen}>
59115946 <AlertDialogPopup>
59125947 <AlertDialogHeader>
apps/web/src/components/preview/PreviewAutomationHosts.tsx+14-9
......@@ -29,10 +29,10 @@ import {
2929 reconcilePreviewServerSessions,
3030 updatePreviewServerSnapshot,
3131} from "~/previewStateStore";
32import { useRightPanelStore } from "~/rightPanelStore";
32import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
3333import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver";
3434import {
35 readActiveBrowserRecordingTabId,
35 readActiveBrowserRecordingTabIds,
3636 startBrowserRecording,
3737 stopBrowserRecording,
3838} from "~/browser/browserRecording";
......@@ -53,7 +53,10 @@ import {
5353 PreviewAutomationTargetUnavailableError,
5454 PreviewAutomationViewportTimeoutError,
5555} from "./previewAutomationErrors";
56import { previewAutomationOpenNeedsOverlay } from "./previewAutomationOpenReadiness";
56import {
57 previewAutomationOpenNeedsOverlay,
58 shouldOpenPreviewMiniPlayer,
59} from "./previewAutomationOpenReadiness";
5760import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer";
5861import { createPreviewAutomationClientId } from "./previewAutomationClientId";
5962import {
......@@ -319,7 +322,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
319322 if (result._tag === "Failure") {
320323 return raiseAtomCommandFailure(result);
321324 }
322 reconcilePreviewServerSessions(threadRef, result.value.sessions);
325 reconcilePreviewServerSessions(threadRef, result.value);
323326 state = readThreadPreviewState(threadRef);
324327 }
325328 tabId = request.tabId ?? state.snapshot?.tabId ?? null;
......@@ -378,8 +381,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
378381 activeSnapshot = snapshot;
379382 tabId = activeTabId;
380383 }
381 if (input.show ?? true) {
382 useRightPanelStore.getState().openBrowser(threadRef, activeTabId);
384 if (shouldOpenPreviewMiniPlayer(input)) {
385 usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId);
383386 }
384387 if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) {
385388 await waitForDesktopOverlay(
......@@ -510,7 +513,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
510513 }
511514 case "recordingStart": {
512515 const ready = await requireReadyTab();
513 const startedAt = await startBrowserRecording(ready.tabId);
516 const startedAt = await startBrowserRecording(ready.tabId, threadRef);
514517 return {
515518 tabId: ready.tabId,
516519 recording: true,
......@@ -518,11 +521,13 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
518521 };
519522 }
520523 case "recordingStop": {
521 const recordingTabId = readActiveBrowserRecordingTabId();
524 const activeTabIds = readActiveBrowserRecordingTabIds(threadRef);
522525 const stopTabId = resolveBrowserRecordingStopTarget(
523 recordingTabId,
526 activeTabIds,
527 tabId,
524528 request.tabIdExplicit ? request.tabId : undefined,
525529 );
530 tabId = stopTabId ?? tabId;
526531 const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null;
527532 if (!artifact) {
528533 return raisePreviewAutomationHostError(
apps/web/src/components/preview/PreviewChromeRow.tsx+31
......@@ -4,6 +4,7 @@ import {
44 Camera,
55 ExternalLink,
66 MousePointerClick,
7 PictureInPicture2,
78 RotateCw,
89} from "lucide-react";
910import {
......@@ -40,6 +41,9 @@ interface Props {
4041 onCapture?: ((record: boolean) => void) | undefined;
4142 captureDisabled?: boolean | undefined;
4243 recording?: boolean | undefined;
44 onPictureInPicture?: (() => void) | undefined;
45 pictureInPicture?: boolean | undefined;
46 pictureInPictureDisabled?: boolean | undefined;
4347 /**
4448 * When provided, renders an annotation-mode toggle button to the right of
4549 * the URL input. Pressed while annotation mode is active (button shows in `pressed`
......@@ -77,6 +81,9 @@ export function PreviewChromeRow({
7781 onCapture,
7882 captureDisabled,
7983 recording,
84 onPictureInPicture,
85 pictureInPicture,
86 pictureInPictureDisabled,
8087 onPickElement,
8188 pickActive,
8289 pickDisabled,
......@@ -274,6 +281,30 @@ export function PreviewChromeRow({
274281 </TooltipPopup>
275282 </Tooltip>
276283 ) : null}
284 {onPictureInPicture ? (
285 <Tooltip>
286 <TooltipTrigger
287 render={
288 <Button
289 variant={pictureInPicture ? "secondary" : "ghost"}
290 size="icon-xs"
291 onClick={onPictureInPicture}
292 aria-label={
293 pictureInPicture ? "Close floating preview" : "Float preview over chat"
294 }
295 aria-pressed={pictureInPicture ? "true" : "false"}
296 type="button"
297 disabled={pictureInPictureDisabled}
298 />
299 }
300 >
301 <PictureInPicture2 className={cn(pictureInPicture && "text-primary")} />
302 </TooltipTrigger>
303 <TooltipPopup>
304 {pictureInPicture ? "Close floating preview" : "Float preview over chat"}
305 </TooltipPopup>
306 </Tooltip>
307 ) : null}
277308 {trailingActions}
278309 </form>
279310 {loadProgress > 0 ? (
apps/web/src/components/preview/PreviewMoreMenu.tsx+11
......@@ -46,6 +46,10 @@ interface Props {
4646 deviceToolbarVisible: boolean;
4747 /** Switches between fill-panel mode and a fixed responsive viewport. */
4848 onToggleDeviceToolbar: () => void;
49 /** Whether the separate native always-on-top preview window is open. */
50 nativePictureInPicture: boolean;
51 /** Toggles the optional native always-on-top preview window. */
52 onNativePictureInPicture: () => void;
4953}
5054
5155/**
......@@ -60,6 +64,8 @@ export function PreviewMoreMenu({
6064 colorScheme,
6165 deviceToolbarVisible,
6266 onToggleDeviceToolbar,
67 nativePictureInPicture,
68 onNativePictureInPicture,
6369}: Props) {
6470 if (!previewBridge) return null;
6571 const bridge = previewBridge;
......@@ -93,6 +99,11 @@ export function PreviewMoreMenu({
9399 <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}>
94100 Open DevTools
95101 </MenuItem>
102 <MenuItem onClick={onNativePictureInPicture} disabled={tabDisabled}>
103 {nativePictureInPicture
104 ? "Close separate preview window"
105 : "Open separate preview window"}
106 </MenuItem>
96107 <MenuItem onClick={onToggleDeviceToolbar} disabled={tabDisabled}>
97108 {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"}
98109 </MenuItem>
apps/web/src/components/preview/PreviewView.test.tsx+125-4
......@@ -8,6 +8,16 @@ const mocks = vi.hoisted(() => ({
88 readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })),
99 submittedUrl: null as ((url: string) => void) | null,
1010 emptyStateUrl: null as ((url: string) => void) | null,
11 togglePictureInPicture: null as (() => void) | null,
12 toggleNativePictureInPicture: null as (() => void) | null,
13 pictureInPicturePressed: false,
14 miniPlayerTabId: null as string | null,
15 openMiniPlayer: vi.fn(),
16 closeMiniPlayer: vi.fn(),
17 closeRightPanel: vi.fn(),
18 openPictureInPicture: vi.fn(async (_tabId: string): Promise<void> => undefined),
19 closePictureInPicture: vi.fn(async (_tabId: string): Promise<void> => undefined),
20 pictureInPicture: false,
1121 showEmptyState: false,
1222}));
1323
......@@ -36,10 +46,12 @@ vi.mock("~/previewStateStore", () => ({
3646 activeTabId: "tab-1",
3747 desktopByTabId: {
3848 "tab-1": {
49 hasWebContents: true,
3950 canGoBack: false,
4051 canGoForward: false,
4152 loading: false,
4253 zoomFactor: 1,
54 pictureInPicture: mocks.pictureInPicture,
4355 colorScheme: "system",
4456 controller: "none",
4557 },
......@@ -80,7 +92,7 @@ vi.mock("~/state/use-atom-command", () => ({
8092vi.mock("~/browser/browserRecording", () => ({
8193 startBrowserRecording: vi.fn(),
8294 stopBrowserRecording: vi.fn(),
83 useActiveBrowserRecordingTabId: () => null,
95 useActiveBrowserRecordingTabIds: () => new Set(),
8496}));
8597
8698vi.mock("~/browser/browserSurfaceStore", () => ({
......@@ -89,18 +101,69 @@ vi.mock("~/browser/browserSurfaceStore", () => ({
89101 ) => select({ byTabId: {} }),
90102}));
91103
104vi.mock("~/previewMiniPlayerStore", () => {
105 const usePreviewMiniPlayerStore = Object.assign(
106 (select: (state: unknown) => unknown) =>
107 select({
108 byThreadKey: mocks.miniPlayerTabId
109 ? {
110 "environment-1:thread-1": {
111 tabId: mocks.miniPlayerTabId,
112 position: null,
113 },
114 }
115 : {},
116 }),
117 {
118 getState: () => ({
119 open: mocks.openMiniPlayer,
120 close: mocks.closeMiniPlayer,
121 }),
122 },
123 );
124 return {
125 selectThreadPreviewMiniPlayer: (
126 byThreadKey: Record<string, { tabId: string; position: null }>,
127 ) => byThreadKey["environment-1:thread-1"] ?? null,
128 usePreviewMiniPlayerStore,
129 };
130});
131
132vi.mock("~/rightPanelStore", () => ({
133 useRightPanelStore: {
134 getState: () => ({ close: mocks.closeRightPanel }),
135 },
136}));
137
92138vi.mock("~/components/ui/toast", () => ({
93139 stackedThreadToast: vi.fn(),
94140 toastManager: { add: vi.fn() },
95141}));
96142
97143vi.mock("./previewBridge", () => ({
98 previewBridge: { navigate: mocks.navigate },
144 previewBridge: {
145 navigate: mocks.navigate,
146 pictureInPicture: {
147 open: mocks.openPictureInPicture,
148 close: mocks.closePictureInPicture,
149 },
150 },
99151}));
100152
101153vi.mock("./PreviewChromeRow", () => ({
102 PreviewChromeRow: (props: { onSubmit: (url: string) => void }) => {
154 PreviewChromeRow: (props: {
155 onSubmit: (url: string) => void;
156 onPictureInPicture?: () => void;
157 pictureInPicture?: boolean;
158 trailingActions?: {
159 props: { onNativePictureInPicture?: () => void };
160 };
161 }) => {
103162 mocks.submittedUrl = props.onSubmit;
163 mocks.togglePictureInPicture = props.onPictureInPicture ?? null;
164 mocks.toggleNativePictureInPicture =
165 props.trailingActions?.props.onNativePictureInPicture ?? null;
166 mocks.pictureInPicturePressed = props.pictureInPicture ?? false;
104167 return null;
105168 },
106169}));
......@@ -111,7 +174,12 @@ vi.mock("./PreviewEmptyState", () => ({
111174 return null;
112175 },
113176}));
114vi.mock("./PreviewMoreMenu", () => ({ PreviewMoreMenu: () => null }));
177vi.mock("./PreviewMoreMenu", () => ({
178 PreviewMoreMenu: (props: { onNativePictureInPicture: () => void }) => {
179 mocks.toggleNativePictureInPicture = props.onNativePictureInPicture;
180 return null;
181 },
182}));
115183vi.mock("./PreviewUnreachable", () => ({ PreviewUnreachable: () => null }));
116184vi.mock("./ZoomIndicator", () => ({ ZoomIndicator: () => null }));
117185vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null }));
......@@ -128,6 +196,16 @@ describe("PreviewView navigation", () => {
128196 mocks.readPreparedConnection.mockClear();
129197 mocks.submittedUrl = null;
130198 mocks.emptyStateUrl = null;
199 mocks.togglePictureInPicture = null;
200 mocks.toggleNativePictureInPicture = null;
201 mocks.pictureInPicturePressed = false;
202 mocks.miniPlayerTabId = null;
203 mocks.openMiniPlayer.mockClear();
204 mocks.closeMiniPlayer.mockClear();
205 mocks.closeRightPanel.mockClear();
206 mocks.openPictureInPicture.mockClear();
207 mocks.closePictureInPicture.mockClear();
208 mocks.pictureInPicture = false;
131209 mocks.showEmptyState = false;
132210 });
133211
......@@ -192,4 +270,47 @@ describe("PreviewView navigation", () => {
192270 "http://172.25.85.75:5173/app?mode=test#top",
193271 );
194272 });
273
274 it("opens and closes a thread-scoped floating preview for the active tab", async () => {
275 const props = {
276 threadRef: {
277 environmentId: EnvironmentId.make("environment-1"),
278 threadId: ThreadId.make("thread-1"),
279 },
280 tabId: "tab-1",
281 visible: true,
282 } as const;
283
284 renderToStaticMarkup(<PreviewView {...props} />);
285 expect(mocks.pictureInPicturePressed).toBe(false);
286 mocks.togglePictureInPicture?.();
287 expect(mocks.openMiniPlayer).toHaveBeenCalledWith(props.threadRef, "tab-1");
288 expect(mocks.closeRightPanel).toHaveBeenCalledWith(props.threadRef);
289
290 mocks.miniPlayerTabId = "tab-1";
291 renderToStaticMarkup(<PreviewView {...props} />);
292 expect(mocks.pictureInPicturePressed).toBe(true);
293 mocks.togglePictureInPicture?.();
294 expect(mocks.closeMiniPlayer).toHaveBeenCalledWith(props.threadRef);
295 });
296
297 it("keeps the native preview window as a secondary action", async () => {
298 const props = {
299 threadRef: {
300 environmentId: EnvironmentId.make("environment-1"),
301 threadId: ThreadId.make("thread-1"),
302 },
303 tabId: "tab-1",
304 visible: true,
305 } as const;
306
307 renderToStaticMarkup(<PreviewView {...props} />);
308 mocks.toggleNativePictureInPicture?.();
309 await vi.waitFor(() => expect(mocks.openPictureInPicture).toHaveBeenCalledWith("tab-1"));
310
311 mocks.pictureInPicture = true;
312 renderToStaticMarkup(<PreviewView {...props} />);
313 mocks.toggleNativePictureInPicture?.();
314 await vi.waitFor(() => expect(mocks.closePictureInPicture).toHaveBeenCalledWith("tab-1"));
315 });
195316});
apps/web/src/components/preview/PreviewView.tsx+41-15
......@@ -22,6 +22,8 @@ import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
2222import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments";
2323import { previewEnvironment } from "~/state/preview";
2424import { useAtomCommand } from "~/state/use-atom-command";
25import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
26import { useRightPanelStore } from "~/rightPanelStore";
2527
2628import { previewBridge } from "./previewBridge";
2729import { subscribePreviewAction } from "./previewActionBus";
......@@ -47,7 +49,7 @@ import { AgentBrowserCursor } from "./AgentBrowserCursor";
4749import {
4850 startBrowserRecording,
4951 stopBrowserRecording,
50 useActiveBrowserRecordingTabId,
52 useActiveBrowserRecordingTabIds,
5153} from "~/browser/browserRecording";
5254import { stackedThreadToast, toastManager } from "~/components/ui/toast";
5355
......@@ -67,10 +69,13 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi();
6769export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) {
6870 const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined);
6971 const [pickActive, setPickActive] = useState(false);
70 const activeRecordingTabId = useActiveBrowserRecordingTabId();
72 const activeRecordingTabIds = useActiveBrowserRecordingTabIds();
7173 const pickActiveRef = useRef(false);
7274 const isMountedRef = useRef(true);
7375 const previewState = useThreadPreviewState(threadRef);
76 const miniPlayer = usePreviewMiniPlayerStore((state) =>
77 selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef),
78 );
7479 const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation);
7580 const addImage = useComposerDraftStore((store) => store.addImage);
7681 const environment = useEnvironment(threadRef.environmentId);
......@@ -227,11 +232,35 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
227232 void localApi.shell.openExternal(url).catch(() => undefined);
228233 }, [url]);
229234
235 const handlePictureInPicture = useCallback(() => {
236 if (!tabId) return;
237 if (miniPlayer?.tabId === tabId) {
238 usePreviewMiniPlayerStore.getState().close(threadRef);
239 return;
240 }
241 usePreviewMiniPlayerStore.getState().open(threadRef, tabId);
242 useRightPanelStore.getState().close(threadRef);
243 }, [miniPlayer?.tabId, tabId, threadRef]);
244
245 const handleNativePictureInPicture = useCallback(() => {
246 if (!previewBridge || !tabId) return;
247 const operation = desktopOverlay?.pictureInPicture
248 ? previewBridge.pictureInPicture.close
249 : previewBridge.pictureInPicture.open;
250 void operation(tabId).catch((error) => {
251 toastManager.add({
252 type: "error",
253 title: "Unable to update popped-out preview",
254 description: error instanceof Error ? error.message : "An error occurred.",
255 });
256 });
257 }, [desktopOverlay?.pictureInPicture, tabId]);
258
230259 const handleCapture = useCallback(
231260 (record: boolean) => {
232261 if (!previewBridge || !tabId) return;
233262 const bridge = previewBridge;
234 const recordingThisTab = activeRecordingTabId === tabId;
263 const recordingThisTab = activeRecordingTabIds.has(tabId);
235264 if (recordingThisTab) {
236265 void stopBrowserRecording(tabId).then(
237266 (artifact) => {
......@@ -325,15 +354,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
325354 return;
326355 }
327356 if (record) {
328 if (activeRecordingTabId !== null) {
329 toastManager.add({
330 type: "warning",
331 title: "Another preview is recording",
332 description: "Stop the active recording before starting a new one.",
333 });
334 return;
335 }
336 void startBrowserRecording(tabId).catch((error) => {
357 void startBrowserRecording(tabId, threadRef).catch((error) => {
337358 toastManager.add({
338359 type: "error",
339360 title: "Unable to start recording",
......@@ -472,7 +493,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
472493 },
473494 );
474495 },
475 [activeRecordingTabId, tabId],
496 [activeRecordingTabIds, tabId, threadRef],
476497 );
477498
478499 const handlePickElement = useCallback(() => {
......@@ -594,7 +615,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
594615 onOpenInBrowser={tabId ? handleOpenInBrowser : undefined}
595616 onCapture={previewBridge && tabId ? handleCapture : undefined}
596617 captureDisabled={!desktopOverlay || isUnreachable}
597 recording={tabId !== null && activeRecordingTabId === tabId}
618 recording={tabId !== null && activeRecordingTabIds.has(tabId)}
619 onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined}
620 pictureInPicture={miniPlayer?.tabId === tabId}
621 pictureInPictureDisabled={!desktopOverlay?.hasWebContents || isUnreachable}
598622 onPickElement={previewBridge && tabId ? handlePickElement : undefined}
599623 pickActive={pickActive}
600624 // Disable when there's no tab (nothing to pick on) OR the page
......@@ -608,11 +632,13 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
608632 previewBridge ? (
609633 <PreviewMoreMenu
610634 tabId={tabId}
611 hasWebContents={desktopOverlay !== null}
635 hasWebContents={desktopOverlay?.hasWebContents ?? false}
612636 zoomFactor={desktopOverlay?.zoomFactor ?? 1}
613637 colorScheme={desktopOverlay?.colorScheme ?? "system"}
614638 deviceToolbarVisible={viewport._tag !== "fill"}
615639 onToggleDeviceToolbar={handleToggleDeviceToolbar}
640 nativePictureInPicture={desktopOverlay?.pictureInPicture ?? false}
641 onNativePictureInPicture={handleNativePictureInPicture}
616642 />
617643 ) : null
618644 }
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx created+313
......@@ -0,0 +1,313 @@
1"use client";
2
3import type { ScopedThreadRef } from "@t3tools/contracts";
4import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react";
5import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef } from "react";
6
7import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
8import { Button } from "~/components/ui/button";
9import { toastManager } from "~/components/ui/toast";
10import { useThreadPreviewState } from "~/previewStateStore";
11import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
12import { useRightPanelStore } from "~/rightPanelStore";
13
14import { previewBridge } from "./previewBridge";
15import {
16 clampPreviewMiniPlayerPosition,
17 clampPreviewMiniPlayerSize,
18 PREVIEW_MINI_PLAYER_DEFAULT_SIZE,
19} from "./previewMiniPlayerLayout";
20
21interface DragState {
22 readonly pointerId: number;
23 readonly pointerX: number;
24 readonly pointerY: number;
25 readonly playerX: number;
26 readonly playerY: number;
27}
28
29interface ResizeState {
30 readonly pointerId: number;
31 readonly pointerX: number;
32 readonly pointerY: number;
33 readonly width: number;
34 readonly height: number;
35}
36
37interface Props {
38 readonly threadRef: ScopedThreadRef;
39 readonly tabId: string;
40 readonly bottomInset: number;
41}
42
43export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props) {
44 const rootRef = useRef<HTMLElement | null>(null);
45 const dragRef = useRef<DragState | null>(null);
46 const resizeRef = useRef<ResizeState | null>(null);
47 const miniPlayer = usePreviewMiniPlayerStore((state) =>
48 selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef),
49 );
50 const previewState = useThreadPreviewState(threadRef);
51 const snapshot = previewState.sessions[tabId] ?? null;
52 const desktopOverlay = previewState.desktopByTabId[tabId] ?? null;
53 const position = miniPlayer?.tabId === tabId ? miniPlayer.position : null;
54 const size =
55 miniPlayer?.tabId === tabId && miniPlayer.size
56 ? miniPlayer.size
57 : PREVIEW_MINI_PLAYER_DEFAULT_SIZE;
58 const close = () => {
59 usePreviewMiniPlayerStore.getState().close(threadRef);
60 };
61
62 const openInPanel = () => {
63 usePreviewMiniPlayerStore.getState().close(threadRef);
64 useRightPanelStore.getState().openBrowser(threadRef, tabId);
65 };
66
67 const toggleNativePictureInPicture = () => {
68 if (!previewBridge) return;
69 const operation = desktopOverlay?.pictureInPicture
70 ? previewBridge.pictureInPicture.close
71 : previewBridge.pictureInPicture.open;
72 void operation(tabId).catch((error) => {
73 toastManager.add({
74 type: "error",
75 title: "Unable to update popped-out preview",
76 description: error instanceof Error ? error.message : "An error occurred.",
77 });
78 });
79 };
80
81 useLayoutEffect(() => {
82 const clampAndMove = () => {
83 const root = rootRef.current;
84 const parent = root?.offsetParent;
85 if (!root || !(parent instanceof HTMLElement)) return;
86 const nextSize = clampPreviewMiniPlayerSize(
87 { width: root.offsetWidth, height: root.offsetHeight },
88 { width: parent.clientWidth, height: parent.clientHeight },
89 bottomInset,
90 );
91 usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize);
92 const next = clampPreviewMiniPlayerPosition(
93 position ?? { x: root.offsetLeft, y: root.offsetTop },
94 { width: parent.clientWidth, height: parent.clientHeight },
95 nextSize,
96 bottomInset,
97 );
98 usePreviewMiniPlayerStore.getState().move(threadRef, tabId, next);
99 };
100 clampAndMove();
101 const root = rootRef.current;
102 const parent = root?.offsetParent;
103 if (!root || !(parent instanceof HTMLElement) || typeof ResizeObserver === "undefined") {
104 return;
105 }
106 const observer = new ResizeObserver(clampAndMove);
107 observer.observe(root);
108 observer.observe(parent);
109 return () => observer.disconnect();
110 }, [bottomInset, position, tabId, threadRef]);
111
112 const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
113 if (event.button !== 0) return;
114 const root = rootRef.current;
115 const parent = root?.offsetParent;
116 if (!root || !(parent instanceof HTMLElement)) return;
117 const rootRect = root.getBoundingClientRect();
118 const parentRect = parent.getBoundingClientRect();
119 dragRef.current = {
120 pointerId: event.pointerId,
121 pointerX: event.clientX,
122 pointerY: event.clientY,
123 playerX: rootRect.left - parentRect.left,
124 playerY: rootRect.top - parentRect.top,
125 };
126 event.currentTarget.setPointerCapture(event.pointerId);
127 event.preventDefault();
128 };
129
130 const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
131 const drag = dragRef.current;
132 const root = rootRef.current;
133 const parent = root?.offsetParent;
134 if (!drag || drag.pointerId !== event.pointerId || !root || !(parent instanceof HTMLElement)) {
135 return;
136 }
137 const next = clampPreviewMiniPlayerPosition(
138 {
139 x: drag.playerX + event.clientX - drag.pointerX,
140 y: drag.playerY + event.clientY - drag.pointerY,
141 },
142 { width: parent.clientWidth, height: parent.clientHeight },
143 { width: root.offsetWidth, height: root.offsetHeight },
144 bottomInset,
145 );
146 usePreviewMiniPlayerStore.getState().move(threadRef, tabId, next);
147 };
148
149 const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
150 if (dragRef.current?.pointerId !== event.pointerId) return;
151 dragRef.current = null;
152 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
153 event.currentTarget.releasePointerCapture(event.pointerId);
154 }
155 };
156
157 const handleResizePointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
158 if (event.button !== 0) return;
159 const root = rootRef.current;
160 if (!root) return;
161 resizeRef.current = {
162 pointerId: event.pointerId,
163 pointerX: event.clientX,
164 pointerY: event.clientY,
165 width: root.offsetWidth,
166 height: root.offsetHeight,
167 };
168 event.currentTarget.setPointerCapture(event.pointerId);
169 event.preventDefault();
170 event.stopPropagation();
171 };
172
173 const handleResizePointerMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
174 const resize = resizeRef.current;
175 const root = rootRef.current;
176 const parent = root?.offsetParent;
177 if (
178 !resize ||
179 resize.pointerId !== event.pointerId ||
180 !root ||
181 !(parent instanceof HTMLElement)
182 ) {
183 return;
184 }
185 const nextSize = clampPreviewMiniPlayerSize(
186 {
187 width: resize.width + event.clientX - resize.pointerX,
188 height: resize.height + event.clientY - resize.pointerY,
189 },
190 { width: parent.clientWidth, height: parent.clientHeight },
191 bottomInset,
192 );
193 usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize);
194 const nextPosition = clampPreviewMiniPlayerPosition(
195 position ?? { x: root.offsetLeft, y: root.offsetTop },
196 { width: parent.clientWidth, height: parent.clientHeight },
197 nextSize,
198 bottomInset,
199 );
200 usePreviewMiniPlayerStore.getState().move(threadRef, tabId, nextPosition);
201 };
202
203 const endResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
204 if (resizeRef.current?.pointerId !== event.pointerId) return;
205 resizeRef.current = null;
206 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
207 event.currentTarget.releasePointerCapture(event.pointerId);
208 }
209 };
210
211 if (!snapshot || miniPlayer?.tabId !== tabId) return null;
212
213 return (
214 <section
215 ref={rootRef}
216 aria-label="Floating browser preview"
217 data-preview-mini-player={tabId}
218 className="pointer-events-none absolute select-none"
219 style={
220 position
221 ? { left: position.x, top: position.y, width: size.width, height: size.height }
222 : {
223 right: 16,
224 top: 16,
225 width: size.width,
226 height: size.height,
227 }
228 }
229 >
230 <div className="group pointer-events-auto absolute right-2 top-2 z-[34] size-3">
231 <div
232 aria-hidden="true"
233 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"
234 />
235 <div
236 className="pointer-events-none absolute right-0 top-0 flex h-8 cursor-grab items-center gap-0.5 rounded-lg border border-border/80 bg-popover/92 p-0.5 opacity-0 shadow-lg/20 backdrop-blur-xl transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 active:cursor-grabbing"
237 onPointerDown={handlePointerDown}
238 onPointerMove={handlePointerMove}
239 onPointerUp={endDrag}
240 onPointerCancel={endDrag}
241 >
242 <Button
243 variant="ghost"
244 size="icon-xs"
245 aria-label="Open preview in right panel"
246 title="Open in right panel"
247 onPointerDown={(event) => event.stopPropagation()}
248 onClick={openInPanel}
249 >
250 <PanelRightIcon />
251 </Button>
252 <Button
253 variant={desktopOverlay?.pictureInPicture ? "secondary" : "ghost"}
254 size="icon-xs"
255 aria-label={
256 desktopOverlay?.pictureInPicture
257 ? "Close popped-out preview"
258 : "Pop preview into separate window"
259 }
260 title={
261 desktopOverlay?.pictureInPicture
262 ? "Close separate window"
263 : "Pop into separate window"
264 }
265 disabled={!desktopOverlay?.hasWebContents}
266 onPointerDown={(event) => event.stopPropagation()}
267 onClick={toggleNativePictureInPicture}
268 >
269 <PictureInPicture2 />
270 </Button>
271 <Button
272 variant="ghost"
273 size="icon-xs"
274 aria-label="Close floating preview"
275 title="Close floating preview"
276 onPointerDown={(event) => event.stopPropagation()}
277 onClick={close}
278 >
279 <XIcon />
280 </Button>
281 </div>
282 </div>
283
284 <div className="relative h-full min-h-0">
285 <div className="absolute inset-0 z-[29] rounded-xl bg-muted shadow-2xl/35" />
286 <BrowserSurfaceSlot
287 tabId={tabId}
288 visible={Boolean(desktopOverlay?.hasWebContents)}
289 cornerRadius={12}
290 fitSourceContent
291 layoutVersion={position ? `${position.x}:${position.y}` : `initial:${bottomInset}`}
292 className="absolute inset-0"
293 />
294 <div className="pointer-events-none absolute inset-0 z-[31] rounded-xl ring-1 ring-inset ring-border/80" />
295 {!desktopOverlay?.hasWebContents ? (
296 <div className="pointer-events-none absolute inset-0 z-[32] flex items-center justify-center rounded-xl bg-muted text-xs text-muted-foreground">
297 Reconnecting preview…
298 </div>
299 ) : null}
300 <button
301 type="button"
302 aria-label="Resize floating preview"
303 title="Resize floating preview"
304 className="pointer-events-auto absolute bottom-0 right-0 z-[33] size-5 cursor-nwse-resize rounded-br-xl after:absolute after:bottom-1 after:right-1 after:size-2 after:border-b after:border-r after:border-foreground/45"
305 onPointerDown={handleResizePointerDown}
306 onPointerMove={handleResizePointerMove}
307 onPointerUp={endResize}
308 onPointerCancel={endResize}
309 />
310 </div>
311 </section>
312 );
313}
apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts+16-1
......@@ -1,7 +1,10 @@
11import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts";
22import { describe, expect, it } from "vite-plus/test";
33
4import { previewAutomationOpenNeedsOverlay } from "./previewAutomationOpenReadiness";
4import {
5 previewAutomationOpenNeedsOverlay,
6 shouldOpenPreviewMiniPlayer,
7} from "./previewAutomationOpenReadiness";
58
69const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({
710 threadId: "thread-1",
......@@ -13,6 +16,18 @@ const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessio
1316});
1417
1518describe("preview automation open readiness", () => {
19 it("opens the inline preview by default", () => {
20 expect(shouldOpenPreviewMiniPlayer({} as PreviewAutomationOpenInput)).toBe(true);
21 });
22
23 it("supports explicit opt-out and the legacy show alias", () => {
24 expect(shouldOpenPreviewMiniPlayer({ open: false } as PreviewAutomationOpenInput)).toBe(false);
25 expect(shouldOpenPreviewMiniPlayer({ show: false } as PreviewAutomationOpenInput)).toBe(false);
26 expect(
27 shouldOpenPreviewMiniPlayer({ open: true, show: false } as PreviewAutomationOpenInput),
28 ).toBe(true);
29 });
30
1631 it("does not wait for a desktop overlay when opening an empty tab", () => {
1732 expect(
1833 previewAutomationOpenNeedsOverlay(
apps/web/src/components/preview/previewAutomationOpenReadiness.ts+4
......@@ -1,5 +1,9 @@
11import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts";
22
3export function shouldOpenPreviewMiniPlayer(input: PreviewAutomationOpenInput): boolean {
4 return input.open ?? input.show ?? true;
5}
6
37export function previewAutomationOpenNeedsOverlay(
48 input: PreviewAutomationOpenInput,
59 snapshot: PreviewSessionSnapshot,
apps/web/src/components/preview/previewMiniPlayerLayout.test.ts created+73
......@@ -0,0 +1,73 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 clampPreviewMiniPlayerPosition,
5 clampPreviewMiniPlayerSize,
6 PREVIEW_MINI_PLAYER_EDGE_GAP,
7} from "./previewMiniPlayerLayout";
8
9describe("clampPreviewMiniPlayerPosition", () => {
10 it("keeps a dragged player within the chat viewport", () => {
11 expect(
12 clampPreviewMiniPlayerPosition(
13 { x: 900, y: -40 },
14 { width: 1_000, height: 700 },
15 { width: 360, height: 240 },
16 ),
17 ).toEqual({
18 x: 628,
19 y: PREVIEW_MINI_PLAYER_EDGE_GAP,
20 });
21 });
22
23 it("keeps an edge gap when the player is larger than its container", () => {
24 expect(
25 clampPreviewMiniPlayerPosition(
26 { x: 20, y: 30 },
27 { width: 200, height: 160 },
28 { width: 360, height: 240 },
29 ),
30 ).toEqual({
31 x: PREVIEW_MINI_PLAYER_EDGE_GAP,
32 y: PREVIEW_MINI_PLAYER_EDGE_GAP,
33 });
34 });
35
36 it("keeps the player above a growing composer inset", () => {
37 expect(
38 clampPreviewMiniPlayerPosition(
39 { x: 500, y: 448 },
40 { width: 1_000, height: 700 },
41 { width: 360, height: 240 },
42 160,
43 ),
44 ).toEqual({
45 x: 500,
46 y: 288,
47 });
48 });
49});
50
51describe("clampPreviewMiniPlayerSize", () => {
52 it("allows resizing within the available chat viewport", () => {
53 expect(
54 clampPreviewMiniPlayerSize({ width: 520, height: 360 }, { width: 1_000, height: 700 }, 120),
55 ).toEqual({ width: 520, height: 360 });
56 });
57
58 it("bounds oversized players above the composer", () => {
59 expect(
60 clampPreviewMiniPlayerSize(
61 { width: 2_000, height: 2_000 },
62 { width: 1_000, height: 700 },
63 120,
64 ),
65 ).toEqual({ width: 976, height: 556 });
66 });
67
68 it("lets a tiny container win over the preferred minimum", () => {
69 expect(
70 clampPreviewMiniPlayerSize({ width: 360, height: 239 }, { width: 250, height: 180 }, 20),
71 ).toEqual({ width: 226, height: 136 });
72 });
73});
apps/web/src/components/preview/previewMiniPlayerLayout.ts created+46
......@@ -0,0 +1,46 @@
1import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore";
2
3export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12;
4export const PREVIEW_MINI_PLAYER_DEFAULT_SIZE = { width: 320, height: 200 } as const;
5export const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const;
6
7export function clampPreviewMiniPlayerSize(
8 size: PreviewMiniPlayerSize,
9 container: PreviewMiniPlayerSize,
10 bottomInset = 0,
11): PreviewMiniPlayerSize {
12 const availableWidth = Math.max(1, container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2);
13 const availableHeight = Math.max(
14 1,
15 container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
16 );
17 return {
18 width: Math.round(
19 Math.min(Math.max(PREVIEW_MINI_PLAYER_MIN_SIZE.width, size.width), availableWidth),
20 ),
21 height: Math.round(
22 Math.min(Math.max(PREVIEW_MINI_PLAYER_MIN_SIZE.height, size.height), availableHeight),
23 ),
24 };
25}
26
27export function clampPreviewMiniPlayerPosition(
28 position: PreviewMiniPlayerPosition,
29 container: PreviewMiniPlayerSize,
30 player: PreviewMiniPlayerSize,
31 bottomInset = 0,
32): PreviewMiniPlayerPosition {
33 const reservedBottomSpace = Math.max(0, bottomInset);
34 const maxX = Math.max(
35 PREVIEW_MINI_PLAYER_EDGE_GAP,
36 container.width - player.width - PREVIEW_MINI_PLAYER_EDGE_GAP,
37 );
38 const maxY = Math.max(
39 PREVIEW_MINI_PLAYER_EDGE_GAP,
40 container.height - reservedBottomSpace - player.height - PREVIEW_MINI_PLAYER_EDGE_GAP,
41 );
42 return {
43 x: Math.min(Math.max(position.x, PREVIEW_MINI_PLAYER_EDGE_GAP), maxX),
44 y: Math.min(Math.max(position.y, PREVIEW_MINI_PLAYER_EDGE_GAP), maxY),
45 };
46}
apps/web/src/components/preview/usePreviewBridge.ts+2
......@@ -73,10 +73,12 @@ function shouldClearBrowserPointer(
7373
7474function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay {
7575 return {
76 hasWebContents: state.webContentsId !== null,
7677 canGoBack: state.canGoBack,
7778 canGoForward: state.canGoForward,
7879 loading: state.navStatus.kind === "Loading",
7980 zoomFactor: state.zoomFactor,
81 pictureInPicture: state.pictureInPicture,
8082 colorScheme: state.colorScheme,
8183 controller: state.controller,
8284 };
apps/web/src/components/preview/usePreviewSession.ts+5-42
......@@ -2,14 +2,12 @@
22
33import { useAtomValue } from "@effect/atom-react";
44import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment";
5import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
65import type { ScopedThreadRef } from "@t3tools/contracts";
76import * as Schema from "effect/Schema";
87import { AsyncResult, Atom } from "effect/unstable/reactivity";
98
109import {
1110 applyPreviewServerEvent,
12 applyPreviewServerSnapshot,
1311 readThreadPreviewState,
1412 reconcilePreviewServerSessions,
1513} from "~/previewStateStore";
......@@ -41,61 +39,26 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {
4139
4240 return Atom.make((get) => {
4341 let disposed = false;
44 let recoveryId = 0;
45 let recoveringUrl: string | null = null;
4642 let sessionsVersion = 0;
4743 let eventsVersion = 0;
4844
4945 const reconcileSessions = (result: Atom.Type<typeof sessionsAtom>) => {
5046 if (!AsyncResult.isSuccess(result)) return;
51 if (result.value.sessions.length > 0) {
52 recoveringUrl = null;
53 recoveryId += 1;
54 reconcilePreviewServerSessions(threadRef, result.value.sessions);
55 return;
56 }
57
58 const localSnapshot = readThreadPreviewState(threadRef).snapshot;
59 const recoverableUrl =
60 localSnapshot && localSnapshot.navStatus._tag !== "Idle"
61 ? localSnapshot.navStatus.url
62 : null;
63 if (!recoverableUrl) {
64 applyPreviewServerSnapshot(threadRef, null);
65 return;
66 }
67 if (recoveringUrl === recoverableUrl) return;
68
69 recoveringUrl = recoverableUrl;
70 const currentRecoveryId = ++recoveryId;
71 void runAtomCommand(
72 get.registry,
73 previewEnvironment.open,
74 {
75 environmentId: threadRef.environmentId,
76 input: { threadId: threadRef.threadId, url: recoverableUrl },
77 },
78 { reportDefect: false, reportFailure: false },
79 ).then((openResult) => {
80 if (disposed || currentRecoveryId !== recoveryId) return;
81 recoveringUrl = null;
82 if (openResult._tag === "Failure") return;
83 applyPreviewServerSnapshot(threadRef, openResult.value);
84 get.refresh(sessionsAtom);
85 });
47 reconcilePreviewServerSessions(threadRef, result.value);
8648 };
8749
8850 const applyLatestEvent = (result: Atom.Type<typeof eventsAtom>) => {
8951 if (!AsyncResult.isSuccess(result) || result.value.threadId !== threadRef.threadId) return;
90 applyPreviewServerEvent(threadRef, result.value);
91 if (result.value.type === "opened" || result.value.type === "closed") {
52 const currentEpoch = readThreadPreviewState(threadRef).serverEpoch;
53 if (currentEpoch !== null && currentEpoch !== result.value.serverEpoch) {
9254 get.refresh(sessionsAtom);
55 return;
9356 }
57 applyPreviewServerEvent(threadRef, result.value);
9458 };
9559
9660 get.addFinalizer(() => {
9761 disposed = true;
98 recoveryId += 1;
9962 });
10063 const initialSessions = get.once(sessionsAtom);
10164 const initialEvent = get.once(eventsAtom);
apps/web/src/previewMiniPlayerStore.test.ts created+64
......@@ -0,0 +1,64 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { type EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it } from "vite-plus/test";
4
5import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "./previewMiniPlayerStore";
6
7const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A"));
8const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B"));
9
10beforeEach(() => {
11 usePreviewMiniPlayerStore.setState({ byThreadKey: {} });
12});
13
14describe("previewMiniPlayerStore", () => {
15 it("keeps floating previews scoped to their thread", () => {
16 usePreviewMiniPlayerStore.getState().open(refA, "tab-a");
17 usePreviewMiniPlayerStore.getState().open(refB, "tab-b");
18
19 expect(
20 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),
21 ).toMatchObject({ tabId: "tab-a" });
22 expect(
23 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refB),
24 ).toMatchObject({ tabId: "tab-b" });
25 });
26
27 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");
31
32 expect(
33 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),
34 ).toEqual({
35 tabId: "tab-b",
36 position: { x: 24, y: 48 },
37 size: null,
38 });
39 });
40
41 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 });
45
46 expect(
47 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),
48 ).toEqual({
49 tabId: "tab-b",
50 position: null,
51 size: null,
52 });
53 });
54
55 it("preserves a thread-bound size while switching tabs", () => {
56 usePreviewMiniPlayerStore.getState().open(refA, "tab-a");
57 usePreviewMiniPlayerStore.getState().resize(refA, "tab-a", { width: 480, height: 320 });
58 usePreviewMiniPlayerStore.getState().open(refA, "tab-b");
59
60 expect(
61 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),
62 ).toMatchObject({ tabId: "tab-b", size: { width: 480, height: 320 } });
63 });
64});
apps/web/src/previewMiniPlayerStore.ts created+96
......@@ -0,0 +1,96 @@
1import { scopedThreadKey } from "@t3tools/client-runtime/environment";
2import type { ScopedThreadRef } from "@t3tools/contracts";
3import { create } from "zustand";
4
5export interface PreviewMiniPlayerPosition {
6 readonly x: number;
7 readonly y: number;
8}
9
10export interface PreviewMiniPlayerSize {
11 readonly width: number;
12 readonly height: number;
13}
14
15export interface PreviewMiniPlayerState {
16 readonly tabId: string;
17 readonly position: PreviewMiniPlayerPosition | null;
18 readonly size: PreviewMiniPlayerSize | null;
19}
20
21interface PreviewMiniPlayerStoreState {
22 readonly byThreadKey: Record<string, PreviewMiniPlayerState>;
23 readonly open: (ref: ScopedThreadRef, tabId: string) => void;
24 readonly close: (ref: ScopedThreadRef) => void;
25 readonly move: (ref: ScopedThreadRef, tabId: string, position: PreviewMiniPlayerPosition) => void;
26 readonly resize: (ref: ScopedThreadRef, tabId: string, size: PreviewMiniPlayerSize) => void;
27 readonly removeThread: (ref: ScopedThreadRef) => void;
28}
29
30export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((set) => ({
31 byThreadKey: {},
32 open: (ref, tabId) =>
33 set((state) => {
34 const threadKey = scopedThreadKey(ref);
35 const current = state.byThreadKey[threadKey];
36 if (current?.tabId === tabId) return state;
37 return {
38 byThreadKey: {
39 ...state.byThreadKey,
40 [threadKey]: {
41 tabId,
42 position: current?.position ?? null,
43 size: current?.size ?? null,
44 },
45 },
46 };
47 }),
48 close: (ref) =>
49 set((state) => {
50 const threadKey = scopedThreadKey(ref);
51 if (!(threadKey in state.byThreadKey)) return state;
52 const { [threadKey]: _closed, ...byThreadKey } = state.byThreadKey;
53 return { byThreadKey };
54 }),
55 move: (ref, tabId, position) =>
56 set((state) => {
57 const threadKey = scopedThreadKey(ref);
58 const current = state.byThreadKey[threadKey];
59 if (!current || current.tabId !== tabId) return state;
60 if (current.position?.x === position.x && current.position.y === position.y) return state;
61 return {
62 byThreadKey: {
63 ...state.byThreadKey,
64 [threadKey]: { ...current, position },
65 },
66 };
67 }),
68 resize: (ref, tabId, size) =>
69 set((state) => {
70 const threadKey = scopedThreadKey(ref);
71 const current = state.byThreadKey[threadKey];
72 if (!current || current.tabId !== tabId) return state;
73 if (current.size?.width === size.width && current.size.height === size.height) return state;
74 return {
75 byThreadKey: {
76 ...state.byThreadKey,
77 [threadKey]: { ...current, size },
78 },
79 };
80 }),
81 removeThread: (ref) =>
82 set((state) => {
83 const threadKey = scopedThreadKey(ref);
84 if (!(threadKey in state.byThreadKey)) return state;
85 const { [threadKey]: _removed, ...byThreadKey } = state.byThreadKey;
86 return { byThreadKey };
87 }),
88}));
89
90export function selectThreadPreviewMiniPlayer(
91 byThreadKey: Record<string, PreviewMiniPlayerState>,
92 ref: ScopedThreadRef | null | undefined,
93): PreviewMiniPlayerState | null {
94 if (!ref) return null;
95 return byThreadKey[scopedThreadKey(ref)] ?? null;
96}
apps/web/src/previewStateStore.test.ts+101-4
......@@ -1,11 +1,16 @@
11import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts";
2import {
3 type EnvironmentId,
4 type PreviewEvent,
5 type PreviewSessionSnapshot,
6 ThreadId,
7} from "@t3tools/contracts";
38import { beforeEach, describe, expect, it } from "vite-plus/test";
49
510import {
611 __testing,
712 applyPreviewDesktopState,
8 applyPreviewServerEvent,
13 applyPreviewServerEvent as applyPreviewServerEventImpl,
914 applyPreviewServerSnapshot,
1015 beginPreviewSessionClose,
1116 cancelPreviewSessionClose,
......@@ -33,7 +38,25 @@ const makeSnapshot = (overrides: Partial<PreviewSessionSnapshot> = {}): PreviewS
3338 ...overrides,
3439});
3540
41type PreviewEventDraft = PreviewEvent extends infer Event
42 ? Event extends { readonly revision: number }
43 ? Omit<Event, "revision" | "serverEpoch">
44 : never
45 : never;
46
47const serverEpoch = "server-a";
48let nextServerRevision = 0;
49const applyPreviewServerEvent = (eventRef: typeof ref, event: PreviewEventDraft): void => {
50 nextServerRevision += 1;
51 applyPreviewServerEventImpl(eventRef, {
52 ...event,
53 serverEpoch,
54 revision: nextServerRevision,
55 } as PreviewEvent);
56};
57
3658beforeEach(() => {
59 nextServerRevision = 0;
3760 resetPreviewStateForTests();
3861});
3962
......@@ -291,10 +314,12 @@ describe("previewStateStore (single-tab)", () => {
291314 snapshot,
292315 });
293316 applyPreviewDesktopState(ref, snapshot.tabId, {
317 hasWebContents: true,
294318 canGoBack: true,
295319 canGoForward: false,
296320 loading: false,
297321 zoomFactor: 1,
322 pictureInPicture: false,
298323 colorScheme: "system",
299324 controller: "none",
300325 });
......@@ -309,10 +334,12 @@ describe("previewStateStore (single-tab)", () => {
309334 applyPreviewServerSnapshot(ref, first);
310335 applyPreviewServerSnapshot(ref, second);
311336 applyPreviewDesktopState(ref, first.tabId, {
337 hasWebContents: true,
312338 canGoBack: true,
313339 canGoForward: false,
314340 loading: false,
315341 zoomFactor: 1,
342 pictureInPicture: false,
316343 colorScheme: "system",
317344 controller: "none",
318345 });
......@@ -355,15 +382,17 @@ describe("previewStateStore (single-tab)", () => {
355382 applyPreviewServerSnapshot(ref, stale);
356383 applyPreviewServerSnapshot(ref, active);
357384 applyPreviewDesktopState(ref, stale.tabId, {
385 hasWebContents: true,
358386 canGoBack: false,
359387 canGoForward: false,
360388 loading: false,
361389 zoomFactor: 1,
390 pictureInPicture: false,
362391 colorScheme: "system",
363392 controller: "none",
364393 });
365394
366 reconcilePreviewServerSessions(ref, [active]);
395 reconcilePreviewServerSessions(ref, { sessions: [active], serverEpoch, revision: 1 });
367396
368397 const state = readThreadPreviewState(ref);
369398 expect(Object.keys(state.sessions)).toEqual([active.tabId]);
......@@ -375,7 +404,7 @@ describe("previewStateStore (single-tab)", () => {
375404 it("clears stale sessions when an authoritative list is empty", () => {
376405 applyPreviewServerSnapshot(ref, makeSnapshot());
377406
378 reconcilePreviewServerSessions(ref, []);
407 reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 1 });
379408
380409 const state = readThreadPreviewState(ref);
381410 expect(state.sessions).toEqual({});
......@@ -383,6 +412,74 @@ describe("previewStateStore (single-tab)", () => {
383412 expect(state.snapshot).toBeNull();
384413 });
385414
415 it("ignores a list response older than the latest server event", () => {
416 const snapshot = makeSnapshot();
417 applyPreviewServerEvent(ref, {
418 type: "opened",
419 threadId: "thread-1",
420 tabId: snapshot.tabId,
421 createdAt: snapshot.updatedAt,
422 snapshot,
423 });
424
425 reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 0 });
426
427 expect(readThreadPreviewState(ref).sessions).toEqual({ [snapshot.tabId]: snapshot });
428 });
429
430 it("does not resurrect a tab from an event older than its close", () => {
431 const snapshot = makeSnapshot();
432 applyPreviewServerEvent(ref, {
433 type: "opened",
434 threadId: "thread-1",
435 tabId: snapshot.tabId,
436 createdAt: snapshot.updatedAt,
437 snapshot,
438 });
439 applyPreviewServerEvent(ref, {
440 type: "closed",
441 threadId: "thread-1",
442 tabId: snapshot.tabId,
443 createdAt: "2026-01-01T00:00:01.000Z",
444 });
445
446 applyPreviewServerEventImpl(ref, {
447 type: "opened",
448 threadId: "thread-1",
449 tabId: snapshot.tabId,
450 createdAt: snapshot.updatedAt,
451 serverEpoch,
452 revision: 1,
453 snapshot,
454 });
455
456 expect(readThreadPreviewState(ref).sessions).toEqual({});
457 });
458
459 it("accepts a lower revision from a newly restarted server", () => {
460 const snapshot = makeSnapshot();
461 applyPreviewServerEventImpl(ref, {
462 type: "opened",
463 threadId: "thread-1",
464 tabId: snapshot.tabId,
465 createdAt: snapshot.updatedAt,
466 serverEpoch,
467 revision: 12,
468 snapshot,
469 });
470
471 reconcilePreviewServerSessions(ref, {
472 sessions: [],
473 serverEpoch: "server-b",
474 revision: 0,
475 });
476
477 const state = readThreadPreviewState(ref);
478 expect(state.sessions).toEqual({});
479 expect(state.serverEpoch).toBe("server-b");
480 expect(state.serverRevision).toBe(0);
481 });
482
386483 it("applyServerSnapshot null clears snapshot for a thread that had one", () => {
387484 const snapshot = makeSnapshot();
388485 applyPreviewServerSnapshot(ref, snapshot);
apps/web/src/previewStateStore.ts+81-45
......@@ -10,6 +10,7 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment";
1010import {
1111 type DesktopPreviewColorScheme,
1212 type PreviewEvent,
13 type PreviewListResult,
1314 type PreviewSessionSnapshot,
1415 type ScopedThreadRef,
1516} from "@t3tools/contracts";
......@@ -19,10 +20,12 @@ import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants"
1920import { appAtomRegistry } from "./rpc/atomRegistry";
2021
2122export interface DesktopPreviewOverlay {
23 hasWebContents: boolean;
2224 canGoBack: boolean;
2325 canGoForward: boolean;
2426 loading: boolean;
2527 zoomFactor: number;
28 pictureInPicture: boolean;
2629 colorScheme: DesktopPreviewColorScheme;
2730 controller: "human" | "agent" | "none";
2831}
......@@ -36,6 +39,10 @@ export interface ThreadPreviewState {
3639 desktopOverlay: DesktopPreviewOverlay | null;
3740 desktopByTabId: Record<string, DesktopPreviewOverlay>;
3841 recentlySeenUrls: string[];
42 /** Server process currently authoritative for revision ordering. */
43 serverEpoch: string | null;
44 /** Latest ordered server revision applied from a list response or event. */
45 serverRevision: number;
3946}
4047
4148const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({
......@@ -46,6 +53,8 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({
4653 desktopOverlay: null,
4754 desktopByTabId: {},
4855 recentlySeenUrls: [] as string[],
56 serverEpoch: null,
57 serverRevision: 0,
4958});
5059
5160const emptyPreviewStateAtom = Atom.make<ThreadPreviewState>(EMPTY_THREAD_PREVIEW_STATE).pipe(
......@@ -175,52 +184,68 @@ export function subscribeThreadPreviewState(
175184
176185export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void {
177186 updateThreadPreviewState(ref, (current) => {
178 switch (event.type) {
179 case "opened":
180 case "navigated":
181 case "resized": {
182 const snapshot = event.snapshot;
183 if (current.suppressedTabIds.has(snapshot.tabId)) return current;
184 const recentlySeenUrls =
185 snapshot.navStatus._tag === "Idle"
186 ? current.recentlySeenUrls
187 : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url);
188 const sessions = { ...current.sessions, [snapshot.tabId]: snapshot };
189 const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId;
190 const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot;
191 return {
192 ...current,
193 sessions,
194 activeTabId: activeTabId ?? snapshot.tabId,
195 snapshot: activeSnapshot,
196 desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null,
197 recentlySeenUrls,
198 };
187 if (current.serverEpoch !== null && event.serverEpoch !== current.serverEpoch) return current;
188 if (event.revision < current.serverRevision) return current;
189 const next = (() => {
190 switch (event.type) {
191 case "opened":
192 case "navigated":
193 case "resized": {
194 const snapshot = event.snapshot;
195 if (current.suppressedTabIds.has(snapshot.tabId)) return current;
196 const recentlySeenUrls =
197 snapshot.navStatus._tag === "Idle"
198 ? current.recentlySeenUrls
199 : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url);
200 const sessions = { ...current.sessions, [snapshot.tabId]: snapshot };
201 const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId;
202 const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot;
203 return {
204 ...current,
205 sessions,
206 activeTabId: activeTabId ?? snapshot.tabId,
207 snapshot: activeSnapshot,
208 desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null,
209 recentlySeenUrls,
210 };
211 }
212 case "failed": {
213 const existing = current.sessions[event.tabId];
214 if (!existing) return current;
215 const failedSnapshot = {
216 ...existing,
217 navStatus: {
218 _tag: "LoadFailed" as const,
219 url: event.url,
220 title: event.title,
221 code: event.code,
222 description: event.description,
223 },
224 updatedAt: event.createdAt,
225 };
226 const sessions = { ...current.sessions, [event.tabId]: failedSnapshot };
227 return {
228 ...current,
229 sessions,
230 snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot,
231 };
232 }
233 case "closed": {
234 const closed = removeSession(current, event.tabId);
235 if (!closed.suppressedTabIds.has(event.tabId)) return closed;
236 const suppressedTabIds = new Set(closed.suppressedTabIds);
237 suppressedTabIds.delete(event.tabId);
238 return { ...closed, suppressedTabIds };
239 }
199240 }
200 case "failed": {
201 const existing = current.sessions[event.tabId];
202 if (!existing) return current;
203 const failedSnapshot = {
204 ...existing,
205 navStatus: {
206 _tag: "LoadFailed" as const,
207 url: event.url,
208 title: event.title,
209 code: event.code,
210 description: event.description,
211 },
212 updatedAt: event.createdAt,
213 };
214 const sessions = { ...current.sessions, [event.tabId]: failedSnapshot };
215 return {
216 ...current,
217 sessions,
218 snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot,
241 })();
242 return next.serverRevision === event.revision && next.serverEpoch === event.serverEpoch
243 ? next
244 : {
245 ...next,
246 serverEpoch: event.serverEpoch,
247 serverRevision: event.revision,
219248 };
220 }
221 case "closed":
222 return removeSession(current, event.tabId);
223 }
224249 });
225250}
226251
......@@ -291,9 +316,12 @@ export function updatePreviewServerSnapshot(
291316 */
292317export function reconcilePreviewServerSessions(
293318 ref: ScopedThreadRef,
294 snapshots: ReadonlyArray<PreviewSessionSnapshot>,
319 result: PreviewListResult,
295320): void {
296321 updateThreadPreviewState(ref, (current) => {
322 const sameServer = current.serverEpoch === result.serverEpoch;
323 if (sameServer && result.revision < current.serverRevision) return current;
324 const snapshots = result.sessions;
297325 const sessions: Record<string, PreviewSessionSnapshot> = {};
298326 let recentlySeenUrls = current.recentlySeenUrls;
299327 for (const snapshot of snapshots) {
......@@ -313,14 +341,22 @@ export function reconcilePreviewServerSessions(
313341 const desktopByTabId = Object.fromEntries(
314342 Object.entries(current.desktopByTabId).filter(([tabId]) => sessions[tabId] !== undefined),
315343 );
344 const suppressedTabIds = new Set(
345 [...current.suppressedTabIds].filter((tabId) =>
346 snapshots.some((snapshot) => snapshot.tabId === tabId),
347 ),
348 );
316349 return {
317350 ...current,
318351 sessions,
352 suppressedTabIds,
319353 activeTabId,
320354 snapshot,
321355 desktopByTabId,
322356 desktopOverlay: activeTabId ? (desktopByTabId[activeTabId] ?? null) : null,
323357 recentlySeenUrls,
358 serverEpoch: result.serverEpoch,
359 serverRevision: result.revision,
324360 };
325361 });
326362}
packages/contracts/src/ipc.ts+7
......@@ -527,6 +527,8 @@ export interface DesktopPreviewTabState {
527527 canGoForward: boolean;
528528 /** Current zoom factor (1.0 = 100%). */
529529 zoomFactor: number;
530 /** Whether this tab is currently mirrored into a desktop picture-in-picture window. */
531 pictureInPicture: boolean;
530532 colorScheme: DesktopPreviewColorScheme;
531533 controller: "human" | "agent" | "none";
532534 updatedAt: string;
......@@ -564,6 +566,7 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState>
564566 canGoBack: Schema.Boolean,
565567 canGoForward: Schema.Boolean,
566568 zoomFactor: Schema.Number,
569 pictureInPicture: Schema.Boolean,
567570 colorScheme: DesktopPreviewColorSchemeSchema,
568571 controller: Schema.Literals(["human", "agent", "none"]),
569572 updatedAt: Schema.String,
......@@ -1080,6 +1083,10 @@ export interface DesktopPreviewBridge {
10801083 captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>;
10811084 revealArtifact: (path: string) => Promise<void>;
10821085 copyArtifactToClipboard: (path: string) => Promise<void>;
1086 pictureInPicture: {
1087 open: (tabId: string) => Promise<void>;
1088 close: (tabId: string) => Promise<void>;
1089 };
10831090 recording: {
10841091 startScreencast: (tabId: string) => Promise<void>;
10851092 stopScreencast: (tabId: string) => Promise<void>;
packages/contracts/src/preview.test.ts+18
......@@ -29,6 +29,16 @@ const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost);
2929const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError);
3030const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus);
3131
32describe("PreviewAutomationOpenInput", () => {
33 it("accepts the inline preview visibility flag", () => {
34 expect(decodeOpenInput({ open: false })).toEqual({ open: false });
35 });
36
37 it("retains the legacy show visibility alias", () => {
38 expect(decodeOpenInput({ show: false })).toEqual({ show: false });
39 });
40});
41
3242describe("PreviewNavStatus", () => {
3343 it("decodes Idle", () => {
3444 expect(decodeNavStatus({ _tag: "Idle" })).toEqual({ _tag: "Idle" });
......@@ -216,6 +226,8 @@ describe("PreviewEvent", () => {
216226 threadId: "t",
217227 tabId: "preview-t",
218228 createdAt: "2026-01-01T00:00:00.000Z",
229 serverEpoch: "server-a",
230 revision: 1,
219231 snapshot: {
220232 threadId: "t",
221233 tabId: "preview-t",
......@@ -234,6 +246,8 @@ describe("PreviewEvent", () => {
234246 threadId: "t",
235247 tabId: "preview-t",
236248 createdAt: "2026-01-01T00:00:00.000Z",
249 serverEpoch: "server-a",
250 revision: 1,
237251 url: "https://example.com/",
238252 title: "",
239253 code: -105,
......@@ -251,6 +265,8 @@ describe("PreviewEvent", () => {
251265 threadId: "t",
252266 tabId: "preview-t",
253267 createdAt: "2026-01-01T00:00:00.000Z",
268 serverEpoch: "server-a",
269 revision: 1,
254270 snapshot: {
255271 threadId: "t",
256272 tabId: "preview-t",
......@@ -270,6 +286,8 @@ describe("PreviewEvent", () => {
270286 threadId: "t",
271287 tabId: "preview-t",
272288 createdAt: "2026-01-01T00:00:00.000Z",
289 serverEpoch: "server-a",
290 revision: 1,
273291 });
274292 expect(event.type).toBe("closed");
275293 });
packages/contracts/src/preview.ts+9-1
......@@ -9,7 +9,7 @@
99 * @module Preview
1010 */
1111import { Schema } from "effect";
12import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
12import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
1313
1414const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(2048));
1515const Title = Schema.String.check(Schema.isMaxLength(512));
......@@ -192,6 +192,10 @@ export type PreviewListInput = typeof PreviewListInput.Type;
192192
193193export const PreviewListResult = Schema.Struct({
194194 sessions: Schema.Array(PreviewSessionSnapshot),
195 /** Identifies the current server process so revision resets are safe. */
196 serverEpoch: TrimmedNonEmptyString,
197 /** Monotonic server state revision used to reject stale list responses. */
198 revision: NonNegativeInt,
195199});
196200export type PreviewListResult = typeof PreviewListResult.Type;
197201
......@@ -199,6 +203,10 @@ const PreviewEventBaseSchema = Schema.Struct({
199203 threadId: TrimmedNonEmptyString,
200204 tabId: PreviewTabId,
201205 createdAt: Schema.String,
206 /** Identifies the server process that emitted this event. */
207 serverEpoch: TrimmedNonEmptyString,
208 /** Monotonic server state revision shared with PreviewListResult. */
209 revision: PositiveInt,
202210});
203211
204212const PreviewOpenedEvent = Schema.Struct({
packages/contracts/src/previewAutomation.ts+8-1
......@@ -86,9 +86,16 @@ export const PreviewAutomationOpenInput = Schema.Struct({
8686 description:
8787 "Optional initial page URL, for example https://t3.chat or localhost:5173. Omit to open a blank tab.",
8888 }),
89 open: Schema.optional(
90 Schema.Boolean.annotate({
91 description:
92 "Whether to open the thread-bound inline preview for the human. Defaults to true; set false for background-only automation.",
93 }),
94 ),
8995 show: Schema.optional(
9096 Schema.Boolean.annotate({
91 description: "Whether to reveal the preview panel to the human. Defaults to true.",
97 description:
98 "Deprecated alias for open. Whether to reveal the thread-bound inline preview to the human.",
9299 }),
93100 ),
94101 reuseExistingTab: Schema.optional(