diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3df8831480d5..384c59807312 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -927,6 +927,7 @@ jobs: echo "Deploying hosted web app for $channel_name channel." deployment_url="$( vp dlx vercel@53.1.1 deploy \ + --archive=tgz \ --prod \ --skip-domain \ --yes \ diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index b59f8572739d..67819def623d 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -94,6 +94,7 @@ describe("ElectronWindow", () => { webPreferences: { preload: "/tmp/preload.js", partition: "persist:t3code-preview-test", + backgroundThrottling: null, sandbox: true, contextIsolation: true, nodeIntegration: false, diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index dacb2eebb47d..4671328587ae 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -22,6 +22,7 @@ const ElectronWindowCreateOptions = Schema.Struct({ webPreferences: Schema.Struct({ preload: Schema.NullOr(Schema.String), partition: Schema.NullOr(Schema.String), + backgroundThrottling: Schema.NullOr(Schema.Boolean), sandbox: Schema.NullOr(Schema.Boolean), contextIsolation: Schema.NullOr(Schema.Boolean), nodeIntegration: Schema.NullOr(Schema.Boolean), @@ -179,6 +180,7 @@ export const make = Effect.gen(function* () { webPreferences: { preload: webPreferences?.preload ?? null, partition: webPreferences?.partition ?? null, + backgroundThrottling: webPreferences?.backgroundThrottling ?? null, sandbox: webPreferences?.sandbox ?? null, contextIsolation: webPreferences?.contextIsolation ?? null, nodeIntegration: webPreferences?.nodeIntegration ?? null, diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index f45b6049120d..a86361bf2e7a 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -65,6 +65,9 @@ export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick- export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; export const PREVIEW_REVEAL_ARTIFACT_CHANNEL = "desktop:preview-reveal-artifact"; export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact"; +export const PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL = "desktop:preview-pip-open"; +export const PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL = "desktop:preview-pip-close"; +export const PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL = "desktop:preview-pip-frame"; export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 28405288f6ce..4d50ad8d665e 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -168,6 +168,16 @@ export const stopRecording = tabMethod( "desktop.ipc.preview.stopRecording", (manager, tabId) => manager.stopRecording(tabId), ); +export const openPictureInPicture = tabMethod( + IpcChannels.PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL, + "desktop.ipc.preview.openPictureInPicture", + (manager, tabId) => manager.openPictureInPicture(tabId), +); +export const closePictureInPicture = tabMethod( + IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, + "desktop.ipc.preview.closePictureInPicture", + (manager, tabId) => manager.closePictureInPicture(tabId), +); export const clearCookies = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, @@ -367,6 +377,8 @@ export const methods = [ captureScreenshot, revealArtifact, copyArtifactToClipboard, + openPictureInPicture, + closePictureInPicture, automationStatus, automationSnapshot, automationClick, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index af986be8d218..b951737a3e75 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -183,6 +183,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, { path }), copyArtifactToClipboard: (path) => ipcRenderer.invoke(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, { path }), + pictureInPicture: { + open: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL, { tabId }), + close: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, { tabId }), + }, recording: { startScreencast: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), diff --git a/apps/desktop/src/preview-pip-preload.ts b/apps/desktop/src/preview-pip-preload.ts new file mode 100644 index 000000000000..384c4129774f --- /dev/null +++ b/apps/desktop/src/preview-pip-preload.ts @@ -0,0 +1,17 @@ +// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. +import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; +import { contextBridge, ipcRenderer } from "electron"; + +import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "./ipc/channels.ts"; + +contextBridge.exposeInMainWorld("previewPictureInPicture", { + onFrame: (listener: (frame: DesktopPreviewRecordingFrame) => void) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => { + if (typeof frame !== "object" || frame === null) return; + listener(frame as DesktopPreviewRecordingFrame); + }; + ipcRenderer.on(PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, wrappedListener); + }, +}); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index f1215ee7b60c..fa962f58ce4e 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,6 +1,8 @@ import { it as effectIt } from "@effect/vitest"; +import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -19,7 +21,23 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as BrowserSession from "./BrowserSession.ts"; import * as PreviewManager from "./Manager.ts"; +describe("fitPictureInPictureContentSize", () => { + it("preserves the PiP content area across aspect-ratio changes", () => { + expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 16 / 9)).toEqual([523, 294]); + expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 9 / 16)).toEqual([294, 523]); + }); + + it("does not collapse toward the minimum size when orientation changes repeatedly", () => { + const portrait = PreviewManager.fitPictureInPictureContentSize([523, 294], 9 / 16); + const landscape = PreviewManager.fitPictureInPictureContentSize(portrait, 16 / 9); + + expect(portrait).toEqual([294, 523]); + expect(landscape).toEqual([523, 294]); + }); +}); + const { + browserWindowConstructor, createFromPath, fromId, getFocusedWebContents, @@ -29,8 +47,9 @@ const { writeFile, writeImage, } = vi.hoisted(() => ({ + browserWindowConstructor: vi.fn(), createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), - fromId: vi.fn(() => null), + fromId: vi.fn((_id?: number) => null), getFocusedWebContents: vi.fn(() => null), mkdir: vi.fn((_path: string) => undefined), showItemInFolder: vi.fn(), @@ -40,6 +59,7 @@ const { })); vi.mock("electron", () => ({ + BrowserWindow: browserWindowConstructor, clipboard: { writeImage, }, @@ -73,6 +93,10 @@ const environmentLayer = Layer.succeed( DesktopEnvironment.DesktopEnvironment, DesktopEnvironment.DesktopEnvironment.of({ browserArtifactsDir: "/tmp/t3/dev/browser-artifacts", + dirname: "/tmp/t3/desktop", + path: { + join: (...parts: ReadonlyArray) => parts.join("/"), + }, } as DesktopEnvironment.DesktopEnvironment["Service"]), ); @@ -92,7 +116,7 @@ const layer = PreviewManager.layer.pipe( Layer.provideMerge(environmentLayer), Layer.provideMerge(fileSystemLayer), Layer.provideMerge(Path.layer), - Layer.provideMerge(Layer.succeed(HostProcessPlatform, "linux")), + Layer.provideMerge(Layer.succeed(HostProcessPlatform, "darwin")), ); const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError); @@ -106,8 +130,73 @@ const withManager = ( return yield* use(manager); }).pipe(Effect.provide(layer), Effect.scoped); +interface TestCapturedPreviewImage { + readonly toJPEG: () => Buffer; + readonly getSize: () => { readonly width: number; readonly height: number }; +} + +const makeTestPreviewWebContents = ( + capturePage: () => Promise, + id = 42, +) => + ({ + id, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + capturePage, + }) as never; + +const makeTestPictureInPictureWindow = (loadURL: () => Promise = async () => undefined) => { + const listeners = new Map void>(); + const send = vi.fn(); + let destroyed = false; + const pictureInPictureWindow = { + isDestroyed: vi.fn(() => destroyed), + once: vi.fn((event: string, listener: () => void) => { + listeners.set(event, listener); + }), + setAlwaysOnTop: vi.fn(), + setVisibleOnAllWorkspaces: vi.fn(), + setAspectRatio: vi.fn(), + getContentSize: vi.fn(() => [480, 320]), + setContentSize: vi.fn(), + loadURL: vi.fn(loadURL), + showInactive: vi.fn(() => { + if (destroyed) throw new Error("Picture-in-picture window is closed."); + }), + close: vi.fn(() => { + if (destroyed) return; + destroyed = true; + listeners.get("closed")?.(); + }), + webContents: { + send, + }, + }; + return { pictureInPictureWindow, send }; +}; + describe("PreviewManager", () => { beforeEach(() => { + browserWindowConstructor.mockReset(); fromId.mockClear(); getFocusedWebContents.mockReset(); getFocusedWebContents.mockReturnValue(null); @@ -438,6 +527,80 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("blocks late webview and capture starts during tab close", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("close-race-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const firstWebContents = makeTestPreviewWebContents(capturePage, 42); + const replacementWebContents = makeTestPreviewWebContents(capturePage, 43); + const replacementListenerSpies = replacementWebContents as unknown as { + readonly on: ReturnType; + readonly off: ReturnType; + readonly ipc: { readonly off: ReturnType }; + }; + fromId.mockImplementation((id) => { + if (id === 42) return firstWebContents; + if (id === 43) return replacementWebContents; + return null; + }); + const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + + yield* manager.createTab("tab_close_register_race"); + yield* manager.registerWebview("tab_close_register_race", 42); + yield* manager.openPictureInPicture("tab_close_register_race"); + + const closeCleanupPaused = yield* Deferred.make(); + const continueCloseCleanup = yield* Deferred.make(); + yield* manager.subscribeStateChanges((_tabId, state) => + !state.pictureInPicture && state.webContentsId === 42 + ? Deferred.succeed(closeCleanupPaused, undefined).pipe( + Effect.andThen(Deferred.await(continueCloseCleanup)), + ) + : Effect.void, + ); + + const closeFiber = yield* manager + .closeTab("tab_close_register_race") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(closeCleanupPaused); + const recreateFiber = yield* manager + .createTab("tab_close_register_race") + .pipe(Effect.forkChild({ startImmediately: true })); + const registrationFiber = yield* manager + .registerWebview("tab_close_register_race", 43) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(replacementListenerSpies.on).not.toHaveBeenCalled(); + yield* manager.closeTab("tab_close_register_race"); + const recordingExit = yield* Effect.exit(manager.startRecording("tab_close_register_race")); + yield* Deferred.succeed(continueCloseCleanup, undefined); + yield* Fiber.join(closeFiber); + const recreated = yield* Fiber.join(recreateFiber); + const registrationExit = yield* Fiber.await(registrationFiber); + + for (const exit of [registrationExit, recordingExit]) { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) continue; + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewTabNotFoundError", + tabId: "tab_close_register_race", + }); + } + expect(replacementListenerSpies.on).not.toHaveBeenCalled(); + expect(replacementListenerSpies.off).not.toHaveBeenCalled(); + expect(replacementListenerSpies.ipc.off).not.toHaveBeenCalled(); + expect(capturePage).toHaveBeenCalledOnce(); + expect(recreated.webContentsId).toBeNull(); + }), + ), + ); + effectIt.effect("keeps a main-frame load failure visible until a retry starts", () => withManager((manager) => Effect.gen(function* () { @@ -603,6 +766,614 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("captures hidden preview recordings independently for concurrent tabs", () => + withManager((manager) => + Effect.gen(function* () { + const firstJpeg = Buffer.from("first-recording-frame"); + const secondJpeg = Buffer.from("second-recording-frame"); + const firstCapturePage = vi.fn(async () => ({ + toJPEG: () => firstJpeg, + getSize: () => ({ width: 800, height: 600 }), + })); + const secondCapturePage = vi.fn(async () => ({ + toJPEG: () => secondJpeg, + getSize: () => ({ width: 390, height: 844 }), + })); + const firstSendCommand = vi.fn(async () => undefined); + const secondSendCommand = vi.fn(async () => undefined); + const makeWebContents = ( + id: number, + capturePage: typeof firstCapturePage, + sendCommand: typeof firstSendCommand, + ) => + ({ + id, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => `https://example.com/${id}`, + getTitle: () => `Example ${id}`, + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + capturePage, + }) as never; + const webContentsById = new Map([ + [41, makeWebContents(41, firstCapturePage, firstSendCommand)], + [42, makeWebContents(42, secondCapturePage, secondSendCommand)], + ]); + fromId.mockImplementation((id) => + id === undefined ? null : (webContentsById.get(id) ?? null), + ); + const frames: DesktopPreviewRecordingFrame[] = []; + + yield* manager.subscribeRecordingFrames((frame) => + Effect.sync(() => { + frames.push(frame); + }), + ); + yield* manager.createTab("tab_1"); + yield* manager.createTab("tab_2"); + yield* manager.registerWebview("tab_1", 41); + yield* manager.registerWebview("tab_2", 42); + yield* Effect.all([manager.startRecording("tab_1"), manager.startRecording("tab_2")], { + concurrency: 2, + discard: true, + }); + + expect(firstCapturePage).toHaveBeenCalledOnce(); + expect(secondCapturePage).toHaveBeenCalledOnce(); + expect(frames).toHaveLength(2); + expect(frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tabId: "tab_1", + data: firstJpeg.toString("base64"), + width: 800, + height: 600, + }), + expect.objectContaining({ + tabId: "tab_2", + data: secondJpeg.toString("base64"), + width: 390, + height: 844, + }), + ]), + ); + expect(firstSendCommand).not.toHaveBeenCalledWith( + "Page.startScreencast", + expect.anything(), + ); + expect(secondSendCommand).not.toHaveBeenCalledWith( + "Page.startScreencast", + expect.anything(), + ); + + yield* Effect.all([manager.stopRecording("tab_1"), manager.stopRecording("tab_2")], { + concurrency: 2, + discard: true, + }); + }), + ), + ); + + effectIt.effect("drops a captured frame when the tab webview changes during capture", () => + withManager((manager) => + Effect.gen(function* () { + const staleImage: TestCapturedPreviewImage = { + toJPEG: vi.fn(() => Buffer.from("stale-recording-frame")), + getSize: vi.fn(() => ({ width: 1280, height: 720 })), + }; + let markCaptureStarted!: () => void; + const captureStarted = new Promise((resolve) => { + markCaptureStarted = resolve; + }); + let resolveCapture: ((image: TestCapturedPreviewImage) => void) | undefined; + const staleCapturePage = vi.fn(() => { + markCaptureStarted(); + return new Promise((resolve) => { + resolveCapture = resolve; + }); + }); + const replacementCapturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("replacement-recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const initialWebContents = makeTestPreviewWebContents(staleCapturePage, 42); + const replacementWebContents = makeTestPreviewWebContents(replacementCapturePage, 43); + fromId.mockImplementation((webContentsId?: number) => { + if (webContentsId === 42) return initialWebContents; + if (webContentsId === 43) return replacementWebContents; + return null; + }); + const frames: DesktopPreviewRecordingFrame[] = []; + + yield* manager.subscribeRecordingFrames((frame) => + Effect.sync(() => { + frames.push(frame); + }), + ); + yield* manager.createTab("tab_capture_replaced"); + yield* manager.registerWebview("tab_capture_replaced", 42); + const recordingFiber = yield* manager + .startRecording("tab_capture_replaced") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => captureStarted); + + yield* manager.registerWebview("tab_capture_replaced", 43); + resolveCapture?.(staleImage); + yield* Fiber.join(recordingFiber); + + expect(staleImage.getSize).not.toHaveBeenCalled(); + expect(staleImage.toJPEG).not.toHaveBeenCalled(); + expect(frames).toHaveLength(0); + expect(replacementCapturePage).not.toHaveBeenCalled(); + + yield* manager.stopRecording("tab_capture_replaced"); + }), + ), + ); + + effectIt.effect("keeps an in-flight frame when a capture consumer is added", () => + withManager((manager) => + Effect.gen(function* () { + const image: TestCapturedPreviewImage = { + toJPEG: vi.fn(() => Buffer.from("shared-in-flight-frame")), + getSize: vi.fn(() => ({ width: 1280, height: 720 })), + }; + let markCaptureStarted!: () => void; + const captureStarted = new Promise((resolve) => { + markCaptureStarted = resolve; + }); + let resolveCapture: ((captured: TestCapturedPreviewImage) => void) | undefined; + const capturePage = vi.fn(() => { + markCaptureStarted(); + return new Promise((resolve) => { + resolveCapture = resolve; + }); + }); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + const recordingFrames: DesktopPreviewRecordingFrame[] = []; + yield* manager.subscribeRecordingFrames((frame) => + Effect.sync(() => { + recordingFrames.push(frame); + }), + ); + + yield* manager.createTab("tab_capture_consumer_added"); + yield* manager.registerWebview("tab_capture_consumer_added", 42); + const recordingFiber = yield* manager + .startRecording("tab_capture_consumer_added") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => captureStarted); + + yield* manager.openPictureInPicture("tab_capture_consumer_added"); + resolveCapture?.(image); + yield* Fiber.join(recordingFiber); + + expect(recordingFrames).toHaveLength(1); + expect(send).toHaveBeenCalledWith( + "desktop:preview-pip-frame", + expect.objectContaining({ + tabId: "tab_capture_consumer_added", + data: Buffer.from("shared-in-flight-frame").toString("base64"), + }), + ); + + yield* manager.stopRecording("tab_capture_consumer_added"); + yield* manager.closePictureInPicture("tab_capture_consumer_added"); + }), + ), + ); + + effectIt.effect("shares background frame capture between recording and picture-in-picture", () => + withManager((manager) => + Effect.gen(function* () { + const jpeg = Buffer.from("shared-preview-frame"); + const capturePage = vi.fn(async () => ({ + toJPEG: () => jpeg, + getSize: () => ({ width: 1280, height: 720 }), + })); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + capturePage, + } as never); + + const pictureInPictureListeners = new Map void>(); + const pictureInPictureSend = vi.fn(); + const pictureInPictureWindow = { + isDestroyed: vi.fn(() => false), + once: vi.fn((event: string, listener: () => void) => { + pictureInPictureListeners.set(event, listener); + }), + setAlwaysOnTop: vi.fn(), + setVisibleOnAllWorkspaces: vi.fn(), + setAspectRatio: vi.fn(), + getContentSize: vi.fn(() => [480, 320] as [number, number]), + setContentSize: vi.fn(), + loadURL: vi.fn(async () => undefined), + showInactive: vi.fn(), + close: vi.fn(() => { + pictureInPictureListeners.get("closed")?.(); + }), + webContents: { + send: pictureInPictureSend, + }, + }; + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + const states: PreviewManager.PreviewTabState[] = []; + const recordingFrames: DesktopPreviewRecordingFrame[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.subscribeRecordingFrames((frame) => + Effect.sync(() => { + recordingFrames.push(frame); + }), + ); + yield* manager.createTab("tab_pip"); + yield* manager.registerWebview("tab_pip", 42); + yield* manager.openPictureInPicture("tab_pip"); + + expect(browserWindowConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + alwaysOnTop: true, + show: false, + skipTaskbar: true, + webPreferences: expect.objectContaining({ + preload: "/tmp/t3/desktop/preview-pip-preload.cjs", + backgroundThrottling: false, + }), + }), + ); + expect(pictureInPictureWindow.showInactive).toHaveBeenCalledOnce(); + expect(pictureInPictureWindow.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, { + visibleOnFullScreen: true, + skipTransformProcessType: true, + }); + expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]); + expect(pictureInPictureWindow.setContentSize).toHaveBeenCalledWith(523, 294, false); + expect(pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[0]).toBeLessThan( + pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0] ?? 0, + ); + expect(pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0]).toBeLessThan( + pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[1] ?? 0, + ); + expect(pictureInPictureSend).toHaveBeenCalledWith( + "desktop:preview-pip-frame", + expect.objectContaining({ + tabId: "tab_pip", + data: jpeg.toString("base64"), + width: 1280, + height: 720, + }), + ); + expect(states.at(-1)?.pictureInPicture).toBe(true); + expect(capturePage).toHaveBeenCalledOnce(); + + yield* manager.startRecording("tab_pip"); + expect(capturePage).toHaveBeenCalledOnce(); + expect(recordingFrames).toHaveLength(0); + + yield* TestClock.adjust(100); + expect(capturePage).toHaveBeenCalledTimes(2); + expect(recordingFrames).toHaveLength(1); + + yield* manager.stopRecording("tab_pip"); + const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length; + yield* TestClock.adjust(100); + expect(capturePage).toHaveBeenCalledTimes(3); + expect(pictureInPictureSend.mock.calls.length).toBeGreaterThan( + framesBeforePictureInPictureOnlyTick, + ); + expect(recordingFrames).toHaveLength(1); + + yield* manager.closePictureInPicture("tab_pip"); + expect(pictureInPictureWindow.close).toHaveBeenCalledOnce(); + expect(states.at(-1)?.pictureInPicture).toBe(false); + const capturesAfterClose = capturePage.mock.calls.length; + yield* TestClock.adjust(200); + expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose); + }), + ), + ); + + effectIt.effect("retries a cold hidden-tab capture without dropping recording", () => + withManager((manager) => + Effect.gen(function* () { + const jpeg = Buffer.from("recovered-preview-frame"); + const capturePage = vi.fn(async () => ({ + toJPEG: () => jpeg, + getSize: () => ({ width: 1280, height: 720 }), + })); + capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const frames: DesktopPreviewRecordingFrame[] = []; + + yield* manager.subscribeRecordingFrames((frame) => + Effect.sync(() => { + frames.push(frame); + }), + ); + yield* manager.createTab("tab_cold_capture"); + yield* manager.registerWebview("tab_cold_capture", 42); + + yield* manager.startRecording("tab_cold_capture"); + + expect(capturePage).toHaveBeenCalledOnce(); + expect(frames).toHaveLength(0); + + yield* TestClock.adjust(100); + + expect(capturePage).toHaveBeenCalledTimes(2); + expect(frames).toEqual([ + expect.objectContaining({ + tabId: "tab_cold_capture", + data: jpeg.toString("base64"), + width: 1280, + height: 720, + }), + ]); + + yield* manager.stopRecording("tab_cold_capture"); + }), + ), + ); + + effectIt.effect("drops empty frames before picture-in-picture delivery", () => + withManager((manager) => + Effect.gen(function* () { + const validImage: TestCapturedPreviewImage = { + toJPEG: () => Buffer.from("valid-preview-frame"), + getSize: () => ({ width: 1280, height: 720 }), + }; + const capturePage = vi.fn(async () => validImage); + capturePage.mockResolvedValueOnce({ + toJPEG: () => Buffer.from("empty-preview-frame"), + getSize: () => ({ width: 0, height: 0 }), + }); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + + yield* manager.createTab("tab_empty_frame"); + yield* manager.registerWebview("tab_empty_frame", 42); + yield* manager.openPictureInPicture("tab_empty_frame"); + + expect(capturePage).toHaveBeenCalledOnce(); + expect(pictureInPictureWindow.setAspectRatio).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + + yield* TestClock.adjust(100); + + expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]); + expect(send).toHaveBeenCalledOnce(); + yield* manager.closePictureInPicture("tab_empty_frame"); + }), + ), + ); + + effectIt.effect("does not publish picture-in-picture readiness after window teardown", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("closing-preview-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); + pictureInPictureWindow.showInactive.mockImplementationOnce(() => { + pictureInPictureWindow.close(); + }); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + + yield* manager.createTab("tab_pip_teardown"); + yield* manager.registerWebview("tab_pip_teardown", 42); + const openExit = yield* Effect.exit(manager.openPictureInPicture("tab_pip_teardown")); + + expect(Exit.hasInterrupts(openExit)).toBe(true); + expect(pictureInPictureWindow.close).toHaveBeenCalledOnce(); + expect(states.some((state) => state.pictureInPicture)).toBe(false); + expect(states.at(-1)?.pictureInPicture).toBe(false); + }), + ), + ); + + effectIt.effect("closes an initializing picture-in-picture without blocking later opens", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("serialized-preview-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const { pictureInPictureWindow: initializingWindow } = makeTestPictureInPictureWindow( + () => + new Promise(() => { + // Simulate a renderer load that never settles. + }), + ); + const { pictureInPictureWindow: reopenedWindow } = makeTestPictureInPictureWindow(); + browserWindowConstructor + .mockImplementationOnce(function () { + return initializingWindow; + }) + .mockImplementationOnce(function () { + return reopenedWindow; + }); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_concurrent_pip"); + yield* manager.registerWebview("tab_concurrent_pip", 42); + + const firstOpen = yield* manager + .openPictureInPicture("tab_concurrent_pip") + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + const secondOpen = yield* manager + .openPictureInPicture("tab_concurrent_pip") + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + const close = yield* manager + .closePictureInPicture("tab_concurrent_pip") + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + + expect(browserWindowConstructor).toHaveBeenCalledOnce(); + expect(initializingWindow.loadURL).toHaveBeenCalledOnce(); + expect(initializingWindow.close).toHaveBeenCalledOnce(); + const [firstOpenExit, secondOpenExit] = yield* Effect.all([ + Fiber.await(firstOpen), + Fiber.await(secondOpen), + ]); + yield* Fiber.join(close); + + expect(Exit.hasInterrupts(firstOpenExit)).toBe(true); + expect(Exit.hasInterrupts(secondOpenExit)).toBe(true); + expect(initializingWindow.showInactive).not.toHaveBeenCalled(); + expect(capturePage).not.toHaveBeenCalled(); + expect(states.at(-1)?.pictureInPicture).toBe(false); + + yield* manager.openPictureInPicture("tab_concurrent_pip"); + + expect(browserWindowConstructor).toHaveBeenCalledTimes(2); + expect(reopenedWindow.showInactive).toHaveBeenCalledOnce(); + expect(capturePage).toHaveBeenCalledOnce(); + expect(states.at(-1)?.pictureInPicture).toBe(true); + + yield* manager.closePictureInPicture("tab_concurrent_pip"); + + expect(browserWindowConstructor).toHaveBeenCalledTimes(2); + expect(reopenedWindow.close).toHaveBeenCalledOnce(); + expect(states.at(-1)?.pictureInPicture).toBe(false); + const capturesAfterClose = capturePage.mock.calls.length; + yield* TestClock.adjust(200); + expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose); + }), + ), + ); + + effectIt.effect("rejects picture-in-picture when its webview changes during initialization", () => + withManager((manager) => + Effect.gen(function* () { + const initialCapturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("stale-preview-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const replacementCapturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("replacement-preview-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const initialWebContents = makeTestPreviewWebContents(initialCapturePage, 42); + const replacementWebContents = makeTestPreviewWebContents(replacementCapturePage, 43); + fromId.mockImplementation((webContentsId?: number) => { + if (webContentsId === 42) return initialWebContents; + if (webContentsId === 43) return replacementWebContents; + return null; + }); + let resolveLoad: (() => void) | undefined; + const { pictureInPictureWindow } = makeTestPictureInPictureWindow( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + + yield* manager.createTab("tab_replaced_webview"); + yield* manager.registerWebview("tab_replaced_webview", 42); + const open = yield* manager + .openPictureInPicture("tab_replaced_webview") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(pictureInPictureWindow.loadURL).toHaveBeenCalledOnce(); + expect(resolveLoad).toBeDefined(); + const concurrentOpen = yield* manager + .openPictureInPicture("tab_replaced_webview") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + + yield* manager.registerWebview("tab_replaced_webview", 43); + resolveLoad?.(); + + const openExits = yield* Effect.all([Fiber.await(open), Fiber.await(concurrentOpen)]); + for (const openExit of openExits) { + expect(Exit.isFailure(openExit)).toBe(true); + if (Exit.isSuccess(openExit)) continue; + const error = Option.getOrThrow(Cause.findErrorOption(openExit.cause)); + expect(error).toMatchObject({ + _tag: "PreviewOperationError", + operation: "pictureInPicture.validateWebContents", + tabId: "tab_replaced_webview", + webContentsId: 42, + }); + } + expect(browserWindowConstructor).toHaveBeenCalledOnce(); + expect(pictureInPictureWindow.close).toHaveBeenCalledOnce(); + expect(pictureInPictureWindow.showInactive).not.toHaveBeenCalled(); + expect(initialCapturePage).not.toHaveBeenCalled(); + expect(replacementCapturePage).not.toHaveBeenCalled(); + }), + ), + ); + effectIt.effect("keeps element picking active during subframe navigation", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 6c942d4ccb9a..4321cf9dcfb3 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -28,21 +28,16 @@ import type { } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; -import { - type BrowserWindow, - type Session, - clipboard, - nativeImage, - shell, - webContents, -} from "electron"; +import { BrowserWindow, type Session, clipboard, nativeImage, shell, webContents } from "electron"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -53,6 +48,7 @@ import * as Scope from "effect/Scope"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, @@ -85,6 +81,7 @@ export interface PreviewTabState { canGoBack: boolean; canGoForward: boolean; zoomFactor: number; + pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; updatedAt: string; @@ -101,6 +98,13 @@ const MAX_EVALUATION_BYTES = 64_000; const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_INTERACTIVE_ELEMENTS = 200; const MAX_SCREENSHOT_WIDTH = 1280; +const RECORDING_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); +const RECORDING_JPEG_QUALITY = 80; +const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; +const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320; +const PICTURE_IN_PICTURE_MIN_WIDTH = 240; +const PICTURE_IN_PICTURE_MIN_HEIGHT = 160; +const PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON = 0.002; const DIAGNOSTIC_BUFFER_LIMIT = 200; const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; const AGENT_CURSOR_MOVE_MS = 160; @@ -126,6 +130,54 @@ const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { fontMono: "ui-monospace, monospace", }; +export const buildPreviewPictureInPictureDataUrl = (): string => { + const html = ` + + + + + + + + + Live browser preview + + +`; + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +}; + +export const fitPictureInPictureContentSize = ( + current: ReadonlyArray, + aspectRatio: number, +): readonly [width: number, height: number] => { + const currentWidth = Math.max(1, current[0] ?? PICTURE_IN_PICTURE_INITIAL_WIDTH); + const currentHeight = Math.max(1, current[1] ?? PICTURE_IN_PICTURE_INITIAL_HEIGHT); + const currentArea = currentWidth * currentHeight; + let width = Math.sqrt(currentArea * aspectRatio); + let height = width / aspectRatio; + const minimumScale = Math.max( + 1, + PICTURE_IN_PICTURE_MIN_WIDTH / width, + PICTURE_IN_PICTURE_MIN_HEIGHT / height, + ); + width *= minimumScale; + height *= minimumScale; + return [Math.round(width), Math.round(height)]; +}; + const artifactSiteSlug = (rawUrl: string): string => { try { const url = new URL(rawUrl); @@ -296,6 +348,20 @@ interface ManagedListeners { readonly scope: Scope.Closeable; } +type FrameCaptureConsumer = "picture-in-picture" | "recording"; + +interface FrameCaptureSession { + readonly scope: Scope.Closeable; + readonly consumers: ReadonlySet; +} + +interface PictureInPictureSession { + readonly window: BrowserWindow; + readonly webContentsId: number; + readonly ready: Deferred.Deferred; + readonly initializationScope: Scope.Closeable; +} + interface PickSession { readonly cancel: Effect.Effect; } @@ -380,6 +446,7 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* ( artifactDirectory: string, + pictureInPicturePreloadPath: string, ) { const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcessPlatform; @@ -415,7 +482,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function >(new Map()); const actionSequenceRef = yield* Ref.make(0); const pointerSequenceRef = yield* Ref.make(0); - const recordingTabIdRef = yield* Ref.make>(Option.none()); + const frameCaptureSessionsRef = yield* SynchronizedRef.make< + ReadonlyMap + >(new Map()); + const pictureInPictureSessionsRef = yield* SynchronizedRef.make< + ReadonlyMap + >(new Map()); + const pictureInPictureAspectRatiosRef = yield* Ref.make>(new Map()); + const pictureInPictureMutationSemaphore = yield* Semaphore.make(1); + const closingTabIdsRef = yield* Ref.make>(new Set()); + const tabLifecycleLocks = new Map< + string, + { readonly semaphore: Semaphore.Semaphore; users: number } + >(); + const tabLifecycleGenerations = new Map(); const attempt = (errorContext: PreviewOperationContext, evaluate: () => A) => Effect.try({ @@ -446,6 +526,58 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function update(copy); return copy; }; + const withTabLifecycleLock = ( + tabId: string, + effect: Effect.Effect, + ): Effect.Effect => + Effect.suspend(() => { + const lifecycle = tabLifecycleLocks.get(tabId) ?? { + semaphore: Semaphore.makeUnsafe(1), + users: 0, + }; + lifecycle.users += 1; + tabLifecycleLocks.set(tabId, lifecycle); + return lifecycle.semaphore.withPermit(effect).pipe( + Effect.ensuring( + Effect.sync(() => { + lifecycle.users -= 1; + if (lifecycle.users === 0 && tabLifecycleLocks.get(tabId) === lifecycle) { + tabLifecycleLocks.delete(tabId); + } + }), + ), + ); + }); + const stopFrameCapture = Effect.fn("PreviewManager.stopFrameCapture")(function* ( + tabId: string, + consumer: FrameCaptureConsumer, + ) { + const captureScope = yield* SynchronizedRef.modify(frameCaptureSessionsRef, (sessions) => { + const current = sessions.get(tabId); + if (!current || !current.consumers.has(consumer)) { + return [undefined, sessions] as const; + } + const consumers = new Set(current.consumers); + consumers.delete(consumer); + if (consumers.size > 0) { + return [ + undefined, + replaceMap(sessions, (copy) => { + copy.set(tabId, { ...current, consumers }); + }), + ] as const; + } + return [ + current.scope, + replaceMap(sessions, (copy) => { + copy.delete(tabId); + }), + ] as const; + }); + if (captureScope) { + yield* Scope.close(captureScope, Exit.void).pipe(Effect.ignore); + } + }); const deliverEvent = ( eventKind: "state-change" | "recording-frame" | "pointer-event", @@ -539,15 +671,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ), ); - const tabIdForWebContents = Effect.fn("PreviewManager.tabIdForWebContents")(function* ( - webContentsId: number, - ) { - const tabs = yield* SynchronizedRef.get(tabsRef); - return ( - Array.from(tabs.entries()).find(([, tab]) => tab.webContentsId === webContentsId)?.[0] ?? null - ); - }); - const pushBounded = (buffer: ReadonlyArray, entry: A): ReadonlyArray => [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT); @@ -731,42 +854,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const scope = yield* Scope.fork(parentScope, "sequential"); const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")( function* (method: string, params: Record) { - if (method === "Page.screencastFrame") { - const sessionId = params["sessionId"]; - if (typeof sessionId === "number") { - yield* attemptPromise( - { - operation: "ackScreencastFrame", - webContentsId: wc.id, - }, - () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), - ).pipe(Effect.ignore); - } - const tabId = yield* tabIdForWebContents(wc.id); - const metadata = - typeof params["metadata"] === "object" && params["metadata"] !== null - ? (params["metadata"] as Record) - : {}; - if (tabId && typeof params["data"] === "string") { - const receivedAt = yield* currentIso; - const listeners = yield* Ref.get(recordingFrameListenersRef); - const frame: DesktopPreviewRecordingFrame = { - tabId, - data: params["data"], - width: - typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, - height: - typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, - receivedAt, - }; - yield* Effect.forEach( - listeners, - (listener) => - deliverEvent("recording-frame", frame.tabId, () => listener(frame)), - { discard: true }, - ); - } - } yield* captureDiagnosticMessage(wc.id, method, params); }, ); @@ -1276,71 +1363,132 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function window: BrowserWindow, ) { yield* Ref.set(mainWindowRef, Option.some(window)); + window.once("closed", () => { + runFork(closeAllPictureInPicture()); + }); }); - const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) { + const createTabUnlocked = Effect.fn("PreviewManager.createTabUnlocked")(function* ( + tabId: string, + ) { const updatedAt = yield* currentIso; - const state = yield* SynchronizedRef.modify(tabsRef, (tabs) => { - const existing = tabs.get(tabId); - if (existing) return [existing, tabs] as const; - const initial: PreviewTabState = { - tabId, - webContentsId: null, - navStatus: { kind: "Idle" }, - canGoBack: false, - canGoForward: false, - zoomFactor: DEFAULT_ZOOM_FACTOR, - colorScheme: "system", - controller: "none", - updatedAt, - }; + const result = yield* SynchronizedRef.modify( + tabsRef, + ( + tabs, + ): readonly [ + { readonly state: PreviewTabState; readonly created: boolean }, + ReadonlyMap, + ] => { + const existing = tabs.get(tabId); + if (existing) return [{ state: existing, created: false }, tabs] as const; + const initial: PreviewTabState = { + tabId, + webContentsId: null, + navStatus: { kind: "Idle" }, + canGoBack: false, + canGoForward: false, + zoomFactor: DEFAULT_ZOOM_FACTOR, + pictureInPicture: false, + colorScheme: "system", + controller: "none", + updatedAt, + }; + return [ + { state: initial, created: true }, + replaceMap(tabs, (copy) => { + copy.set(tabId, initial); + }), + ] as const; + }, + ); + if (result.created) { + tabLifecycleGenerations.set(tabId, (tabLifecycleGenerations.get(tabId) ?? 0) + 1); + } + yield* emit(tabId, result.state); + return result.state; + }); + + const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) { + return yield* withTabLifecycleLock(tabId, createTabUnlocked(tabId)); + }); + + const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) { + if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return; + yield* Effect.all( + [ + cancelPickElement(tabId), + closePictureInPicture(tabId), + stopFrameCapture(tabId, "recording"), + ], + { + concurrency: 3, + discard: true, + }, + ); + const tab = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if (!current) return [Option.none(), tabs] as const; return [ - initial, + Option.some(current), replaceMap(tabs, (copy) => { - copy.set(tabId, initial); + copy.delete(tabId); }), ] as const; }); - yield* emit(tabId, state); - return state; - }); - - const closeTab = Effect.fn("PreviewManager.closeTab")(function* (tabId: string) { - const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); - if (!tab) return; - yield* cancelPickElement(tabId); - if (tab.webContentsId != null) { + if (Option.isNone(tab)) return; + const closedTab = tab.value; + if (closedTab.webContentsId != null) { yield* Effect.all( - [detachControlSession(tab.webContentsId), detachListeners(tab.webContentsId)], + [detachControlSession(closedTab.webContentsId), detachListeners(closedTab.webContentsId)], { concurrency: 2, discard: true }, ); } const updatedAt = yield* currentIso; const closed: PreviewTabState = { - ...tab, + ...closedTab, webContentsId: null, navStatus: { kind: "Idle" }, canGoBack: false, canGoForward: false, zoomFactor: DEFAULT_ZOOM_FACTOR, + pictureInPicture: false, colorScheme: "system", controller: "none", updatedAt, }; - yield* SynchronizedRef.update(tabsRef, (tabs) => - replaceMap(tabs, (copy) => { - copy.delete(tabId); - }), - ); yield* emit(tabId, closed); }); - const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* ( + const closeTab = Effect.fn("PreviewManager.closeTab")(function* (tabId: string) { + const claimed = yield* Ref.modify(closingTabIdsRef, (closingTabIds) => { + if (closingTabIds.has(tabId)) return [false, closingTabIds] as const; + return [true, new Set([...closingTabIds, tabId])] as const; + }); + if (!claimed) return; + return yield* withTabLifecycleLock(tabId, closeTabUnlocked(tabId)).pipe( + Effect.ensuring( + Ref.update(closingTabIdsRef, (closingTabIds) => { + if (!closingTabIds.has(tabId)) return closingTabIds; + const next = new Set(closingTabIds); + next.delete(tabId); + return next; + }), + ), + ); + }); + + const registerWebviewUnlocked = Effect.fn("PreviewManager.registerWebviewUnlocked")(function* ( tabId: string, webContentsId: number, + expectedGeneration: number | undefined, ) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); - if (!tab) { + if ( + !tab || + tabLifecycleGenerations.get(tabId) !== expectedGeneration || + (yield* Ref.get(closingTabIdsRef)).has(tabId) + ) { return yield* new PreviewTabNotFoundError({ tabId }); } const wc = webContents.fromId(webContentsId); @@ -1377,53 +1525,71 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { concurrency: 3, discard: true }, ); } + const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if ( + !currentTab || + tabLifecycleGenerations.get(tabId) !== expectedGeneration || + (yield* Ref.get(closingTabIdsRef)).has(tabId) + ) { + return yield* new PreviewTabNotFoundError({ tabId }); + } const zoomFactor = replacedWebContentsId !== null ? yield* attempt( { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => { - wc.setZoomFactor(tab.zoomFactor); - return tab.zoomFactor; + wc.setZoomFactor(currentTab.zoomFactor); + return currentTab.zoomFactor; }, ) : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => wc.getZoomFactor(), ); yield* attachListeners(tabId, wc); - runFork(restoreControlSession(tabId, wc)); const registeredAt = yield* currentIso; - const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => { - const current = tabs.get(tabId); - if (!current) { + const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => + Effect.gen(function* () { + const current = tabs.get(tabId); + if ( + !current || + tabLifecycleGenerations.get(tabId) !== expectedGeneration || + (yield* Ref.get(closingTabIdsRef)).has(tabId) + ) { + return [ + Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(), + tabs, + ] as const; + } + const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; + const next: PreviewTabState = { + ...current, + webContentsId, + navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + zoomFactor, + updatedAt: registeredAt, + }; return [ - Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(), - tabs, + Option.some({ + state: next, + pendingUrl, + }), + replaceMap(tabs, (copy) => { + copy.set(tabId, next); + }), ] as const; - } - const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; - const next: PreviewTabState = { - ...current, - webContentsId, - navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, - canGoBack: wc.navigationHistory.canGoBack(), - canGoForward: wc.navigationHistory.canGoForward(), - zoomFactor, - updatedAt: registeredAt, - }; - return [ - Option.some({ - state: next, - pendingUrl, - }), - replaceMap(tabs, (copy) => { - copy.set(tabId, next); - }), - ] as const; - }); + }), + ); if (Option.isNone(registration)) { + yield* Effect.all([detachControlSession(webContentsId), detachListeners(webContentsId)], { + concurrency: 2, + discard: true, + }); return yield* new PreviewTabNotFoundError({ tabId }); } const { state: registered, pendingUrl } = registration.value; + runFork(restoreControlSession(tabId, wc)); yield* emit(tabId, registered); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), @@ -1443,6 +1609,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }); + const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* ( + tabId: string, + webContentsId: number, + ) { + const expectedGeneration = tabLifecycleGenerations.get(tabId); + return yield* withTabLifecycleLock( + tabId, + registerWebviewUnlocked(tabId, webContentsId, expectedGeneration), + ); + }); + const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => normalizePreviewUrl(rawUrl), @@ -1461,6 +1638,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function canGoBack: current?.canGoBack ?? false, canGoForward: current?.canGoForward ?? false, zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, + pictureInPicture: current?.pictureInPicture ?? false, colorScheme: current?.colorScheme ?? "system", controller: current?.controller ?? "none", updatedAt, @@ -1716,14 +1894,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function // session attaches so a concurrent setColorScheme is not overwritten with // a stale snapshot. const restoreControlSession = (tabId: string, wc: Electron.WebContents) => - ensureControlSession(wc).pipe( - Effect.andThen(SynchronizedRef.get(tabsRef)), - Effect.flatMap((tabs) => { - const colorScheme = tabs.get(tabId)?.colorScheme ?? "system"; - return colorScheme === "system" ? Effect.void : applyColorScheme(tabId, wc, colorScheme); - }), - Effect.ignore, - ); + Effect.gen(function* () { + const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (beforeAttach?.webContentsId !== wc.id) return; + yield* ensureControlSession(wc); + const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (afterAttach?.webContentsId !== wc.id) { + yield* detachControlSession(wc.id); + return; + } + if (afterAttach.colorScheme !== "system") { + yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => + wc.debugger.sendCommand("Emulation.setEmulatedMedia", { + features: [ + { + name: "prefers-color-scheme", + value: afterAttach.colorScheme, + }, + ], + }), + ); + } + }).pipe(Effect.ignore); const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* ( tabId: string, @@ -1801,40 +1993,488 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; }); - const startScreencast = Effect.fn("PreviewManager.startScreencast")(function* ( - send: SendCommand, + const capturePreviewFrame = Effect.fn("PreviewManager.capturePreviewFrame")(function* ( + tabId: string, ) { - yield* send("Page.enable"); - yield* send("Page.startScreencast", { - format: "jpeg", - quality: 80, - maxWidth: 1600, - maxHeight: 1200, - everyNthFrame: 1, + const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + if (!captureSession) return; + const wc = yield* requireWebContents(tabId); + const image = yield* attemptPromise( + { + operation: "frameCapture.capturePage", + tabId, + webContentsId: wc.id, + }, + () => wc.capturePage(), + ); + const currentCaptureSession = yield* Effect.all( + [SynchronizedRef.get(frameCaptureSessionsRef), SynchronizedRef.get(tabsRef)], + { concurrency: 2 }, + ).pipe( + Effect.map(([captureSessions, tabs]) => { + const current = captureSessions.get(tabId); + return current?.scope === captureSession.scope && + tabs.get(tabId)?.webContentsId === wc.id && + !wc.isDestroyed() + ? current + : undefined; + }), + ); + if (!currentCaptureSession) return; + const size = yield* attempt( + { + operation: "frameCapture.measureFrame", + tabId, + webContentsId: wc.id, + }, + () => image.getSize(), + ); + if ( + !Number.isFinite(size.width) || + !Number.isFinite(size.height) || + size.width <= 0 || + size.height <= 0 + ) { + return; + } + const encoded = yield* attempt( + { + operation: "frameCapture.encodeFrame", + tabId, + webContentsId: wc.id, + }, + () => image.toJPEG(RECORDING_JPEG_QUALITY).toString("base64"), + ); + const receivedAt = yield* currentIso; + const frame: DesktopPreviewRecordingFrame = { + tabId, + data: encoded, + width: size.width, + height: size.height, + receivedAt, + }; + const deliveries: Array> = []; + if (currentCaptureSession.consumers.has("recording")) { + const listeners = yield* Ref.get(recordingFrameListenersRef); + deliveries.push( + Effect.forEach( + listeners, + (listener) => deliverEvent("recording-frame", frame.tabId, () => listener(frame)), + { discard: true }, + ), + ); + } + if (currentCaptureSession.consumers.has("picture-in-picture")) { + const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( + tabId, + )?.window; + if (pictureInPictureWindow && !pictureInPictureWindow.isDestroyed()) { + deliveries.push( + Effect.gen(function* () { + const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get( + tabId, + ); + const aspectRatio = frame.width / frame.height; + if ( + previousAspectRatio === undefined || + Math.abs(previousAspectRatio - aspectRatio) > PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON + ) { + yield* attempt( + { + operation: "pictureInPicture.setAspectRatio", + tabId, + webContentsId: wc.id, + }, + () => { + const contentSize = fitPictureInPictureContentSize( + pictureInPictureWindow.getContentSize(), + aspectRatio, + ); + pictureInPictureWindow.setAspectRatio(0); + pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false); + pictureInPictureWindow.setAspectRatio(aspectRatio); + }, + ); + yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => + replaceMap(aspectRatios, (copy) => { + copy.set(tabId, aspectRatio); + }), + ); + } + yield* attempt( + { + operation: "pictureInPicture.deliverFrame", + tabId, + webContentsId: wc.id, + }, + () => { + pictureInPictureWindow.webContents.send( + PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, + frame, + ); + }, + ); + }).pipe( + Effect.catch((error) => + Effect.logWarning("Picture-in-picture frame delivery failed.", { + tabId, + error, + }), + ), + ), + ); + } + } + yield* Effect.all(deliveries, { concurrency: 2, discard: true }); + }); + + const startFrameCapture = Effect.fn("PreviewManager.startFrameCapture")(function* ( + tabId: string, + consumer: FrameCaptureConsumer, + ) { + // Validate the tab synchronously, but treat capturePage failures as + // transient. Chromium can return UnknownVizError while a hidden guest is + // warming its first compositor frame; the scheduled loop should keep the + // consumer alive and recover instead of tearing recording/PiP back down. + yield* requireWebContents(tabId); + const captureNextFrame = Effect.sleep(RECORDING_FRAME_INTERVAL_MS).pipe( + Effect.andThen(capturePreviewFrame(tabId)), + Effect.catch((error) => + Effect.logWarning("Background preview frame capture failed.", { + tabId, + error, + }), + ), + ); + const created = yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => { + return Effect.gen(function* () { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + const current = sessions.get(tabId); + if (current) { + if (current.consumers.has(consumer)) { + return [false, sessions] as const; + } + return [ + false, + replaceMap(sessions, (copy) => { + copy.set(tabId, { + ...current, + consumers: new Set([...current.consumers, consumer]), + }); + }), + ] as const; + } + const scope = yield* Scope.fork(parentScope, "sequential"); + yield* Effect.forkIn(Effect.forever(captureNextFrame), scope); + return [ + true, + replaceMap(sessions, (copy) => { + copy.set(tabId, { + scope, + consumers: new Set([consumer]), + }); + }), + ] as const; + }); + }); + if (!created) return; + yield* capturePreviewFrame(tabId).pipe( + Effect.catch((error) => + Effect.logWarning("Initial background preview frame was not ready; capture will retry.", { + tabId, + consumer, + error, + }), + ), + ); + }); + + const releasePictureInPicture = Effect.fn("PreviewManager.releasePictureInPicture")(function* ( + tabId: string, + expectedSession: PictureInPictureSession, + closeWindow: boolean, + ) { + const removed = yield* SynchronizedRef.modify(pictureInPictureSessionsRef, (sessions) => { + if (sessions.get(tabId) !== expectedSession) { + return [false, sessions] as const; + } + return [ + true, + replaceMap(sessions, (copy) => { + copy.delete(tabId); + }), + ] as const; }); + if (!removed) return; + yield* Deferred.interrupt(expectedSession.ready); + yield* Scope.close(expectedSession.initializationScope, Exit.void).pipe(Effect.ignore); + yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => + replaceMap(aspectRatios, (copy) => { + copy.delete(tabId); + }), + ); + yield* stopFrameCapture(tabId, "picture-in-picture"); + const tabs = yield* SynchronizedRef.get(tabsRef); + if (tabs.has(tabId)) { + yield* update(tabId, { pictureInPicture: false }); + } + if (closeWindow && !expectedSession.window.isDestroyed()) { + yield* attempt({ operation: "pictureInPicture.close", tabId }, () => + expectedSession.window.close(), + ).pipe(Effect.ignore); + } }); - const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { - const recordingTabId = yield* Ref.get(recordingTabIdRef); - if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) { - return yield* new PreviewRecordingAlreadyActiveError({ - requestedTabId: tabId, - activeTabId: recordingTabId.value, + const closePictureInPictureUnlocked = Effect.fn("PreviewManager.closePictureInPictureUnlocked")( + function* (tabId: string) { + const pictureInPictureSession = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( + tabId, + ); + if (!pictureInPictureSession) { + yield* stopFrameCapture(tabId, "picture-in-picture"); + const tabs = yield* SynchronizedRef.get(tabsRef); + if (tabs.has(tabId)) { + yield* update(tabId, { pictureInPicture: false }); + } + return; + } + yield* releasePictureInPicture(tabId, pictureInPictureSession, true); + }, + ); + + const closePictureInPicture = Effect.fn("PreviewManager.closePictureInPicture")(function* ( + tabId: string, + ) { + yield* pictureInPictureMutationSemaphore.withPermit(closePictureInPictureUnlocked(tabId)); + }); + + const closeAllPictureInPicture = Effect.fn("PreviewManager.closeAllPictureInPicture")( + function* () { + const sessions = yield* SynchronizedRef.get(pictureInPictureSessionsRef); + yield* Effect.forEach(sessions.keys(), closePictureInPicture, { + concurrency: "unbounded", + discard: true, }); + }, + ); + + const openPictureInPicture = Effect.fn("PreviewManager.openPictureInPicture")(function* ( + tabId: string, + ) { + const claim = yield* pictureInPictureMutationSemaphore.withPermit( + Effect.gen(function* () { + const existing = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); + if (existing && !existing.window.isDestroyed()) { + return { kind: "existing" as const, session: existing }; + } + if (existing) { + yield* releasePictureInPicture(tabId, existing, false); + } + const wc = yield* requireWebContents(tabId); + const title = yield* attempt( + { + operation: "pictureInPicture.readTitle", + tabId, + webContentsId: wc.id, + }, + () => wc.getTitle().trim(), + ); + const pictureInPictureWindow = yield* attempt( + { + operation: "pictureInPicture.create", + tabId, + webContentsId: wc.id, + }, + () => + new BrowserWindow({ + width: PICTURE_IN_PICTURE_INITIAL_WIDTH, + height: PICTURE_IN_PICTURE_INITIAL_HEIGHT, + minWidth: PICTURE_IN_PICTURE_MIN_WIDTH, + minHeight: PICTURE_IN_PICTURE_MIN_HEIGHT, + title: title.length > 0 ? `Preview · ${title}` : "Browser preview", + show: false, + alwaysOnTop: true, + autoHideMenuBar: true, + fullscreenable: false, + maximizable: false, + minimizable: false, + resizable: true, + skipTaskbar: true, + backgroundColor: "#111111", + ...(hostPlatform === "darwin" ? { type: "panel" as const } : {}), + webPreferences: { + preload: pictureInPicturePreloadPath, + backgroundThrottling: false, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }), + ); + const initializationScope = yield* Scope.fork(parentScope, "sequential"); + const ready = yield* Deferred.make(); + const session: PictureInPictureSession = { + window: pictureInPictureWindow, + webContentsId: wc.id, + ready, + initializationScope, + }; + const onClosed = () => { + runFork( + pictureInPictureMutationSemaphore.withPermit( + releasePictureInPicture(tabId, session, false), + ), + ); + }; + yield* attempt( + { + operation: "pictureInPicture.configure", + tabId, + webContentsId: wc.id, + }, + () => { + pictureInPictureWindow.once("closed", onClosed); + pictureInPictureWindow.setAlwaysOnTop( + true, + hostPlatform === "darwin" ? "floating" : "normal", + ); + if (hostPlatform === "darwin") { + pictureInPictureWindow.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + // Electron otherwise temporarily transforms the entire app into + // a UIElement process, which removes the owning app from the Dock. + skipTransformProcessType: true, + }); + } + }, + ).pipe( + Effect.onError(() => + Effect.all( + [ + Scope.close(initializationScope, Exit.void).pipe(Effect.ignore), + attempt({ operation: "pictureInPicture.close", tabId }, () => + pictureInPictureWindow.close(), + ).pipe(Effect.ignore), + ], + { discard: true }, + ), + ), + ); + yield* SynchronizedRef.update(pictureInPictureSessionsRef, (sessions) => + replaceMap(sessions, (copy) => { + copy.set(tabId, session); + }), + ); + return { kind: "created" as const, session }; + }), + ); + const pictureInPictureSession = claim.session; + if (claim.kind === "existing") { + yield* Deferred.await(pictureInPictureSession.ready); + return yield* pictureInPictureMutationSemaphore.withPermit( + Effect.gen(function* () { + const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); + if (current !== pictureInPictureSession || pictureInPictureSession.window.isDestroyed()) { + return yield* new PreviewOperationError({ + operation: "pictureInPicture.showExisting", + tabId, + webContentsId: pictureInPictureSession.webContentsId, + cause: new Error("Picture-in-picture session closed before it became visible."), + }); + } + yield* attempt( + { + operation: "pictureInPicture.showExisting", + tabId, + webContentsId: pictureInPictureSession.webContentsId, + }, + () => pictureInPictureSession.window.showInactive(), + ); + }), + ); } - const wc = yield* requireWebContents(tabId); - yield* withControlSession(tabId, wc, "recording.start", startScreencast); - yield* Ref.set(recordingTabIdRef, Option.some(tabId)); + + const initialize = Effect.gen(function* () { + yield* attemptPromise( + { + operation: "pictureInPicture.load", + tabId, + webContentsId: pictureInPictureSession.webContentsId, + }, + () => pictureInPictureSession.window.loadURL(buildPreviewPictureInPictureDataUrl()), + ); + const currentWebContents = yield* requireWebContents(tabId); + if ( + currentWebContents.id !== pictureInPictureSession.webContentsId || + currentWebContents.isDestroyed() + ) { + return yield* new PreviewOperationError({ + operation: "pictureInPicture.validateWebContents", + tabId, + webContentsId: pictureInPictureSession.webContentsId, + cause: new Error("Preview webview changed while picture-in-picture was opening."), + }); + } + yield* startFrameCapture(tabId, "picture-in-picture"); + yield* attempt( + { + operation: "pictureInPicture.show", + tabId, + webContentsId: pictureInPictureSession.webContentsId, + }, + () => pictureInPictureSession.window.showInactive(), + ); + }); + const initializationExit = yield* Effect.gen(function* () { + const initializationFiber = yield* Effect.forkIn( + initialize, + pictureInPictureSession.initializationScope, + ); + return yield* Fiber.await(initializationFiber); + }).pipe( + Effect.onInterrupt(() => + pictureInPictureMutationSemaphore.withPermit( + releasePictureInPicture(tabId, pictureInPictureSession, true), + ), + ), + ); + if (Exit.isSuccess(initializationExit)) { + const published = yield* pictureInPictureMutationSemaphore.withPermit( + Effect.gen(function* () { + const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); + if (current !== pictureInPictureSession || pictureInPictureSession.window.isDestroyed()) { + if (current === pictureInPictureSession) { + yield* releasePictureInPicture(tabId, pictureInPictureSession, false); + } + return false; + } + yield* update(tabId, { pictureInPicture: true }); + yield* Deferred.done(pictureInPictureSession.ready, initializationExit); + return true; + }), + ); + if (published) return; + return yield* Deferred.await(pictureInPictureSession.ready); + } + yield* Deferred.done(pictureInPictureSession.ready, initializationExit); + const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); + if (current === pictureInPictureSession) { + yield* pictureInPictureMutationSemaphore.withPermit( + releasePictureInPicture(tabId, pictureInPictureSession, true), + ); + } + return yield* Effect.failCause(initializationExit.cause); + }); + + const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { + yield* startFrameCapture(tabId, "recording"); }); const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { - const recordingTabId = yield* Ref.get(recordingTabIdRef); - if (Option.isNone(recordingTabId) || recordingTabId.value !== tabId) return; - const wc = yield* requireWebContents(tabId); - yield* withControlSession(tabId, wc, "recording.stop", (send) => - send("Page.stopScreencast").pipe(Effect.asVoid), - ); - yield* Ref.set(recordingTabIdRef, Option.none()); + yield* stopFrameCapture(tabId, "recording"); }); const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( @@ -2582,6 +3222,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function goForward, hardReload, navigate, + openPictureInPicture, openDevTools, pickElement, refresh, @@ -2593,6 +3234,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function setColorScheme, setMainWindow, startRecording, + closePictureInPicture, stopRecording, subscribePointerEvents: (listener: PointerEventListener) => subscribe(pointerEventListenersRef, listener), @@ -2678,18 +3320,6 @@ export class PreviewArtifactImageLoadError extends Schema.TaggedErrorClass()( - "PreviewRecordingAlreadyActiveError", - { - requestedTabId: Schema.String, - activeTabId: Schema.String, - }, -) { - override get message(): string { - return `Cannot record preview tab ${this.requestedTabId} while tab ${this.activeTabId} is already recording`; - } -} - export class PreviewAutomationDevToolsOpenError extends Schema.TaggedErrorClass()( "PreviewAutomationDevToolsOpenError", { webContentsId: Schema.Number }, @@ -2852,7 +3482,6 @@ export const PreviewManagerError = Schema.Union([ PreviewOperationError, PreviewArtifactPathOutsideDirectoryError, PreviewArtifactImageLoadError, - PreviewRecordingAlreadyActiveError, PreviewAutomationDevToolsOpenError, PreviewAutomationDebuggerAttachedError, PreviewAutomationEvaluationError, @@ -2915,6 +3544,8 @@ export class PreviewManager extends Context.Service< ) => Effect.Effect; readonly revealArtifact: (path: string) => Effect.Effect; readonly copyArtifactToClipboard: (path: string) => Effect.Effect; + readonly openPictureInPicture: (tabId: string) => Effect.Effect; + readonly closePictureInPicture: (tabId: string) => Effect.Effect; readonly startRecording: (tabId: string) => Effect.Effect; readonly stopRecording: (tabId: string) => Effect.Effect; readonly saveRecording: ( @@ -2965,7 +3596,10 @@ export class PreviewManager extends Context.Service< export const make = Effect.gen(function* PreviewManagerMake() { const environment = yield* DesktopEnvironment.DesktopEnvironment; const browserSession = yield* BrowserSession.BrowserSession; - const operations = yield* makeNativeOperations(environment.browserArtifactsDir); + const operations = yield* makeNativeOperations( + environment.browserArtifactsDir, + environment.path.join(environment.dirname, "preview-pip-preload.cjs"), + ); return PreviewManager.of({ setMainWindow: operations.setMainWindow, @@ -3023,6 +3657,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { captureScreenshot: operations.captureScreenshot, revealArtifact: operations.revealArtifact, copyArtifactToClipboard: operations.copyArtifactToClipboard, + openPictureInPicture: operations.openPictureInPicture, + closePictureInPicture: operations.closePictureInPicture, startRecording: operations.startRecording, stopRecording: operations.stopRecording, saveRecording: operations.saveRecording, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 14d0ce01ebf2..d1f104bf571e 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -316,6 +316,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n webPreferences: { preload: null, partition: null, + backgroundThrottling: null, sandbox: null, contextIsolation: null, nodeIntegration: null, @@ -431,6 +432,7 @@ describe("DesktopWindow", () => { assert.isUndefined(createdWindowOptions[0]?.x); assert.isUndefined(createdWindowOptions[0]?.y); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); + assert.isFalse(createdWindowOptions[0]?.webPreferences?.backgroundThrottling); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index db4b698434d6..40788009d3b3 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -331,6 +331,7 @@ export const make = Effect.gen(function* () { ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), webPreferences: { preload: environment.preloadPath, + backgroundThrottling: false, contextIsolation: true, nodeIntegration: false, sandbox: true, diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 96e089b91833..9f25204f1630 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -73,5 +73,12 @@ export default defineConfig({ alwaysBundle: (id) => id === "react-grab" || id.startsWith("react-grab/"), }, }, + { + format: "cjs", + outDir: "dist-electron", + sourcemap: true, + outExtensions: () => ({ js: ".cjs" }), + entry: ["src/preview-pip-preload.ts"], + }, ], }); diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 8432f49695a3..440efcee51ee 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -8,9 +8,9 @@ import * as ServerConfig from "../config.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; -import { resolveSessionCookieName } from "./utils.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; +import * as SessionStore from "./SessionStore.ts"; /** Pinned so dev-mode cookie tests can assert the port-scoped name. */ const TEST_SERVER_PORT = 13_773; @@ -23,11 +23,8 @@ const makeServerConfigLayer = (overrides?: Partial while every request still sent t3_session_13773, - // and the tests would fail for a reason unrelated to what they assert. + // Keep the test server deterministic even when the default test layer + // changes its development port. port: TEST_SERVER_PORT, } satisfies ServerConfig.ServerConfig["Service"]; }), @@ -41,15 +38,12 @@ const makeEnvironmentAuthLayer = (overrides?: Partial[0] => ({ cookies: { - // Derived, not hardcoded: the name is port-scoped so concurrent servers - // on one hostname don't share a cookie. Mode and devUrl mirror - // ServerConfig.layerTest, so this resolves to whatever the server reads. - [resolveSessionCookieName({ mode: "web", port: TEST_SERVER_PORT, devUrl: undefined })]: - sessionToken, + [cookieName]: sessionToken, }, headers: {}, }) as unknown as Parameters< @@ -92,6 +86,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { it.effect("issues standard pairing credentials by default", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; const pairingCredential = yield* serverAuth.issuePairingCredential(); const exchanged = yield* serverAuth.createBrowserSession( @@ -99,7 +94,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { requestMetadata, ); const verified = yield* serverAuth.authenticateHttpRequest( - makeCookieRequest(exchanged.sessionToken), + makeCookieRequest(sessions.cookieName, exchanged.sessionToken), ); expect(verified.sessionId.length).toBeGreaterThan(0); @@ -165,6 +160,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { it.effect("issues startup pairing URLs that bootstrap administrative sessions", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773"); const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token"); @@ -178,7 +174,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { const exchanged = yield* serverAuth.createBrowserSession(token ?? "", requestMetadata); const verified = yield* serverAuth.authenticateHttpRequest( - makeCookieRequest(exchanged.sessionToken), + makeCookieRequest(sessions.cookieName, exchanged.sessionToken), ); expect(verified.scopes).toEqual([ @@ -200,13 +196,14 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; const administrativeExchange = yield* serverAuth.createBrowserSession( "desktop-bootstrap-token", requestMetadata, ); const administrativeSession = yield* serverAuth.authenticateHttpRequest( - makeCookieRequest(administrativeExchange.sessionToken), + makeCookieRequest(sessions.cookieName, administrativeExchange.sessionToken), ); const pairingCredential = yield* serverAuth.issuePairingCredential({ label: "Julius iPhone", @@ -223,7 +220,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { }, ); const clientSession = yield* serverAuth.authenticateHttpRequest( - makeCookieRequest(clientExchange.sessionToken), + makeCookieRequest(sessions.cookieName, clientExchange.sessionToken), ); const clientsBeforeRevoke = yield* serverAuth.listClientSessions( administrativeSession.sessionId, diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 0e21ef19c90c..8e4c21710880 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -88,47 +88,50 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("loopback-browser"); expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); - expect(descriptor.sessionCookieName).toBe("t3_session"); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_3773_[a-f0-9]{12}$/); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ mode: "web", host: "127.0.0.1", - port: 13773, + port: 3773, }), ), ), ); - it.effect("scopes web session cookies by port only in development", () => + it.effect("uses remote-reachable policy for wildcard web hosts", () => Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const descriptor = yield* policy.getDescriptor(); - expect(descriptor.sessionCookieName).toBe("t3_session_13773"); + expect(descriptor.policy).toBe("remote-reachable"); + expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); + expect(descriptor.sessionCookieName).toBe("t3_session"); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ mode: "web", - port: 13773, - devUrl: new URL("http://127.0.0.1:5733"), + host: "0.0.0.0", }), ), ), ); - it.effect("uses remote-reachable policy for wildcard web hosts", () => + it.effect("isolates wildcard-bound web development sessions", () => Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const descriptor = yield* policy.getDescriptor(); expect(descriptor.policy).toBe("remote-reachable"); - expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ mode: "web", host: "0.0.0.0", + port: 5775, + devUrl: new URL("http://127.0.0.1:5736"), }), ), ), @@ -140,6 +143,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { const descriptor = yield* policy.getDescriptor(); expect(descriptor.policy).toBe("remote-reachable"); + expect(descriptor.sessionCookieName).toBe("t3_session"); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 28e415767699..9945c69067d7 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -4,8 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; -import { resolveSessionCookieName } from "./utils.ts"; -import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; +import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts"; export class EnvironmentAuthPolicy extends Context.Service< EnvironmentAuthPolicy, @@ -16,7 +15,7 @@ export class EnvironmentAuthPolicy extends Context.Service< export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); + const isRemoteReachable = isRemoteReachableHost(config.host); const policy = config.mode === "desktop" @@ -41,7 +40,9 @@ export const make = Effect.gen(function* () { sessionCookieName: resolveSessionCookieName({ mode: config.mode, port: config.port, - devUrl: config.devUrl, + host: config.host, + instanceKey: config.stateDir, + development: config.devUrl !== undefined, }), }; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index efa811302dc7..40a1c43e0be7 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -470,7 +470,9 @@ export const make = Effect.gen(function* () { const cookieName = resolveSessionCookieName({ mode: serverConfig.mode, port: serverConfig.port, - devUrl: serverConfig.devUrl, + host: serverConfig.host, + instanceKey: serverConfig.stateDir, + development: serverConfig.devUrl !== undefined, }); const emitUpsert = (clientSession: AuthClientSession) => diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index 90dc0f8ddf94..edc58f71131f 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { deriveAuthClientMetadata } from "./utils.ts"; +import { + deriveAuthClientMetadata, + isRemoteReachableHost, + resolveSessionCookieName, +} from "./utils.ts"; describe("deriveAuthClientMetadata", () => { it("labels Electron user agents as Electron instead of Chrome", () => { @@ -52,3 +56,80 @@ describe("deriveAuthClientMetadata", () => { expect(metadata.userAgent).toContain("Electron/36.3.2"); }); }); + +describe("session cookie isolation", () => { + it("isolates loopback web servers by port and server state", () => { + const first = resolveSessionCookieName({ + mode: "web", + port: 5775, + host: "127.0.0.1", + instanceKey: "/tmp/t3-agent-one", + development: true, + }); + const second = resolveSessionCookieName({ + mode: "web", + port: 5775, + host: "127.0.0.1", + instanceKey: "/tmp/t3-agent-two", + development: true, + }); + + expect(first).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); + expect(second).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); + expect(first).not.toBe(second); + }); + + it("keeps the hosted web cookie stable across server instances", () => { + expect( + resolveSessionCookieName({ + mode: "web", + port: 8080, + host: "0.0.0.0", + instanceKey: "/srv/release-a", + development: false, + }), + ).toBe("t3_session"); + expect( + resolveSessionCookieName({ + mode: "web", + port: 9090, + host: "app.example.com", + instanceKey: "/srv/release-b", + development: false, + }), + ).toBe("t3_session"); + }); + + it("retains desktop port scoping", () => { + expect( + resolveSessionCookieName({ + mode: "desktop", + port: 3773, + host: "127.0.0.1", + instanceKey: "/tmp/desktop", + development: true, + }), + ).toBe("t3_session_3773"); + }); + + it("isolates development servers even when they bind a wildcard host", () => { + expect( + resolveSessionCookieName({ + mode: "web", + port: 5775, + host: "0.0.0.0", + instanceKey: "/tmp/t3-wildcard-dev", + development: true, + }), + ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); + }); + + it("classifies loopback aliases separately from remotely reachable hosts", () => { + expect(isRemoteReachableHost(undefined)).toBe(false); + expect(isRemoteReachableHost("localhost")).toBe(false); + expect(isRemoteReachableHost("127.12.0.1")).toBe(false); + expect(isRemoteReachableHost("[::1]")).toBe(false); + expect(isRemoteReachableHost("0.0.0.0")).toBe(true); + expect(isRemoteReachableHost("192.168.1.50")).toBe(true); + }); +}); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 81ef9bffc493..32a6799b01f4 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -28,11 +28,42 @@ const SESSION_COOKIE_NAME = "t3_session"; export function resolveSessionCookieName(input: { readonly mode: "web" | "desktop"; readonly port: number; - readonly devUrl: URL | undefined; + readonly host: string | undefined; + readonly instanceKey: string; + readonly development: boolean; }): string { - return input.devUrl === undefined && input.mode !== "desktop" - ? SESSION_COOKIE_NAME - : `${SESSION_COOKIE_NAME}_${input.port}`; + if (input.mode === "desktop") { + return `${SESSION_COOKIE_NAME}_${input.port}`; + } + + if (!input.development && isRemoteReachableHost(input.host)) { + return SESSION_COOKIE_NAME; + } + + // Cookies are scoped by host, not port. Loopback development servers need an + // instance-specific name or parallel agents overwrite each other's session, + // and a server that later reuses the port receives a token signed elsewhere. + const instanceHash = NodeCrypto.createHash("sha256") + .update(input.instanceKey) + .digest("hex") + .slice(0, 12); + return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`; +} + +export function isRemoteReachableHost(host: string | undefined): boolean { + if (host === "0.0.0.0" || host === "::" || host === "[::]") { + return true; + } + if (!host || host.length === 0) { + return false; + } + return !( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host === "[::1]" || + host.startsWith("127.") + ); } export function base64UrlEncode(input: string | Uint8Array): string { diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 857c22797ee2..241dffef4840 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -16,11 +16,16 @@ import { expect } from "vite-plus/test"; import type { GitActionProgressEvent, GitPreparePullRequestThreadInput, - ModelSelection, ThreadId, } from "@t3tools/contracts"; -import { GitCommandError, TextGenerationError } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + GitCommandError, + ProviderDriverKind, + ProviderInstanceId, + TextGenerationError, +} from "@t3tools/contracts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -29,6 +34,7 @@ import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceContr import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitManager from "./GitManager.ts"; @@ -65,38 +71,7 @@ function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { }; } -interface FakeGitTextGeneration { - generateCommitMessage: (input: { - cwd: string; - branch: string | null; - stagedSummary: string; - stagedPatch: string; - includeBranch?: boolean; - modelSelection: ModelSelection; - }) => Effect.Effect< - { subject: string; body: string; branch?: string | undefined }, - TextGenerationError - >; - generatePrContent: (input: { - cwd: string; - baseBranch: string; - headBranch: string; - commitSummary: string; - diffSummary: string; - diffPatch: string; - modelSelection: ModelSelection; - }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; - generateBranchName: (input: { - cwd: string; - message: string; - modelSelection: ModelSelection; - }) => Effect.Effect<{ branch: string }, TextGenerationError>; - generateThreadTitle: (input: { - cwd: string; - message: string; - modelSelection: ModelSelection; - }) => Effect.Effect<{ title: string }, TextGenerationError>; -} +type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"]; type FakePullRequest = NonNullable; @@ -640,6 +615,7 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; + serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); @@ -648,7 +624,7 @@ function makeManager(input?: { prefix: "t3-git-manager-test-", }); - const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); + const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings); const vcsDriverLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(VcsProcess.layer), @@ -672,6 +648,9 @@ function makeManager(input?: { const managerLayer = Layer.mergeAll( Layer.succeed(TextGeneration.TextGeneration, textGeneration), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + }), Layer.succeed( ProjectSetupScriptRunner.ProjectSetupScriptRunner, input?.setupScriptRunner ?? { @@ -1569,8 +1548,22 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); + let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; - const { manager } = yield* makeManager(); + const { manager } = yield* makeManager({ + serverSettings: { + sourceControlWritingStyle: { + mode: "custom" as const, + customInstructions: "Use a direct tone.", + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedPolicy = input.policy; + return Effect.succeed({ subject: "Implement stacked git actions", body: "" }); + }, + }, + }); const result = yield* runStackedAction(manager, { cwd: repoDir, action: "commit", @@ -1580,6 +1573,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.commit.status).toBe("created"); expect(result.push.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("skipped_not_requested"); + expect(generatedPolicy).toMatchObject({ commitInstructions: "Use a direct tone." }); expect(result.toast).toMatchObject({ description: "Implement stacked git actions", cta: { @@ -1599,6 +1593,118 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("preserves custom style when instructions are empty", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); + let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; + + const { manager } = yield* makeManager({ + serverSettings: { + sourceControlWritingStyle: { + mode: "custom" as const, + customInstructions: "", + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedPolicy = input.policy; + return Effect.succeed({ subject: "Preserve custom style", body: "" }); + }, + }, + }); + yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(generatedPolicy).toEqual({ + kind: "custom", + inferRepositoryConventions: false, + }); + }), + ); + + it.effect("falls back when the dedicated source control writer is unavailable", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); + const missingInstanceId = ProviderInstanceId.make("missing_writer"); + let generatedModelSelection: + | TextGeneration.CommitMessageGenerationInput["modelSelection"] + | undefined; + + const { manager } = yield* makeManager({ + serverSettings: { + providerInstances: { + [missingInstanceId]: { + driver: ProviderDriverKind.make("missing-driver"), + config: {}, + }, + }, + sourceControlWriterModelSelection: { + instanceId: missingInstanceId, + model: "missing-model", + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedModelSelection = input.modelSelection; + return Effect.succeed({ subject: "Use the available writer", body: "" }); + }, + }, + }); + + yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(generatedModelSelection).toEqual(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection); + }), + ); + + it.effect("preserves repository conventions style when recent history is empty", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* runGit(repoDir, ["init", "--initial-branch=main"]); + yield* runGit(repoDir, ["config", "user.email", "test@example.com"]); + yield* runGit(repoDir, ["config", "user.name", "Test User"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n"); + yield* runGit(repoDir, ["add", "README.md"]); + let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; + + const { manager } = yield* makeManager({ + serverSettings: { + sourceControlWritingStyle: { + mode: "repo_conventions" as const, + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedPolicy = input.policy; + return Effect.succeed({ subject: "Create initial commit", body: "" }); + }, + }, + }); + yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(generatedPolicy).toEqual({ + kind: "repo_conventions", + commitInstructions: + "Follow the repository's established commit message style when examples are available.", + changeRequestInstructions: + "Follow the repository's established change request title and body style when examples are available.", + inferRepositoryConventions: true, + }); + }), + ); + it.effect("uses custom commit message when provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2540,6 +2646,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); + NodeFS.mkdirSync(NodePath.join(repoDir, ".github")); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".github", "pull_request_template.md"), + "## What changed?\n\n## Verification", + ); + yield* runGit(repoDir, ["add", ".github/pull_request_template.md"]); + yield* runGit(repoDir, ["commit", "-m", "Add pull request template"]); yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); @@ -2548,8 +2661,26 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); + let generatedPolicy: TextGeneration.PrContentGenerationInput["policy"] = undefined; + let generatedChangeRequestTemplate: string | undefined; const { manager, ghCalls } = yield* makeManager({ + serverSettings: { + sourceControlWritingStyle: { + mode: "custom" as const, + customInstructions: "Lead with user impact.", + }, + }, + textGeneration: { + generatePrContent: (input) => { + generatedPolicy = input.policy; + generatedChangeRequestTemplate = input.changeRequestTemplate; + return Effect.succeed({ + title: "Add stacked git actions", + body: "## What changed?\nAdded stacked git actions.", + }); + }, + }, ghScenario: { prListSequence: [ "[]", @@ -2574,6 +2705,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(88); + expect(generatedPolicy).toMatchObject({ + changeRequestInstructions: "Lead with user impact.", + }); + expect(generatedChangeRequestTemplate).toBe("## What changed?\n\n## Verification"); expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index e7d32493cb0d..da002df5e6c4 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -28,6 +28,7 @@ import { type VcsStatusRemoteResult, VcsStatusResult, ModelSelection, + type SourceControlWritingStyleSettings, } from "@t3tools/contracts"; import { detectSourceControlProviderFromGitRemoteUrl, @@ -44,12 +45,19 @@ import { import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import { + conventionalCommitsTextGenerationPolicy, + customTextGenerationPolicy, + repositoryConventionsTextGenerationPolicy, +} from "../textGeneration/TextGenerationPresets.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts"; import type { ChangeRequest } from "@t3tools/contracts"; export interface GitActionProgressReporter { @@ -61,6 +69,11 @@ export interface GitRunStackedActionOptions { readonly progressReporter?: GitActionProgressReporter; } +interface SourceControlTextGenerationSettings { + readonly modelSelection: ModelSelection; + readonly style: SourceControlWritingStyleSettings; +} + export class GitManager extends Context.Service< GitManager, { @@ -565,11 +578,58 @@ export const make = Effect.gen(function* () { const gitCore = yield* GitVcsDriver.GitVcsDriver; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const textGeneration = yield* TextGeneration.TextGeneration; + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const crypto = yield* Crypto.Crypto; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; + + const readRecentCommitSubjects = (cwd: string) => + gitCore + .execute({ + operation: "GitManager.readRecentCommitSubjects", + cwd, + args: ["log", "-n", "20", "--no-merges", "--pretty=format:%s"], + }) + .pipe( + Effect.map((result) => + result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0), + ), + Effect.orElseSucceed(() => []), + ); + + const resolveStylePolicy = (cwd: string, style: SourceControlWritingStyleSettings) => + Effect.gen(function* () { + switch (style.mode) { + case "conventional_commits": + return conventionalCommitsTextGenerationPolicy; + case "custom": + return customTextGenerationPolicy( + style.customInstructions + ? { + commitInstructions: style.customInstructions, + changeRequestInstructions: style.customInstructions, + } + : {}, + ); + case "repo_conventions": { + const subjects = yield* readRecentCommitSubjects(cwd); + if (subjects.length === 0) { + return repositoryConventionsTextGenerationPolicy; + } + const examples = ["Recent commit subjects from this repository:", ...subjects].join("\n"); + return { + ...repositoryConventionsTextGenerationPolicy, + commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\n${examples}`, + changeRequestInstructions: `${repositoryConventionsTextGenerationPolicy.changeRequestInstructions}\n\n${examples}`, + }; + } + } + }); const randomUUIDv4 = (cwd: string) => crypto.randomUUIDv4.pipe( Effect.mapError( @@ -1330,7 +1390,7 @@ export const make = Effect.gen(function* () { /** When true, also produce a semantic feature branch name. */ includeBranch?: boolean; filePaths?: readonly string[]; - modelSelection: ModelSelection; + settings: SourceControlTextGenerationSettings; }) { const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); if (!context) { @@ -1349,6 +1409,8 @@ export const make = Effect.gen(function* () { }; } + const policy = yield* resolveStylePolicy(input.cwd, input.settings.style); + const generated = yield* textGeneration .generateCommitMessage({ cwd: input.cwd, @@ -1356,7 +1418,8 @@ export const make = Effect.gen(function* () { stagedSummary: limitContext(context.stagedSummary, 8_000), stagedPatch: limitContext(context.stagedPatch, 50_000), ...(input.includeBranch ? { includeBranch: true } : {}), - modelSelection: input.modelSelection, + ...(policy ? { policy } : {}), + modelSelection: input.settings.modelSelection, }) .pipe(Effect.map((result) => sanitizeCommitMessage(result))); @@ -1370,7 +1433,7 @@ export const make = Effect.gen(function* () { ); const runCommitStep = Effect.fn("runCommitStep")(function* ( - modelSelection: ModelSelection, + settings: SourceControlTextGenerationSettings, cwd: string, action: "commit" | "commit_push" | "commit_push_pr", branch: string | null, @@ -1405,7 +1468,7 @@ export const make = Effect.gen(function* () { branch, ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), - modelSelection, + settings, }); } if (!suggestion) { @@ -1483,7 +1546,7 @@ export const make = Effect.gen(function* () { }); const runPrStep = Effect.fn("runPrStep")(function* ( - modelSelection: ModelSelection, + settings: SourceControlTextGenerationSettings, cwd: string, fallbackBranch: string | null, emit: GitActionProgressEmitter, @@ -1532,6 +1595,11 @@ export const make = Effect.gen(function* () { }); const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch); const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef); + const policy = yield* resolveStylePolicy(cwd, settings.style); + const changeRequestTemplate = + settings.style.followChangeRequestTemplates && provider.kind === "github" + ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute)) + : undefined; const generated = yield* textGeneration.generatePrContent({ cwd, @@ -1540,7 +1608,9 @@ export const make = Effect.gen(function* () { commitSummary: limitContext(rangeContext.commitSummary, 20_000), diffSummary: limitContext(rangeContext.diffSummary, 20_000), diffPatch: limitContext(rangeContext.diffPatch, 60_000), - modelSelection, + ...(changeRequestTemplate ? { changeRequestTemplate } : {}), + ...(policy ? { policy } : {}), + modelSelection: settings.modelSelection, }); const bodyFile = path.join( @@ -1820,7 +1890,7 @@ export const make = Effect.gen(function* () { }); const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( - modelSelection: ModelSelection, + settings: SourceControlTextGenerationSettings, cwd: string, branch: string | null, commitMessage?: string, @@ -1832,7 +1902,7 @@ export const make = Effect.gen(function* () { ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), includeBranch: true, - modelSelection, + settings, }); if (!suggestion) { return yield* new GitManagerError({ @@ -1921,8 +1991,23 @@ export const make = Effect.gen(function* () { let commitMessageForStep = input.commitMessage; let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; - const modelSelection = yield* serverSettingsService.getSettings.pipe( - Effect.map((settings) => settings.textGenerationModelSelection), + const textGenerationSettings = yield* serverSettingsService.getSettings.pipe( + Effect.flatMap((settings) => + settings.sourceControlWriterModelSelection === null + ? Effect.succeed({ + modelSelection: settings.textGenerationModelSelection, + style: settings.sourceControlWritingStyle, + }) + : providerRegistry.getProviders.pipe( + Effect.map((providers) => ({ + modelSelection: ServerSettings.resolveSourceControlWriterModelSelection( + settings, + providers, + ), + style: settings.sourceControlWritingStyle, + })), + ), + ), Effect.mapError( (cause) => new GitManagerError({ @@ -1942,7 +2027,7 @@ export const make = Effect.gen(function* () { label: "Preparing feature branch...", }); const result = yield* runFeatureBranchStep( - modelSelection, + textGenerationSettings, input.cwd, initialStatus.branch, input.commitMessage, @@ -1968,7 +2053,7 @@ export const make = Effect.gen(function* () { ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( Effect.flatMap(() => runCommitStep( - modelSelection, + textGenerationSettings, input.cwd, commitAction, currentBranch, @@ -2005,7 +2090,7 @@ export const make = Effect.gen(function* () { .pipe( Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), Effect.flatMap(() => - runPrStep(modelSelection, input.cwd, currentBranch, progress.emit), + runPrStep(textGenerationSettings, input.cwd, currentBranch, progress.emit), ), ) : { status: "skipped_not_requested" as const }; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index f93e00f53405..ef3b68db4d72 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -173,40 +173,6 @@ it.effect("does not let an older response replace a newer explicit tab target", ), ); -it.effect("does not replace the default tab with a globally stopped recording tab", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const browsingTabId = PreviewTabId.make("tab-session-b"); - const recordingTabId = PreviewTabId.make("tab-session-a-recording"); - const routedRequests: RoutedRequest[] = []; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => { - routedRequests.push(request); - return broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: - request.operation === "open" - ? { available: true, tabId: browsingTabId } - : request.operation === "recordingStop" - ? { id: "recording-1", tabId: recordingTabId } - : { url: "http://localhost:3200" }, - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - yield* broker.invoke({ scope, operation: "open", input: {} }); - yield* broker.invoke({ scope, operation: "recordingStop", input: {} }); - yield* broker.invoke({ scope, operation: "snapshot", input: {} }); - - expect(routedRequests.at(-1)?.tabId).toBe(browsingTabId); - }), - ), -); - it.effect("does not replace the default tab with an explicit recording stop target", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/preview/handlers.test.ts b/apps/server/src/mcp/toolkits/preview/handlers.test.ts new file mode 100644 index 000000000000..93985fc9d4c8 --- /dev/null +++ b/apps/server/src/mcp/toolkits/preview/handlers.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { normalizePreviewOpenInput } from "./handlers.ts"; + +describe("normalizePreviewOpenInput", () => { + it("opens the inline preview and reuses the current tab by default", () => { + expect(normalizePreviewOpenInput({})).toEqual({ + open: true, + reuseExistingTab: true, + show: true, + }); + }); + + it("preserves an explicit background-only opt-out", () => { + expect(normalizePreviewOpenInput({ open: false })).toEqual({ + open: false, + reuseExistingTab: true, + show: false, + }); + }); + + it("supports show as a legacy alias while preferring open", () => { + expect(normalizePreviewOpenInput({ show: false })).toEqual({ + open: false, + reuseExistingTab: true, + show: false, + }); + expect(normalizePreviewOpenInput({ open: true, show: false })).toEqual({ + open: true, + reuseExistingTab: true, + show: true, + }); + }); +}); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 8c4651dc1cfb..1c7ff6f9cd95 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import type { PreviewAutomationOperation, + PreviewAutomationOpenInput, PreviewAutomationRecordingArtifact, PreviewAutomationRecordingStatus, PreviewAutomationResizeResult, @@ -14,6 +15,18 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; +export function normalizePreviewOpenInput( + input: PreviewAutomationOpenInput, +): PreviewAutomationOpenInput { + const open = input.open ?? input.show ?? true; + return { + ...input, + open, + show: open, + reuseExistingTab: input.reuseExistingTab ?? true, + }; +} + const invoke = Effect.fn("PreviewToolkit.invoke")(function* ( operation: PreviewAutomationOperation, input: unknown, @@ -50,11 +63,7 @@ const invokeTargeted = ( const handlers = { preview_status: (input) => invokeTargeted("status", input ?? {}), preview_open: (input) => - invokeTargeted("open", { - ...input, - show: input.show ?? true, - reuseExistingTab: input.reuseExistingTab ?? true, - }), + invokeTargeted("open", normalizePreviewOpenInput(input)), preview_navigate: (input) => invokeTargeted("navigate", input, input.timeoutMs), preview_resize: (input) => diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index d2527fdfb395..a94d2b056f7a 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -54,7 +54,7 @@ export const PreviewStatusTool = Tool.make("preview_status", { export const PreviewOpenTool = browserTool( Tool.make("preview_open", { description: - "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.", + "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.", parameters: PreviewAutomationOpenInput, success: PreviewAutomationStatus, failure: PreviewAutomationError, @@ -191,7 +191,8 @@ export const PreviewRecordingStartTool = safeBrowserTool( export const PreviewRecordingStopTool = safeBrowserTool( Tool.make("preview_recording_stop", { - description: "Stop the active browser recording and save it as a local evidence artifact.", + description: + "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.", parameters: PreviewAutomationTabTargetInput, success: PreviewAutomationRecordingArtifact, failure: PreviewAutomationError, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 63dd13219b81..684e2d2fdcc7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -38,7 +38,10 @@ import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { + resolveSourceControlWriterModelSelection, + ServerSettingsService, +} from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); @@ -688,8 +691,14 @@ const make = Effect.gen(function* () { const cwd = input.worktreePath; const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = - yield* serverSettingsService.getSettings; + const settings = yield* serverSettingsService.getSettings; + const modelSelection = + settings.sourceControlWriterModelSelection === null + ? settings.textGenerationModelSelection + : resolveSourceControlWriterModelSelection( + settings, + yield* providerRegistry.getProviders, + ); const generated = yield* textGeneration.generateBranchName({ cwd, diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index 693111b578fa..8b3dabfa3386 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -67,6 +67,32 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { }), ); + it.effect("orders list snapshots and events with one monotonic revision", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + const before = yield* manager.list({ threadId }); + + const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.navigate({ + threadId, + tabId: opened.tabId, + url: "http://localhost:5173/ready", + }); + + const events = yield* collector.drain; + const listed = yield* manager.list({ threadId }); + expect(events).toHaveLength(2); + expect(events[0]!.serverEpoch).toBe(listed.serverEpoch); + expect(events[1]!.serverEpoch).toBe(listed.serverEpoch); + expect(events[0]!.revision).toBeGreaterThan(before.revision); + expect(events[1]!.revision).toBeGreaterThan(events[0]!.revision); + expect(listed.revision).toBe(events[1]!.revision); + expect(listed.sessions).toHaveLength(1); + }), + ); + it.effect("treats bare hosts as https", () => Effect.gen(function* () { const threadId = freshThreadId(); @@ -254,6 +280,26 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { }), ); + it.effect("gives every tab in a batch close its own monotonic revision", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.open({ threadId, url: "http://localhost:3000" }); + const collector = yield* collectEvents; + + yield* manager.close({ threadId }); + + const events = yield* collector.drain; + const listed = yield* manager.list({ threadId }); + expect(events).toHaveLength(2); + expect(events.every((event) => event.type === "closed")).toBe(true); + expect(events[1]!.revision).toBeGreaterThan(events[0]!.revision); + expect(listed.revision).toBe(events[1]!.revision); + expect(listed.sessions).toHaveLength(0); + }), + ); + it.effect("close is idempotent for unknown threads", () => Effect.gen(function* () { const threadId = freshThreadId(); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 193ea85b21b5..e38e28ecd2e9 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -30,6 +30,7 @@ import { newPreviewTabId, normalizePreviewUrl, } from "@t3tools/shared/preview"; +import * as NodeCrypto from "node:crypto"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -67,9 +68,17 @@ interface PreviewSessionState { interface ManagerState { /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */ readonly sessions: ReadonlyMap; + /** Global monotonic revision establishing list/event ordering. */ + readonly revision: number; } -const initialState: ManagerState = { sessions: new Map() }; +const initialState: ManagerState = { sessions: new Map(), revision: 0 }; + +type PreviewEventDraft = PreviewEvent extends infer Event + ? Event extends { readonly revision: number } + ? Omit + : never + : never; const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`; @@ -138,6 +147,7 @@ const buildIdleSnapshot = (input: { }); export const make = Effect.gen(function* PreviewManagerMake() { + const serverEpoch = NodeCrypto.randomUUID(); const stateRef = yield* SynchronizedRef.make(initialState); // Unbounded PubSub is fine here — events are tiny and we don't want to // block publishers if a subscriber is slow. WS clients backpressure on @@ -160,7 +170,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { tabId: string, mutator: ( session: PreviewSessionState, - ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEvent | null; result: R }, E>, + ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEventDraft | null; result: R }, E>, ): Effect.Effect => { type ModifyResult = | { kind: "fail"; error: PreviewSessionLookupError } @@ -177,10 +187,17 @@ export const make = Effect.gen(function* PreviewManagerMake() { return mutator(session).pipe( Effect.flatMap( Effect.fn("PreviewManager.commitMutation")(function* ({ next, emit, result }) { - if (emit) yield* PubSub.publish(eventsPubSub, emit); + const revision = emit ? state.revision + 1 : state.revision; + if (emit) { + yield* PubSub.publish(eventsPubSub, { + ...emit, + revision, + serverEpoch, + } as PreviewEvent); + } const sessions = new Map(state.sessions); sessions.set(compositeKey(threadId, tabId), next); - return [{ kind: "ok", result } as ModifyResult, { sessions }] as readonly [ + return [{ kind: "ok", result } as ModifyResult, { sessions, revision }] as readonly [ ModifyResult, ManagerState, ]; @@ -207,22 +224,27 @@ export const make = Effect.gen(function* PreviewManagerMake() { updatedAt, }) : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); - yield* SynchronizedRef.update(stateRef, (state) => { - const sessions = new Map(state.sessions); - sessions.set(compositeKey(input.threadId, tabId), { - threadId: input.threadId, - tabId, - snapshot, - }); - return { sessions }; - }); - yield* PubSub.publish(eventsPubSub, { - type: "opened", - threadId: input.threadId, - tabId, - createdAt: snapshot.updatedAt, - snapshot, - }); + yield* SynchronizedRef.modifyEffect(stateRef, (state) => + Effect.gen(function* () { + const revision = state.revision + 1; + const sessions = new Map(state.sessions); + sessions.set(compositeKey(input.threadId, tabId), { + threadId: input.threadId, + tabId, + snapshot, + }); + yield* PubSub.publish(eventsPubSub, { + type: "opened", + threadId: input.threadId, + tabId, + createdAt: snapshot.updatedAt, + serverEpoch, + revision, + snapshot, + }); + return [snapshot, { sessions, revision }] as const; + }), + ); return snapshot; }, ); @@ -280,7 +302,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, updatedAt, }; - const emit: PreviewEvent = + const emit: PreviewEventDraft = input.navStatus._tag === "LoadFailed" ? { type: "failed", @@ -349,7 +371,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { const close: PreviewManager["Service"]["close"] = Effect.fn("PreviewManager.close")( function* (input) { const createdAt = yield* currentIsoTimestamp; - const events = yield* SynchronizedRef.modify(stateRef, (state) => { + yield* SynchronizedRef.modifyEffect(stateRef, (state) => { const eventsToEmit: PreviewEvent[] = []; const sessions = new Map(state.sessions); const targets = input.tabId @@ -357,25 +379,29 @@ export const make = Effect.gen(function* PreviewManagerMake() { (entry): entry is PreviewSessionState => entry !== undefined, ) : sessionsForThread(state, input.threadId); + let revision = state.revision; for (const target of targets) { + revision += 1; sessions.delete(compositeKey(target.threadId, target.tabId)); eventsToEmit.push({ type: "closed", threadId: target.threadId, tabId: target.tabId, createdAt, + serverEpoch, + revision, }); } if (eventsToEmit.length === 0) { - return [eventsToEmit, state] as const; + return Effect.succeed([undefined, state] as const); } - return [eventsToEmit, { sessions }] as const; + return Effect.as( + Effect.forEach(eventsToEmit, (event) => PubSub.publish(eventsPubSub, event), { + discard: true, + }), + [undefined, { sessions, revision }] as const, + ); }); - if (events.length > 0) { - yield* Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), { - discard: true, - }); - } }, ); @@ -387,6 +413,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { sessions: sessionsForThread(state, input.threadId) .map((s) => s.snapshot) .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), + serverEpoch, + revision: state.revision, }), ), ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 31e5d463c71d..f47bded1a77b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -669,7 +669,7 @@ const buildAppUnderTest = (options?: { reportStatus: () => Effect.void, refresh: () => Effect.void, close: () => Effect.void, - list: () => Effect.succeed({ sessions: [] }), + list: () => Effect.succeed({ sessions: [], serverEpoch: "test-server", revision: 0 }), events: Stream.empty, subscribeEvents: Effect.flatMap(PubSub.unbounded(), (pubsub) => PubSub.subscribe(pubsub), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 487ae9b45b8a..50ca810a95a5 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -325,6 +325,73 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect( + "preserves the source control writer selection when its provider instance is disabled", + () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const instanceId = ProviderInstanceId.make("codex_writer"); + const sourceControlWriterModelSelection = { + instanceId, + model: "gpt-5.4-mini", + }; + + yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: {}, + }, + }, + sourceControlWriterModelSelection, + }); + + const next = yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: {}, + }, + }, + }); + + assert.deepEqual(next.sourceControlWriterModelSelection, sourceControlWriterModelSelection); + assert.deepEqual( + ServerSettingsModule.resolveSourceControlWriterModelSelection(next), + next.textGenerationModelSelection, + ); + assert.deepEqual( + (yield* serverSettings.getSettings).sourceControlWriterModelSelection, + sourceControlWriterModelSelection, + ); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.deepEqual( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.parse(raw).sourceControlWriterModelSelection, + sourceControlWriterModelSelection, + ); + + const restored = yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: {}, + }, + }, + }); + assert.deepEqual( + ServerSettingsModule.resolveSourceControlWriterModelSelection(restored), + sourceControlWriterModelSelection, + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("drops stale text generation options when resetting model selection", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 4119a72640fe..82fd2f29f03f 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -11,11 +11,10 @@ * @module ServerSettings */ import { - DEFAULT_GIT_TEXT_GENERATION_MODEL, - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, + DEFAULT_TEXT_GENERATION_MODEL, + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, - isProviderDriverKind, type ModelSelection, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, @@ -47,9 +46,14 @@ import { writeFileStringAtomically } from "./atomicWrite.ts"; import * as ServerConfig from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; -import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { + applyServerSettingsPatch, + isModelSelectionProviderEnabled, +} from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +export { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; + const encodeServerSettings = Schema.encodeEffect(ServerSettings); const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); @@ -148,12 +152,13 @@ const makeTest = (overrides: DeepPartial = {}) => return { start: Effect.void, ready: Effect.void, - getSettings: Ref.get(currentSettingsRef), + getSettings: Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)), updateSettings: (patch) => Ref.get(currentSettingsRef).pipe( Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)), Effect.flatMap(normalizeServerSettings), Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), + Effect.map(resolveTextGenerationProvider), ), streamChanges: Stream.empty, } satisfies ServerSettingsService["Service"]; @@ -165,35 +170,10 @@ export const layerTest = (overrides: DeepPartial = {}) => const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); -type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; - -const getLegacyProviderSettings = ( - settings: ServerSettings, - provider: ProviderDriverKind, -): LegacyProviderSettings | undefined => - (settings.providers as Record)[provider]; - -/** - * Ensure the `textGenerationModelSelection` points to an enabled provider. - * If the selected provider is disabled, fall back to the first enabled - * provider with its default model. This is applied at read-time so the - * persisted preference is preserved for when a provider is re-enabled. - */ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { - const selection = settings.textGenerationModelSelection; - const instanceConfig = settings.providerInstances[selection.instanceId]; - if (instanceConfig !== undefined) { - return (instanceConfig.enabled ?? true) ? settings : fallbackTextGenerationProvider(settings); - } - - if ( - isProviderDriverKind(selection.instanceId) && - getLegacyProviderSettings(settings, selection.instanceId)?.enabled - ) { - return settings; - } - - return fallbackTextGenerationProvider(settings); + return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection) + ? settings + : fallbackTextGenerationProvider(settings); } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { @@ -208,9 +188,9 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting textGenerationModelSelection: { instanceId: ProviderInstanceId.make(fallback), model: - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? DEFAULT_MODEL_BY_PROVIDER[fallback] ?? - DEFAULT_GIT_TEXT_GENERATION_MODEL, + DEFAULT_TEXT_GENERATION_MODEL, } satisfies ModelSelection, }; } @@ -218,6 +198,7 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting // Values under these keys are compared as a whole — never stripped field-by-field. const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "automaticGitFetchInterval", + "sourceControlWriterModelSelection", "textGenerationModelSelection", ]); diff --git a/apps/server/src/sourceControl/PrTemplateDetection.test.ts b/apps/server/src/sourceControl/PrTemplateDetection.test.ts new file mode 100644 index 000000000000..34112c9c528c --- /dev/null +++ b/apps/server/src/sourceControl/PrTemplateDetection.test.ts @@ -0,0 +1,248 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { ServerConfig } from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { detectPrTemplate } from "./PrTemplateDetection.ts"; + +const SINGLE_TEMPLATE_PATHS = [ + ".github/pull_request_template.md", + ".github/PULL_REQUEST_TEMPLATE.md", + "pull_request_template.md", + "PULL_REQUEST_TEMPLATE.md", + "docs/pull_request_template.md", + "docs/PULL_REQUEST_TEMPLATE.md", +] as const; + +const TEMPLATE_DIRECTORIES = [ + ".github/PULL_REQUEST_TEMPLATE", + "PULL_REQUEST_TEMPLATE", + "docs/PULL_REQUEST_TEMPLATE", +] as const; + +const PrTemplateDetectionTestLayer = GitVcsDriver.layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-pr-template-test-", + }), + ), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +const runGit = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + return yield* git.execute({ + operation: "PrTemplateDetection.test.runGit", + cwd, + args, + }); + }); + +const runWithTempDirectory = ( + test: (cwd: string) => Effect.Effect, +) => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-pr-template-" }); + yield* runGit(cwd, ["init", "--initial-branch=main"]); + yield* runGit(cwd, ["config", "user.email", "test@example.com"]); + yield* runGit(cwd, ["config", "user.name", "Test User"]); + return yield* test(cwd); + }), + ).pipe(Effect.provide(PrTemplateDetectionTestLayer)); + +const writeTemplate = (cwd: string, relativePath: string, contents: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const templatePath = path.join(cwd, relativePath); + yield* fileSystem.makeDirectory(path.dirname(templatePath), { recursive: true }); + yield* fileSystem.writeFileString(templatePath, contents); + return templatePath; + }); + +const commitTemplates = (cwd: string) => + Effect.gen(function* () { + yield* runGit(cwd, ["add", "-A"]); + yield* runGit(cwd, ["commit", "--allow-empty", "-m", "Add pull request templates"]); + }); + +const detectTemplate = (cwd: string, treeish = "HEAD") => + Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + return yield* detectPrTemplate(cwd, treeish, git.execute); + }); + +it.effect.each(SINGLE_TEMPLATE_PATHS)("recognizes $0", (relativePath) => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, relativePath, `template from ${relativePath}`); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), `template from ${relativePath}`); + }), + ), +); + +it.effect("reads templates from the requested base tree", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, "README.md", "initial\n"); + yield* commitTemplates(cwd); + yield* runGit(cwd, ["branch", "feature"]); + yield* writeTemplate(cwd, ".github/pull_request_template.md", "base template"); + yield* commitTemplates(cwd); + yield* runGit(cwd, ["checkout", "feature"]); + + assert.isTrue(Option.isNone(yield* detectTemplate(cwd))); + assert.strictEqual( + Option.getOrUndefined(yield* detectTemplate(cwd, "main")), + "base template", + ); + }), + ), +); + +it.effect("uses the first non-empty template in the configured path order", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, ".github/pull_request_template.md", " \n"); + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE.md", " ## Preferred template \n"); + yield* writeTemplate(cwd, "pull_request_template.md", "## Later template"); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), "## Preferred template"); + }), + ), +); + +it.effect.each(TEMPLATE_DIRECTORIES)("recognizes the $0 directory", (relativeDirectory) => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, `${relativeDirectory}/template.MD`, "directory template"); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), "directory template"); + }), + ), +); + +it.effect("skips unusable directory entries and uses the one valid template", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const templateDirectory = path.join(cwd, ".github", "PULL_REQUEST_TEMPLATE"); + yield* fileSystem.makeDirectory(path.join(templateDirectory, "b-directory.md"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(templateDirectory, "a-empty.md"), " \n"); + yield* fileSystem.symlink( + path.join(templateDirectory, "missing.md"), + path.join(templateDirectory, "c-broken.md"), + ); + yield* fileSystem.writeFileString(path.join(templateDirectory, "z-valid.md"), "valid"); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), "valid"); + }), + ), +); + +it.effect("does not guess between multiple directory templates", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/a.md", "first"); + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/b.md", "second"); + yield* writeTemplate(cwd, "PULL_REQUEST_TEMPLATE/fallback.md", "fallback"); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.isTrue(Option.isNone(template)); + }), + ), +); + +it.effect("rejects a committed template symlink escaping the repository", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-pr-template-outside-", + }); + const outsideTemplate = path.join(outsideDirectory, "secret.md"); + yield* fileSystem.writeFileString(outsideTemplate, "LOCAL_SECRET_SENTINEL"); + const escapedTemplatePath = path.join(cwd, ".github", "pull_request_template.md"); + yield* fileSystem.makeDirectory(path.dirname(escapedTemplatePath), { recursive: true }); + yield* fileSystem.symlink(outsideTemplate, escapedTemplatePath); + yield* writeTemplate(cwd, "pull_request_template.md", "safe template"); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), "safe template"); + assert.notInclude( + Option.getOrElse(template, () => ""), + "LOCAL_SECRET_SENTINEL", + ); + }), + ), +); + +it.effect("reads the committed template when a worktree parent is replaced", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-pr-template-outside-", + }); + const templatePath = yield* writeTemplate( + cwd, + ".github/pull_request_template.md", + "committed template", + ); + yield* commitTemplates(cwd); + yield* writeTemplate(outsideDirectory, "pull_request_template.md", "LOCAL_SECRET_SENTINEL"); + + const templateDirectory = path.dirname(templatePath); + yield* fileSystem.rename(templateDirectory, path.join(cwd, ".github-original")); + yield* fileSystem.symlink(outsideDirectory, templateDirectory); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), "committed template"); + assert.notInclude( + Option.getOrElse(template, () => ""), + "LOCAL_SECRET_SENTINEL", + ); + }), + ), +); + +it.effect("bounds template reads and marks truncated content", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + const prefix = "a".repeat(8_000); + yield* writeTemplate(cwd, ".github/pull_request_template.md", `${prefix}SECRET_SENTINEL`); + yield* commitTemplates(cwd); + + const template = Option.getOrThrow(yield* detectTemplate(cwd)); + assert.strictEqual(template, `${prefix}\n\n[truncated]`); + assert.lengthOf(template.match(/\[truncated\]/g) ?? [], 1); + assert.notInclude(template, "SECRET_SENTINEL"); + }), + ), +); diff --git a/apps/server/src/sourceControl/PrTemplateDetection.ts b/apps/server/src/sourceControl/PrTemplateDetection.ts new file mode 100644 index 000000000000..6872708fec94 --- /dev/null +++ b/apps/server/src/sourceControl/PrTemplateDetection.ts @@ -0,0 +1,177 @@ +import type { GitCommandError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import type * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; + +const TEMPLATE_MAX_BYTES = 8_000; +const TREE_LIST_MAX_BYTES = 100_000; +const TRUNCATION_MARKER = "[truncated]"; + +const TEMPLATE_PATHS = [ + ".github/pull_request_template.md", + ".github/PULL_REQUEST_TEMPLATE.md", + "pull_request_template.md", + "PULL_REQUEST_TEMPLATE.md", + "docs/pull_request_template.md", + "docs/PULL_REQUEST_TEMPLATE.md", +] as const; + +const TEMPLATE_DIRECTORIES = [ + ".github/PULL_REQUEST_TEMPLATE", + "PULL_REQUEST_TEMPLATE", + "docs/PULL_REQUEST_TEMPLATE", +] as const; + +const TREE_PATHS = [...TEMPLATE_PATHS, ...TEMPLATE_DIRECTORIES] as const; + +type ExecuteGit = GitVcsDriver.GitVcsDriver["Service"]["execute"]; + +interface TemplateTreeEntry { + readonly objectId: string; + readonly path: string; +} + +function parseTemplateTreeEntries(output: string): ReadonlyArray { + const entries: TemplateTreeEntry[] = []; + for (const record of output.split("\0")) { + if (record.length === 0) { + continue; + } + + const separator = record.indexOf("\t"); + if (separator < 0) { + continue; + } + + const [mode, type, objectId] = record.slice(0, separator).split(" "); + if ( + type !== "blob" || + (mode !== "100644" && mode !== "100755") || + !objectId || + !/^[0-9a-f]{40,64}$/.test(objectId) + ) { + continue; + } + + entries.push({ objectId, path: record.slice(separator + 1) }); + } + return entries; +} + +function readTemplateBlob(input: { + readonly cwd: string; + readonly executeGit: ExecuteGit; + readonly entry: TemplateTreeEntry; +}): Effect.Effect, GitCommandError> { + return input + .executeGit({ + operation: "PrTemplateDetection.readTemplateBlob", + cwd: input.cwd, + args: ["cat-file", "blob", input.entry.objectId], + maxOutputBytes: TEMPLATE_MAX_BYTES, + appendTruncationMarker: true, + }) + .pipe( + Effect.map((result) => { + const template = result.stdout.trim(); + if (template.length === 0) { + return Option.none(); + } + return Option.some( + result.stdoutTruncated && !template.endsWith(TRUNCATION_MARKER) + ? `${template}\n\n${TRUNCATION_MARKER}` + : template, + ); + }), + ); +} + +type DirectoryTemplateResult = + | { readonly _tag: "None" } + | { readonly _tag: "Ambiguous" } + | { readonly _tag: "Template"; readonly template: string }; + +function readTemplateDirectory(input: { + readonly cwd: string; + readonly executeGit: ExecuteGit; + readonly entries: ReadonlyArray; + readonly directory: string; +}): Effect.Effect { + return Effect.gen(function* () { + const prefix = `${input.directory}/`; + const candidates = input.entries.filter((entry) => { + if (!entry.path.startsWith(prefix)) { + return false; + } + const relativePath = entry.path.slice(prefix.length); + return !relativePath.includes("/") && relativePath.toLowerCase().endsWith(".md"); + }); + + const templates: string[] = []; + for (const entry of candidates) { + const template = yield* readTemplateBlob({ ...input, entry }); + if (Option.isSome(template)) { + templates.push(template.value); + if (templates.length > 1) { + return { _tag: "Ambiguous" } as const; + } + } + } + + return templates[0] + ? ({ _tag: "Template", template: templates[0] } as const) + : ({ _tag: "None" } as const); + }); +} + +export const detectPrTemplate = Effect.fn("detectPrTemplate")(function* ( + cwd: string, + treeish: string, + executeGit: ExecuteGit, +) { + return yield* Effect.gen(function* () { + // Worktree paths can be replaced between validation and open. Read regular blobs from the + // committed base tree so repository-controlled symlinks and path races never reach the host filesystem. + const result = yield* executeGit({ + operation: "PrTemplateDetection.listTemplates", + cwd, + args: ["ls-tree", "-r", "-z", "--full-tree", treeish, "--", ...TREE_PATHS], + maxOutputBytes: TREE_LIST_MAX_BYTES, + appendTruncationMarker: true, + }); + if (result.stdoutTruncated) { + return Option.none(); + } + + const entries = parseTemplateTreeEntries(result.stdout); + const entriesByPath = new Map(entries.map((entry) => [entry.path, entry])); + for (const templatePath of TEMPLATE_PATHS) { + const entry = entriesByPath.get(templatePath); + if (!entry) { + continue; + } + const template = yield* readTemplateBlob({ cwd, executeGit, entry }); + if (Option.isSome(template)) { + return template; + } + } + + for (const directory of TEMPLATE_DIRECTORIES) { + const directoryTemplate = yield* readTemplateDirectory({ + cwd, + executeGit, + entries, + directory, + }); + if (directoryTemplate._tag === "Template") { + return Option.some(directoryTemplate.template); + } + if (directoryTemplate._tag === "Ambiguous") { + return Option.none(); + } + } + + return Option.none(); + }).pipe(Effect.orElseSucceed(() => Option.none())); +}); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 453bb62b728e..a27b2c8bd3ee 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -272,6 +272,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + policy: input.policy, }); const generated = yield* runClaudeJson({ @@ -299,6 +300,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, }); const generated = yield* runClaudeJson({ diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 6a5c0df43c7f..6ea710cd5a39 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -305,6 +305,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + policy: input.policy, }); const generated = yield* runCodexJson({ @@ -332,6 +333,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, }); const generated = yield* runCodexJson({ diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index ddd0e89cb506..b40a38cb19e3 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -175,6 +175,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + policy: input.policy, }); const generated = yield* runCursorJson({ @@ -203,6 +204,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, }); const generated = yield* runCursorJson({ diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index fd367acdf4c4..d26a2ebe01b6 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -164,6 +164,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + policy: input.policy, }); const generated = yield* runGrokJson({ @@ -191,6 +192,8 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, }); const generated = yield* runGrokJson({ diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 1f94f970692c..2adbb829b8d6 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -530,6 +530,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + policy: input.policy, }); const generated = yield* runOpenCodeJson({ operation: "generateCommitMessage", @@ -556,6 +557,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, }); const generated = yield* runOpenCodeJson({ operation: "generatePrContent", diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index e62a79afe787..ead09638776f 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -6,6 +6,7 @@ import { TextGenerationError } from "@t3tools/contracts"; import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; @@ -16,6 +17,7 @@ export interface CommitMessageGenerationInput { stagedPatch: string; /** When true, the model also returns a semantic branch name for the change. */ includeBranch?: boolean; + policy?: TextGenerationPolicy | undefined; /** What model and provider to use for generation. */ modelSelection: ModelSelection; } @@ -34,6 +36,8 @@ export interface PrContentGenerationInput { commitSummary: string; diffSummary: string; diffPatch: string; + changeRequestTemplate?: string | undefined; + policy?: TextGenerationPolicy | undefined; /** What model and provider to use for generation. */ modelSelection: ModelSelection; } @@ -77,7 +81,7 @@ export interface TextGenerationService { } /** - * TextGeneration - Service tag for commit and PR text generation. + * TextGeneration - Service tag for commit and change request text generation. */ export class TextGeneration extends Context.Service< TextGeneration, @@ -90,7 +94,7 @@ export class TextGeneration extends Context.Service< ) => Effect.Effect; /** - * Generate pull request title/body from branch and diff context. + * Generate change request title/body from branch and diff context. */ readonly generatePrContent: ( input: PrContentGenerationInput, diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index b67e8b93c4aa..65b61b99bfc2 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -49,6 +49,23 @@ describe("buildCommitMessagePrompt", () => { expect(result.prompt).toContain("Branch: (detached)"); }); + + it("includes policy instructions", () => { + const result = buildCommitMessagePrompt({ + branch: "main", + stagedSummary: "M a.ts", + stagedPatch: "diff", + includeBranch: false, + policy: { + kind: "custom", + commitInstructions: "Use a terse repository-specific subject.", + inferRepositoryConventions: false, + }, + }); + + expect(result.prompt).toContain("Additional instructions:"); + expect(result.prompt).toContain("Use a terse repository-specific subject."); + }); }); describe("buildPrContentPrompt", () => { @@ -69,6 +86,30 @@ describe("buildPrContentPrompt", () => { expect(result.prompt).toContain("3 files changed"); expect(result.prompt).toContain("Diff patch:"); expect(result.prompt).toContain("export function login()"); + expect(result.prompt).toContain("include headings '## Summary' and '## Testing'"); + }); + + it("follows a repository PR template instead of the default body headings", () => { + const result = buildPrContentPrompt({ + baseBranch: "main", + headBranch: "feature/auth", + commitSummary: "feat: add login page", + diffSummary: "3 files changed", + diffPatch: "diff", + changeRequestTemplate: "\n## What changed\n\n## Verification", + policy: { + kind: "custom", + changeRequestInstructions: "Keep the title in sentence case.", + inferRepositoryConventions: false, + }, + }); + + expect(result.prompt).toContain("Keep the title in sentence case."); + expect(result.prompt).toContain("follow the repository change request template structure"); + expect(result.prompt).toContain("drop HTML comments from the template"); + expect(result.prompt).toContain("Repository change request template:"); + expect(result.prompt).toContain("\n## What changed\n\n## Verification"); + expect(result.prompt).not.toContain("include headings '## Summary' and '## Testing'"); }); }); diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 6015e83b5d46..efa251963a53 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -25,12 +25,12 @@ export interface CommitMessagePromptInput { branch: string | null; stagedSummary: string; stagedPatch: string; - includeBranch: boolean; + includeBranch?: boolean; policy?: TextGenerationPolicy | undefined; } export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { - const wantsBranch = input.includeBranch; + const wantsBranch = input.includeBranch === true; const prompt = [ "You write concise git commit messages.", @@ -76,7 +76,7 @@ export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { } // --------------------------------------------------------------------------- -// PR content +// Change request content // --------------------------------------------------------------------------- export interface PrContentPromptInput { @@ -85,19 +85,34 @@ export interface PrContentPromptInput { commitSummary: string; diffSummary: string; diffPatch: string; + changeRequestTemplate?: string | undefined; policy?: TextGenerationPolicy | undefined; } export function buildPrContentPrompt(input: PrContentPromptInput) { + const changeRequestTemplate = input.changeRequestTemplate?.trim(); + const bodyRules = changeRequestTemplate + ? [ + "- body must be markdown and follow the repository change request template structure", + "- fill in the template sections appropriately for this change", + "- drop HTML comments from the template in the generated body", + "- keep the template's markdown structure", + ] + : [ + "- body must be markdown and include headings '## Summary' and '## Testing'", + "- under Summary, provide short bullet points", + "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", + ]; const prompt = [ - "You write GitHub pull request content.", + "You write source control change request content.", "Return a JSON object with keys: title, body.", "Rules:", "- title should be concise and specific", - "- body must be markdown and include headings '## Summary' and '## Testing'", - "- under Summary, provide short bullet points", - "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", + ...bodyRules, ...policyInstruction(input.policy?.changeRequestInstructions), + ...(changeRequestTemplate + ? ["", "Repository change request template:", limitSection(changeRequestTemplate, 8_000)] + : []), "", `Base branch: ${input.baseBranch}`, `Head branch: ${input.headBranch}`, diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index de74cfa2a90d..a9d3f541ff19 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -7,27 +7,56 @@ import { acquireBrowserSurface } from "./browserSurfaceStore"; export function BrowserSurfaceSlot(props: { readonly tabId: string; readonly visible: boolean; + readonly cornerRadius?: number; + readonly layoutVersion?: string | number; readonly className?: string; + readonly fitSourceContent?: boolean; }) { - const { tabId, visible, className } = props; + const { + tabId, + visible, + cornerRadius = 0, + layoutVersion, + className, + fitSourceContent = false, + } = props; const elementRef = useRef(null); + const presentationRef = useRef({ visible, cornerRadius }); + const updateRef = useRef<(() => void) | null>(null); useLayoutEffect(() => { const element = elementRef.current; if (!element) return; - const lease = acquireBrowserSurface(tabId); + let lease = acquireBrowserSurface(tabId, fitSourceContent); const update = () => { const rect = element.getBoundingClientRect(); - lease.present( + const presentation = presentationRef.current; + const presented = lease.present( { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)), }, - visible && rect.width > 0 && rect.height > 0, + presentation.visible && rect.width > 0 && rect.height > 0, + presentation.cornerRadius, ); + if (presentation.visible && !presented) { + lease.release(); + lease = acquireBrowserSurface(tabId, fitSourceContent); + lease.present( + { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.max(1, Math.round(rect.width)), + height: Math.max(1, Math.round(rect.height)), + }, + rect.width > 0 && rect.height > 0, + presentation.cornerRadius, + ); + } }; + updateRef.current = update; update(); const observer = new ResizeObserver(update); observer.observe(element); @@ -37,9 +66,15 @@ export function BrowserSurfaceSlot(props: { observer.disconnect(); window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true); + if (updateRef.current === update) updateRef.current = null; lease.release(); }; - }, [tabId, visible]); + }, [fitSourceContent, tabId]); + + useLayoutEffect(() => { + presentationRef.current = { visible, cornerRadius }; + updateRef.current?.(); + }, [cornerRadius, layoutVersion, visible]); return
; } diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 51fa73a721fd..fbf7c14b738c 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -11,6 +11,7 @@ import { useActivePreviewSessions } from "~/previewStateStore"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserPointerStore } from "./browserPointerStore"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; +import { previewRuntimeTabId } from "./previewRuntimeTabId"; export function ElectronBrowserHost() { const { resolvedTheme } = useTheme(); @@ -23,6 +24,11 @@ export function ElectronBrowserHost() { ? Object.values(previewState.sessions).map((snapshot) => ({ threadRef, snapshot, + runtimeTabId: previewRuntimeTabId( + threadRef, + previewState.serverEpoch, + snapshot.tabId, + ), zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1, })) : []; @@ -74,13 +80,14 @@ export function ElectronBrowserHost() { if (!isElectron) return null; return (
- {sessions.map(({ threadRef, snapshot, zoomFactor }) => { + {sessions.map(({ threadRef, snapshot, runtimeTabId, zoomFactor }) => { const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; return ( initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); const wrapperRef = useRef(null); const webviewRef = useRef(null); + const crashRecoveryRef = useRef(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE); const [lockedAspectRatio, setLockedAspectRatio] = useState(null); const presentation = useBrowserSurfaceStore( useShallow((state) => { - const current = state.byTabId[tabId]; + const current = state.byTabId[runtimeTabId]; return { - rect: resolveBrowserSurfacePanelRect(state.byTabId, tabId), + content: current?.content ?? null, + cornerRadius: current?.cornerRadius ?? 0, + fitSourceContent: current?.fitSourceContent ?? false, + fittedSourceContent: current?.fittedSourceContent ?? null, + rect: resolveBrowserSurfacePanelRect(state.byTabId, runtimeTabId), visible: current?.visible ?? false, }; }), ); - usePreviewBridge({ threadRef, tabId }); + usePreviewBridge({ threadRef, tabId, runtimeTabId }); useEffect(() => { - const lease = acquireDesktopTab(tabId); + crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; + const lease = acquireDesktopTab(runtimeTabId); tabLeaseRef.current = lease; return () => { if (tabLeaseRef.current === lease) tabLeaseRef.current = null; lease.release(); }; - }, [tabId]); + }, [runtimeTabId]); + + const [webviewGeneration, setWebviewGeneration] = useState(0); + const [recoverySrc, setRecoverySrc] = useState(initialSrc); + const latestUrlRef = useRef(initialUrl); + + useEffect(() => { + latestUrlRef.current = initialUrl; + }, [initialUrl]); const setWebviewRef = useCallback((node: HTMLElement | null) => { webviewRef.current = node as ElectronWebview | null; @@ -77,6 +101,7 @@ export function HostedBrowserWebview(props: { const bridge = previewBridge; if (!webview || !config || !bridge) return; let disposed = false; + let recoveryTimeout: ReturnType | null = null; const register = () => { const lease = tabLeaseRef.current; if (!lease) return; @@ -89,22 +114,38 @@ export function HostedBrowserWebview(props: { if (disposed || webviewRef.current !== webview) return; const webContentsId = webview.getWebContentsId(); if (Number.isInteger(webContentsId) && webContentsId > 0) { - await bridge.registerWebview(tabId, webContentsId); + await bridge.registerWebview(runtimeTabId, webContentsId); } } catch { // did-attach/dom-ready will retry if the guest was not ready yet. } })(); }; + const recoverGuest = () => { + if (disposed || recoveryTimeout !== null) return; + const recovery = planWebviewCrashRecovery(crashRecoveryRef.current, Date.now()); + if (!recovery) return; + crashRecoveryRef.current = recovery.state; + recoveryTimeout = setTimeout(() => { + recoveryTimeout = null; + if (!disposed) { + setRecoverySrc(latestUrlRef.current ?? initialSrc); + setWebviewGeneration((generation) => generation + 1); + } + }, recovery.delayMs); + }; webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); + webview.addEventListener("render-process-gone", recoverGuest); register(); return () => { disposed = true; + if (recoveryTimeout !== null) clearTimeout(recoveryTimeout); webview.removeEventListener("did-attach", register); webview.removeEventListener("dom-ready", register); + webview.removeEventListener("render-process-gone", recoverGuest); }; - }, [config, tabId]); + }, [config, initialSrc, runtimeTabId, webviewGeneration]); const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; @@ -116,35 +157,56 @@ export function HostedBrowserWebview(props: { useEffect(() => { setLockedAspectRatio((current) => reconcileLockedAspectRatio(current, viewportAspectRatio)); }, [viewportAspectRatio]); + const hiddenContentSize = presentation.content + ? { + width: presentation.content.width / presentation.content.scale, + height: presentation.content.height / presentation.content.scale, + } + : null; const hiddenSize = viewport._tag !== "fill" ? { width: viewport.width * normalizedZoomFactor, height: viewport.height * normalizedZoomFactor, } - : { width: lastRect?.width ?? 1280, height: lastRect?.height ?? 800 }; + : { + width: hiddenContentSize?.width ?? lastRect?.width ?? 1280, + height: hiddenContentSize?.height ?? lastRect?.height ?? 800, + }; const containerSize = active && lastRect ? lastRect : hiddenSize; - const deviceToolbarVisible = active && viewport._tag !== "fill"; + const deviceToolbarVisible = active && viewport._tag !== "fill" && !presentation.fitSourceContent; const { activeDrag, commitViewportChange, effectiveViewport, handleResizeKeyDown, handleResizePointerDown, - layout, + layout: viewportLayout, } = useBrowserViewportResize({ - tabId, + tabId: runtimeTabId, viewport, zoomFactor, containerSize, deviceToolbarVisible, aspectRatio: lockedAspectRatio, }); + const fittedSourceViewport = + presentation.fitSourceContent && lastRect + ? resolveFittedBrowserViewport( + viewport, + presentation.fittedSourceContent, + normalizedZoomFactor, + ) + : null; + const layout = + fittedSourceViewport && lastRect + ? resolveBrowserViewportLayout(lastRect, fittedSourceViewport, normalizedZoomFactor) + : viewportLayout; const syncContentPresentation = useCallback(() => { const wrapper = wrapperRef.current; if (!wrapper) return; - useBrowserSurfaceStore.getState().presentContent(tabId, { + useBrowserSurfaceStore.getState().presentContent(runtimeTabId, { x: layout.viewportX, y: layout.viewportY, width: layout.viewportWidth, @@ -153,7 +215,7 @@ export function HostedBrowserWebview(props: { scrollLeft: wrapper.scrollLeft, scrollTop: wrapper.scrollTop, }); - }, [layout, tabId]); + }, [layout, runtimeTabId]); useEffect(() => { const frameId = window.requestAnimationFrame(syncContentPresentation); @@ -164,12 +226,13 @@ export function HostedBrowserWebview(props: { const wrapper = wrapperRef.current; if (!wrapper) return; wrapper.scrollTo({ left: 0, top: 0 }); - }, [tabId, viewport._tag, viewportHeight, viewportWidth]); + }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]); if (!config) return null; const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ active, + cornerRadius: presentation.cornerRadius, rect: lastRect, hiddenSize, }); @@ -180,7 +243,7 @@ export function HostedBrowserWebview(props: { className="fixed overflow-hidden bg-muted/35" style={{ ...wrapperStyle, overscrollBehavior: "contain" }} onScroll={syncContentPresentation} - data-preview-viewport={tabId} + data-preview-viewport={runtimeTabId} >
{deviceToolbarVisible && effectiveViewport._tag !== "fill" ? ( @@ -193,23 +256,29 @@ export function HostedBrowserWebview(props: { /> ) : null} - {active && effectiveViewport._tag !== "fill" ? ( + {active && effectiveViewport._tag !== "fill" && !fittedSourceViewport ? ( <> { - const events: string[] = []; - const surfaceState = { - byTabId: {} as Record, - }; - return { - events, - onFrame: vi.fn(() => vi.fn()), - registrySet: vi.fn((_atom: unknown, value: string | null) => { - events.push(value === null ? "clear" : `publish:${value}`); - }), - save: vi.fn(async () => ({ - id: "recording-test", - tabId: "recording-tab", - path: "/tmp/recording-test.webm", - mimeType: "video/webm" as const, - sizeBytes: 0, - createdAt: "2026-06-26T00:00:00.000Z", - })), - startScreencast: vi.fn(async () => { - events.push("start-screencast"); - }), - stopScreencast: vi.fn(async () => undefined), - surfaceState, - }; - }); +const { + events, + frameSubscription, + onFrame, + registrySet, + save, + startScreencast, + stopScreencast, + surfaceState, +} = vi.hoisted(() => { + const events: string[] = []; + type Frame = { + readonly tabId: string; + readonly data: string; + readonly width: number; + readonly height: number; + readonly receivedAt: string; + }; + const frameSubscription: { listener: ((frame: Frame) => void) | null } = { + listener: null, + }; + const surfaceState = { + byTabId: {} as Record, + }; + return { + events, + frameSubscription, + onFrame: vi.fn((listener: (frame: Frame) => void) => { + frameSubscription.listener = listener; + return () => { + if (frameSubscription.listener === listener) frameSubscription.listener = null; + }; + }), + registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet }) => { + events.push( + value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`, + ); + }), + save: vi.fn(async (tabId: string) => ({ + id: "recording-test", + tabId, + path: "/tmp/recording-test.webm", + mimeType: "video/webm" as const, + sizeBytes: 0, + createdAt: "2026-06-26T00:00:00.000Z", + })), + startScreencast: vi.fn(async (tabId: string) => { + events.push("start-screencast"); + const surface = surfaceState.byTabId[tabId] as + | { + readonly content?: { readonly width: number; readonly height: number }; + readonly rect?: { readonly width: number; readonly height: number }; + } + | undefined; + const size = surface?.content ?? surface?.rect; + frameSubscription.listener?.({ + tabId, + data: "initial-frame", + width: size?.width ?? 1280, + height: size?.height ?? 800, + receivedAt: "2026-06-26T00:00:00.000Z", + }); + }), + stopScreencast: vi.fn(async () => undefined), + surfaceState, + }; +}); vi.mock("~/components/preview/previewBridge", () => ({ previewBridge: { @@ -45,12 +86,16 @@ vi.mock("./browserSurfaceStore", () => ({ })); import { + BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS, BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, BrowserRecordingConflictError, - BrowserRecordingOperationError, + findActiveBrowserRecordingRuntimeTabId, + readActiveBrowserRecordingTabIds, + readActiveBrowserRecordingTargets, startBrowserRecording, stopBrowserRecording, } from "./browserRecording"; +import { previewRuntimeTabId } from "./previewRuntimeTabId"; class FakeMediaRecorder { static isTypeSupported(): boolean { @@ -79,9 +124,20 @@ class FakeMediaRecorder { } } +const emitRecordingFrame = () => { + frameSubscription.listener?.({ + tabId: "recording-tab", + data: "startup-frame", + width: 800, + height: 600, + receivedAt: "2026-06-26T00:00:00.000Z", + }); +}; + describe("browser recording", () => { beforeEach(() => { events.length = 0; + frameSubscription.listener = null; surfaceState.byTabId = { "recording-tab": { visible: true, @@ -92,12 +148,26 @@ describe("browser recording", () => { vi.clearAllMocks(); vi.stubGlobal("window", globalThis); vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder); + class ImmediateImage { + private loadListener: EventListenerOrEventListenerObject | undefined; + + addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + if (type === "load") this.loadListener = listener; + } + + set src(_value: string) { + const event = new Event("load"); + if (typeof this.loadListener === "function") this.loadListener(event); + else this.loadListener?.handleEvent(event); + } + } + vi.stubGlobal("Image", ImmediateImage as unknown as typeof Image); vi.stubGlobal("document", { createElement: () => ({ width: 0, height: 0, captureStream: () => ({}), - getContext: () => ({ drawImage: vi.fn() }), + getContext: () => ({ drawImage: vi.fn(), fillRect: vi.fn(), fillStyle: "" }), }), }); }); @@ -115,7 +185,7 @@ describe("browser recording", () => { await stopBrowserRecording("recording-tab"); }); - it("starts recording for an offscreen tab with retained content size", async () => { + it("records a hidden tab without requiring it to become visible", async () => { surfaceState.byTabId = { "recording-tab": { visible: false, @@ -132,10 +202,221 @@ describe("browser recording", () => { await stopBrowserRecording("recording-tab"); }); + it("fails startup instead of locking a fallback size when no frame arrives", async () => { + vi.useFakeTimers(); + startScreencast.mockImplementationOnce(async () => { + events.push("start-screencast"); + }); + + const startPromise = startBrowserRecording("recording-tab"); + const rejection = expect(startPromise).rejects.toMatchObject({ + operation: "wait-first-frame", + tabId: "recording-tab", + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS); + + await rejection; + expect(stopScreencast).toHaveBeenCalledWith("recording-tab"); + expect(events.at(-1)).toBe("clear"); + }); + + it("fixes hidden recording dimensions before MediaRecorder starts", async () => { + const drawImage = vi.fn(); + const fillRect = vi.fn(); + let capturedStreamSize: { readonly width: number; readonly height: number } | undefined; + const canvas = { + width: 0, + height: 0, + captureStream: () => { + capturedStreamSize = { width: canvas.width, height: canvas.height }; + return {}; + }, + getContext: () => ({ drawImage, fillRect, fillStyle: "" }), + }; + vi.stubGlobal("document", { + createElement: () => canvas, + }); + surfaceState.byTabId = {}; + startScreencast.mockImplementationOnce(async (tabId: string) => { + events.push("start-screencast"); + frameSubscription.listener?.({ + tabId, + data: "captured-frame", + width: 390, + height: 844, + receivedAt: "2026-06-26T00:00:00.000Z", + }); + }); + + await startBrowserRecording("recording-tab"); + + expect(canvas).toMatchObject({ width: 390, height: 844 }); + expect(capturedStreamSize).toEqual({ width: 390, height: 844 }); + expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 390, 844); + + frameSubscription.listener?.({ + tabId: "recording-tab", + data: "different-sized-frame", + width: 1280, + height: 720, + receivedAt: "2026-06-26T00:00:01.000Z", + }); + + expect(canvas).toMatchObject({ width: 390, height: 844 }); + expect(fillRect).toHaveBeenLastCalledWith(0, 0, 390, 844); + + await stopBrowserRecording("recording-tab"); + }); + + it("draws the newest decoded frames without starving behind decode latency", async () => { + const drawImage = vi.fn(); + class DeferredImage { + static readonly instances: DeferredImage[] = []; + private loadListener: EventListenerOrEventListenerObject | undefined; + + constructor() { + DeferredImage.instances.push(this); + } + + addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + if (type === "load") this.loadListener = listener; + } + + set src(_value: string) {} + + finishLoading(): void { + const event = new Event("load"); + if (typeof this.loadListener === "function") this.loadListener(event); + else this.loadListener?.handleEvent(event); + } + } + vi.stubGlobal("Image", DeferredImage as unknown as typeof Image); + vi.stubGlobal("document", { + createElement: () => ({ + width: 0, + height: 0, + captureStream: () => ({}), + getContext: () => ({ drawImage, fillRect: vi.fn(), fillStyle: "" }), + }), + }); + + await startBrowserRecording("recording-tab"); + frameSubscription.listener?.({ + tabId: "recording-tab", + data: "second-frame", + width: 800, + height: 600, + receivedAt: "2026-06-26T00:00:01.000Z", + }); + frameSubscription.listener?.({ + tabId: "recording-tab", + data: "third-frame", + width: 800, + height: 600, + receivedAt: "2026-06-26T00:00:02.000Z", + }); + + DeferredImage.instances[1]?.finishLoading(); + expect(drawImage).toHaveBeenCalledOnce(); + DeferredImage.instances[2]?.finishLoading(); + expect(drawImage).toHaveBeenCalledTimes(2); + DeferredImage.instances[0]?.finishLoading(); + expect(drawImage).toHaveBeenCalledTimes(2); + + await stopBrowserRecording("recording-tab"); + }); + + it("records separate tabs concurrently", async () => { + const firstThreadRef = { + environmentId: EnvironmentId.make("environment-recording"), + threadId: ThreadId.make("thread-recording-first"), + }; + const secondThreadRef = { + environmentId: EnvironmentId.make("environment-recording"), + threadId: ThreadId.make("thread-recording-second"), + }; + surfaceState.byTabId = { + ...surfaceState.byTabId, + "recording-tab-2": { + visible: false, + rect: { x: 0, y: 0, width: 390, height: 844 }, + content: { x: 0, y: 0, width: 390, height: 844, scale: 1, scrollLeft: 0, scrollTop: 0 }, + }, + }; + + await Promise.all([ + startBrowserRecording("recording-tab", firstThreadRef), + startBrowserRecording("recording-tab-2", secondThreadRef), + ]); + + expect(startScreencast).toHaveBeenCalledTimes(2); + expect(onFrame).toHaveBeenCalledOnce(); + expect(events).toContain("publish:recording-tab,recording-tab-2"); + expect(readActiveBrowserRecordingTabIds()).toEqual( + new Set(["recording-tab", "recording-tab-2"]), + ); + expect(readActiveBrowserRecordingTabIds(firstThreadRef)).toEqual(new Set(["recording-tab"])); + expect(readActiveBrowserRecordingTabIds(secondThreadRef)).toEqual(new Set(["recording-tab-2"])); + + await stopBrowserRecording("recording-tab"); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set(["recording-tab-2"])); + await stopBrowserRecording("recording-tab-2"); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + expect(save).toHaveBeenCalledTimes(2); + }); + + it("keeps a recording reachable through its runtime id after a server epoch changes", async () => { + const threadRef = { + environmentId: EnvironmentId.make("environment-recording"), + threadId: ThreadId.make("thread-recording-scoped"), + }; + const runtimeTabId = previewRuntimeTabId(threadRef, "epoch-a", "tab_1"); + surfaceState.byTabId = { + [runtimeTabId]: { + visible: false, + rect: { x: 0, y: 0, width: 1280, height: 800 }, + content: { + x: 0, + y: 0, + width: 1280, + height: 800, + scale: 1, + scrollLeft: 0, + scrollTop: 0, + }, + }, + }; + + await startBrowserRecording(runtimeTabId, threadRef, "tab_1"); + + expect(startScreencast).toHaveBeenCalledWith(runtimeTabId); + expect(readActiveBrowserRecordingTabIds(threadRef)).toEqual(new Set([runtimeTabId])); + expect(readActiveBrowserRecordingTargets(threadRef)).toEqual([ + { runtimeTabId, serverTabId: "tab_1" }, + ]); + expect(findActiveBrowserRecordingRuntimeTabId(threadRef, "tab_1")).toBe(runtimeTabId); + + const replacementRuntimeTabId = previewRuntimeTabId(threadRef, "epoch-b", "tab_1"); + await expect( + startBrowserRecording(replacementRuntimeTabId, threadRef, "tab_1"), + ).rejects.toBeInstanceOf(BrowserRecordingConflictError); + expect(startScreencast).toHaveBeenCalledTimes(1); + + await stopBrowserRecording(runtimeTabId); + }); + it("does not report success for a second start while the first is still starting", async () => { let finishStartingScreencast: (() => void) | undefined; - startScreencast.mockImplementationOnce(async () => { + startScreencast.mockImplementationOnce(async (tabId: string) => { events.push("start-screencast"); + frameSubscription.listener?.({ + tabId, + data: "initial-frame", + width: 800, + height: 600, + receivedAt: "2026-06-26T00:00:00.000Z", + }); await new Promise((resolve) => { finishStartingScreencast = resolve; }); @@ -196,32 +477,27 @@ describe("browser recording", () => { expect(save).toHaveBeenCalledOnce(); }); - it("stops a screencast that finishes starting after cancellation", async () => { + it("finishes startup before stopping so an active recording yields an artifact", async () => { let finishStartingScreencast: (() => void) | undefined; startScreencast.mockImplementationOnce(async () => { events.push("start-screencast"); await new Promise((resolve) => { finishStartingScreencast = resolve; }); + emitRecordingFrame(); }); const startPromise = startBrowserRecording("recording-tab"); - const rejectedStart = expect(startPromise).rejects.toBeInstanceOf( - BrowserRecordingOperationError, - ); await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); const stopPromise = stopBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); + expect(stopScreencast).not.toHaveBeenCalled(); finishStartingScreencast?.(); - await rejectedStart; - await expect(stopPromise).rejects.toMatchObject({ - operation: "wait-startup", - tabId: "recording-tab", - }); - expect(stopScreencast).toHaveBeenCalledTimes(2); - expect(save).not.toHaveBeenCalled(); + await startPromise; + await expect(stopPromise).resolves.toMatchObject({ tabId: "recording-tab" }); + expect(stopScreencast).toHaveBeenCalledOnce(); + expect(save).toHaveBeenCalledOnce(); expect(events.at(-1)).toBe("clear"); }); @@ -232,34 +508,59 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); + emitRecordingFrame(); }); const firstStart = startBrowserRecording("recording-tab"); - const rejectedFirstStart = expect(firstStart).rejects.toBeInstanceOf( - BrowserRecordingOperationError, - ); await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); const stopPromise = stopBrowserRecording("recording-tab"); - const rejectedStop = expect(stopPromise).rejects.toMatchObject({ - operation: "wait-startup", - tabId: "recording-tab", - }); - const restartAfterStop = stopPromise - .catch(() => null) - .then(() => startBrowserRecording("recording-tab")); + const restartAfterStop = stopPromise.then(() => startBrowserRecording("recording-tab")); await new Promise((resolve) => setTimeout(resolve, 0)); const startCallsBeforeFirstSettled = startScreencast.mock.calls.length; finishStartingScreencast?.(); - await rejectedFirstStart; - await rejectedStop; + await firstStart; + await stopPromise; await restartAfterStop; await stopBrowserRecording("recording-tab"); expect(startCallsBeforeFirstSettled).toBe(1); }); + it("keeps the recording slot while a failed stop waits for startup", async () => { + let finishStartingScreencast: (() => void) | undefined; + startScreencast.mockImplementationOnce(async () => { + events.push("start-screencast"); + await new Promise((resolve) => { + finishStartingScreencast = resolve; + }); + emitRecordingFrame(); + }); + stopScreencast.mockRejectedValueOnce(new Error("initial stop failed")); + + const firstStart = startBrowserRecording("recording-tab"); + await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); + + const stopPromise = stopBrowserRecording("recording-tab"); + const rejectedStop = expect(stopPromise).rejects.toMatchObject({ + operation: "stop-screencast", + tabId: "recording-tab", + }); + expect(stopScreencast).not.toHaveBeenCalled(); + await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( + BrowserRecordingConflictError, + ); + + finishStartingScreencast?.(); + await firstStart; + await rejectedStop; + expect(stopScreencast).toHaveBeenCalledOnce(); + + await startBrowserRecording("recording-tab"); + await stopBrowserRecording("recording-tab"); + }); + it("fails a stop that waits too long for startup without freeing the recording slot", async () => { vi.useFakeTimers(); let finishStartingScreencast: (() => void) | undefined; @@ -268,18 +569,16 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); + emitRecordingFrame(); }); const startPromise = startBrowserRecording("recording-tab"); - const rejectedStart = expect(startPromise).rejects.toBeInstanceOf( - BrowserRecordingOperationError, - ); expect(startScreencast).toHaveBeenCalledOnce(); const stopPromise = stopBrowserRecording("recording-tab"); await Promise.resolve(); await Promise.resolve(); - expect(stopScreencast).toHaveBeenCalledOnce(); + expect(stopScreencast).not.toHaveBeenCalled(); const rejection = expect(stopPromise).rejects.toMatchObject({ operation: "wait-startup", @@ -294,13 +593,16 @@ describe("browser recording", () => { ); finishStartingScreencast?.(); - await rejectedStart; + await startPromise; const cleanupResult = await stopBrowserRecording("recording-tab"); expect(cleanupResult).toBeNull(); + expect(stopScreencast).toHaveBeenCalledOnce(); expect(save).not.toHaveBeenCalled(); expect(events.at(-1)).toBe("clear"); }); + // A startup failure must reach a stop that is already waiting on startup, + // instead of settling it as if the recording had started successfully. it("propagates screencast startup failures to a pending stop without saving", async () => { let rejectStartingScreencast: ((error: Error) => void) | undefined; startScreencast.mockImplementationOnce(async () => { @@ -311,24 +613,31 @@ describe("browser recording", () => { }); const startPromise = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); - - const stopPromise = stopBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); - rejectStartingScreencast?.(new Error("DevTools attachment rejected")); - - await expect(startPromise).rejects.toMatchObject({ + expect(startScreencast).toHaveBeenCalledOnce(); + const rejectedStart = expect(startPromise).rejects.toMatchObject({ operation: "start-screencast", tabId: "recording-tab", }); - await expect(stopPromise).rejects.toMatchObject({ + + const stopPromise = stopBrowserRecording("recording-tab"); + await Promise.resolve(); + await Promise.resolve(); + // The stop parks on startup rather than cancelling the screencast. + expect(stopScreencast).not.toHaveBeenCalled(); + const rejectedStop = expect(stopPromise).rejects.toMatchObject({ operation: "wait-startup", tabId: "recording-tab", }); + + rejectStartingScreencast?.(new Error("DevTools attachment rejected")); + await rejectedStart; + await rejectedStop; expect(save).not.toHaveBeenCalled(); expect(events.at(-1)).toBe("clear"); }); + // The slot stays held after a stop times out, and is only released once the + // abandoned start settles — here by rejecting rather than completing. it("cleans up a timed-out recording when startup later rejects", async () => { vi.useFakeTimers(); let rejectStartingScreencast: ((error: Error) => void) | undefined; @@ -340,7 +649,11 @@ describe("browser recording", () => { }); const startPromise = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); + expect(startScreencast).toHaveBeenCalledOnce(); + const rejectedStart = expect(startPromise).rejects.toMatchObject({ + operation: "start-screencast", + tabId: "recording-tab", + }); const stopPromise = stopBrowserRecording("recording-tab"); const rejectedStop = expect(stopPromise).rejects.toMatchObject({ @@ -349,19 +662,15 @@ describe("browser recording", () => { }); await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS); await rejectedStop; + await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( BrowserRecordingConflictError, ); rejectStartingScreencast?.(new Error("DevTools attachment rejected")); - await expect(startPromise).rejects.toMatchObject({ - operation: "start-screencast", - tabId: "recording-tab", - }); - await vi.waitFor(() => expect(events.at(-1)).toBe("clear")); + await rejectedStart; + await vi.advanceTimersByTimeAsync(0); expect(save).not.toHaveBeenCalled(); - - await startBrowserRecording("recording-tab"); - await stopBrowserRecording("recording-tab"); + expect(events.at(-1)).toBe("clear"); }); }); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index f501f81db762..e4c5842340db 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -1,6 +1,7 @@ import type { DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, + ScopedThreadRef, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; @@ -55,6 +56,7 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass; + readonly firstFrameSize: Promise<"frame" | "cancelled">; + readonly settleFirstFrameSize: (outcome: "frame" | "cancelled") => void; + recorder: MediaRecorder | null; + mimeType: string | null; + frameSizeEstablished: boolean; + frameSequence: number; + lastDrawnFrameSequence: number; lifecycle: BrowserRecordingLifecycle; } -const activeBrowserRecordingTabIdAtom = Atom.make(null).pipe( - Atom.keepAlive, - Atom.withLabel("preview:active-browser-recording-tab"), -); +export interface ActiveBrowserRecordingTarget { + readonly runtimeTabId: string; + readonly serverTabId: string; +} -export function useActiveBrowserRecordingTabId(): string | null { - return useAtomValue(activeBrowserRecordingTabIdAtom); +interface ActiveBrowserRecordingIndex { + readonly tabIds: ReadonlySet; } -let active: ActiveRecording | null = null; +const activeBrowserRecordingTabIdsAtom = Atom.make({ + tabIds: new Set(), +}).pipe(Atom.keepAlive, Atom.withLabel("preview:active-browser-recording-tabs")); + +export function useActiveBrowserRecordingTabIds(): ReadonlySet { + return useAtomValue(activeBrowserRecordingTabIdsAtom).tabIds; +} + +const activeRecordings = new Map(); let unsubscribeFrames: (() => void) | null = null; export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000; +export const BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS = 5_000; + +export function readActiveBrowserRecordingTabIds(threadRef?: ScopedThreadRef): ReadonlySet { + const tabIds = new Set(); + for (const recording of activeRecordings.values()) { + if ( + threadRef === undefined || + (recording.threadRef?.environmentId === threadRef.environmentId && + recording.threadRef.threadId === threadRef.threadId) + ) { + tabIds.add(recording.tabId); + } + } + return tabIds; +} -export function readActiveBrowserRecordingTabId(): string | null { - return active?.tabId ?? null; +export function readActiveBrowserRecordingTargets( + threadRef: ScopedThreadRef, +): ReadonlyArray { + return Array.from(activeRecordings.values()).flatMap((recording) => + recording.threadRef?.environmentId === threadRef.environmentId && + recording.threadRef.threadId === threadRef.threadId + ? [{ runtimeTabId: recording.tabId, serverTabId: recording.serverTabId }] + : [], + ); +} + +export function findActiveBrowserRecordingRuntimeTabId( + threadRef: ScopedThreadRef, + serverTabId: string, +): string | null { + return ( + readActiveBrowserRecordingTargets(threadRef).find( + (recording) => recording.serverTabId === serverTabId, + )?.runtimeTabId ?? null + ); } const preferredMimeType = (): string => { @@ -115,22 +167,52 @@ const preferredMimeType = (): string => { }; const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { - const recording = active; - if (!recording || recording.tabId !== frame.tabId) return; + const recording = activeRecordings.get(frame.tabId); + if (!recording) return; + if ( + !Number.isFinite(frame.width) || + !Number.isFinite(frame.height) || + frame.width <= 0 || + frame.height <= 0 + ) { + return; + } + const width = Math.max(1, Math.round(frame.width)); + const height = Math.max(1, Math.round(frame.height)); + if (!recording.frameSizeEstablished) { + recording.canvas.width = width; + recording.canvas.height = height; + recording.frameSizeEstablished = true; + recording.settleFirstFrameSize("frame"); + } + const frameSequence = ++recording.frameSequence; const image = new Image(); image.addEventListener( "load", () => { - if (active !== recording) return; - recording.context.drawImage(image, 0, 0, recording.canvas.width, recording.canvas.height); + if ( + activeRecordings.get(frame.tabId) !== recording || + frameSequence <= recording.lastDrawnFrameSequence + ) { + return; + } + recording.lastDrawnFrameSequence = frameSequence; + const scale = Math.min(recording.canvas.width / width, recording.canvas.height / height); + const targetWidth = width * scale; + const targetHeight = height * scale; + const targetX = (recording.canvas.width - targetWidth) / 2; + const targetY = (recording.canvas.height - targetHeight) / 2; + recording.context.fillStyle = "#000000"; + recording.context.fillRect(0, 0, recording.canvas.width, recording.canvas.height); + recording.context.drawImage(image, targetX, targetY, targetWidth, targetHeight); }, { once: true }, ); image.src = `data:image/jpeg;base64,${frame.data}`; }; -const stopMediaRecorder = async (recorder: MediaRecorder): Promise => { - if (recorder.state === "inactive") return; +const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise => { + if (!recorder || recorder.state === "inactive") return; const stopped = new Promise((resolve) => recorder.addEventListener("stop", () => resolve(), { once: true }), ); @@ -139,11 +221,42 @@ const stopMediaRecorder = async (recorder: MediaRecorder): Promise => { }; const clearActiveRecording = (recording: ActiveRecording): void => { - if (active !== recording) return; - active = null; - unsubscribeFrames?.(); - unsubscribeFrames = null; - appAtomRegistry.set(activeBrowserRecordingTabIdAtom, null); + if (activeRecordings.get(recording.tabId) !== recording) return; + recording.settleFirstFrameSize("cancelled"); + activeRecordings.delete(recording.tabId); + if (activeRecordings.size === 0) { + unsubscribeFrames?.(); + unsubscribeFrames = null; + } + appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { + tabIds: new Set(activeRecordings.keys()), + }); +}; + +const cleanupFailedRecordingStart = async ( + bridge: NonNullable, + recording: ActiveRecording, +): Promise => { + const errors: unknown[] = []; + try { + await bridge.recording.stopScreencast(recording.tabId); + } catch (error) { + errors.push(error); + } + try { + await stopMediaRecorder(recording.recorder); + } catch (error) { + errors.push(error); + } finally { + clearActiveRecording(recording); + } + if (errors.length === 0) return undefined; + if (errors.length === 1) return errors[0]; + return new AggregateError( + errors, + `Browser recording startup cleanup failed for tab ${recording.tabId}.`, + { cause: errors[0] }, + ); }; const recordingStartupCancelledError = ( @@ -157,7 +270,20 @@ const recordingStartupCancelledError = ( }); const isRecordingStarting = (recording: ActiveRecording): boolean => - active === recording && recording.lifecycle.phase === "starting"; + activeRecordings.get(recording.tabId) === recording && recording.lifecycle.phase === "starting"; + +const waitForFirstFrameSize = async (recording: ActiveRecording): Promise => { + if (recording.frameSizeEstablished) return true; + let timeout: ReturnType | null = null; + const outcome = await Promise.race([ + recording.firstFrameSize, + new Promise<"timeout">((resolve) => { + timeout = setTimeout(() => resolve("timeout"), BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS); + }), + ]); + if (timeout !== null) clearTimeout(timeout); + return outcome === "frame"; +}; const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Promise => { let timeout: ReturnType | null = null; @@ -187,16 +313,29 @@ const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperatio error.cause instanceof Error && error.cause.message.startsWith("Browser recording startup did not settle"); -export async function startBrowserRecording(tabId: string): Promise { +export async function startBrowserRecording( + tabId: string, + threadRef: ScopedThreadRef | null = null, + serverTabId = tabId, +): Promise { const bridge = previewBridge; if (!bridge) throw new BrowserRecordingUnavailableError({ tabId }); - if (active) { - if (active.tabId === tabId && active.lifecycle.phase === "recording") { - return active.startedAt; + const activeRecording = activeRecordings.get(tabId); + if (activeRecording) { + if (activeRecording.lifecycle.phase === "recording") { + return activeRecording.startedAt; } throw new BrowserRecordingConflictError({ requestedTabId: tabId, - activeTabId: active.tabId, + activeTabId: activeRecording.tabId, + }); + } + const activeLogicalRecording = + threadRef === null ? null : findActiveBrowserRecordingRuntimeTabId(threadRef, serverTabId); + if (activeLogicalRecording !== null) { + throw new BrowserRecordingConflictError({ + requestedTabId: tabId, + activeTabId: activeLogicalRecording, }); } const surface = useBrowserSurfaceStore.getState().byTabId[tabId]; @@ -212,21 +351,6 @@ export async function startBrowserRecording(tabId: string): Promise { height: canvas.height, }); } - let mimeType: string; - let recorder: MediaRecorder; - try { - mimeType = preferredMimeType(); - recorder = new MediaRecorder(canvas.captureStream(12), { - mimeType, - videoBitsPerSecond: 4_000_000, - }); - } catch (cause) { - throw new BrowserRecordingOperationError({ - operation: "initialize-media-recorder", - tabId, - cause, - }); - } const startedAt = new Date().toISOString(); const chunks: Blob[] = []; let settleStartup: (() => void) | undefined; @@ -236,6 +360,8 @@ export async function startBrowserRecording(tabId: string): Promise { settleStartup = resolve; failStartup = reject; }); + // Startup can now reject, so keep a permanent handler attached: waiters + // subscribe later and an unobserved rejection would otherwise be fatal. void startupSettled.catch(() => undefined); const resolveStartup = () => { if (startupComplete) return; @@ -247,88 +373,61 @@ export async function startBrowserRecording(tabId: string): Promise { startupComplete = true; failStartup?.(error); }; - recorder.addEventListener("dataavailable", (event) => { - if (event.data.size > 0) chunks.push(event.data); + let settleFirstFrameSize: ((outcome: "frame" | "cancelled") => void) | undefined; + const firstFrameSize = new Promise<"frame" | "cancelled">((resolve) => { + settleFirstFrameSize = resolve; }); const recording: ActiveRecording = { tabId, + serverTabId, + threadRef, canvas, context, - recorder, chunks, - mimeType, startedAt, startupSettled, + firstFrameSize, + settleFirstFrameSize: (outcome) => settleFirstFrameSize?.(outcome), + recorder: null, + mimeType: null, + frameSizeEstablished: false, + frameSequence: 0, + lastDrawnFrameSequence: 0, lifecycle: { phase: "starting" }, }; - active = recording; - { + activeRecordings.set(tabId, recording); + try { try { unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); } catch (cause) { - const error = new BrowserRecordingOperationError({ - operation: "subscribe-frames", - tabId, - cause, - }); clearActiveRecording(recording); - rejectStartup(error); - throw error; - } - try { - recorder.start(1_000); - } catch (cause) { - const error = new BrowserRecordingOperationError({ - operation: "start-media-recorder", + throw new BrowserRecordingOperationError({ + operation: "subscribe-frames", tabId, cause, }); - clearActiveRecording(recording); - rejectStartup(error); - throw error; - } - if (!isRecordingStarting(recording)) { - const error = recordingStartupCancelledError(recording); - rejectStartup(error); - throw error; } try { await bridge.recording.startScreencast(tabId); } catch (cause) { if (!isRecordingStarting(recording)) { - const error = recordingStartupCancelledError(recording, cause); - rejectStartup(error); - throw error; - } - let cleanupCause: unknown; - try { - await stopMediaRecorder(recorder); - } catch (error) { - cleanupCause = error; - } finally { - clearActiveRecording(recording); + throw recordingStartupCancelledError(recording, cause); } - const error = new BrowserRecordingOperationError({ + clearActiveRecording(recording); + throw new BrowserRecordingOperationError({ operation: "start-screencast", tabId, - cause: - cleanupCause === undefined - ? cause - : new AggregateError( - [cause, cleanupCause], - `Browser recording start and cleanup failed for tab ${tabId}.`, - { cause }, - ), + cause, }); - rejectStartup(error); - throw error; } - if (!isRecordingStarting(recording)) { - let error: BrowserRecordingOperationError; + const throwIfStartupCancelled = async (): Promise => { + // A stop requested during startup should let startup finish so the + // caller receives a real artifact. Only replacement/removal cancels it. + if (activeRecordings.get(tabId) === recording) return; try { await bridge.recording.stopScreencast(tabId); } catch (cause) { - error = recordingStartupCancelledError( + throw recordingStartupCancelledError( recording, new AggregateError( [new Error(`Browser recording startup was cancelled for tab ${tabId}.`), cause], @@ -336,29 +435,105 @@ export async function startBrowserRecording(tabId: string): Promise { { cause }, ), ); - rejectStartup(error); - throw error; } - error = recordingStartupCancelledError(recording); - rejectStartup(error); - throw error; + throw recordingStartupCancelledError(recording); + }; + await throwIfStartupCancelled(); + const hasFirstFrame = await waitForFirstFrameSize(recording); + await throwIfStartupCancelled(); + if (!hasFirstFrame) { + const cause = new Error(`No valid recording frame arrived for tab ${tabId}.`); + const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); + throw new BrowserRecordingOperationError({ + operation: "wait-first-frame", + tabId, + cause: + cleanupCause === undefined + ? cause + : new AggregateError( + [cause, cleanupCause], + `Browser recording frame wait and cleanup failed for tab ${tabId}.`, + { cause }, + ), + }); } - resolveStartup(); - recording.lifecycle = { phase: "recording" }; - appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId); + + let mimeType: string; + let recorder: MediaRecorder; + try { + mimeType = preferredMimeType(); + recorder = new MediaRecorder(canvas.captureStream(12), { + mimeType, + videoBitsPerSecond: 4_000_000, + }); + recording.mimeType = mimeType; + recording.recorder = recorder; + recorder.addEventListener("dataavailable", (event) => { + if (event.data.size > 0) chunks.push(event.data); + }); + } catch (cause) { + const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); + throw new BrowserRecordingOperationError({ + operation: "initialize-media-recorder", + tabId, + cause: + cleanupCause === undefined + ? cause + : new AggregateError( + [cause, cleanupCause], + `Browser recording initialization and cleanup failed for tab ${tabId}.`, + { cause }, + ), + }); + } + try { + recorder.start(1_000); + } catch (cause) { + const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); + throw new BrowserRecordingOperationError({ + operation: "start-media-recorder", + tabId, + cause: + cleanupCause === undefined + ? cause + : new AggregateError( + [cause, cleanupCause], + `Browser media recorder start and cleanup failed for tab ${tabId}.`, + { cause }, + ), + }); + } + if (recording.lifecycle.phase === "starting") { + recording.lifecycle = { phase: "recording" }; + } + appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { + tabIds: new Set(activeRecordings.keys()), + }); return startedAt; + } catch (error) { + // Propagate the real startup failure to `startupSettled` waiters instead of + // resolving them as if startup had succeeded. + rejectStartup(error); + throw error; + } finally { + // No-op once `rejectStartup` has settled the promise. + resolveStartup(); } } const finalizeBrowserRecording = async ( bridge: NonNullable, recording: ActiveRecording, -): Promise => { +): Promise => { const { tabId } = recording; let result: - | { readonly _tag: "Success"; readonly artifact: DesktopPreviewRecordingArtifact } + | { + readonly _tag: "Success"; + readonly artifact: DesktopPreviewRecordingArtifact | null; + } | { readonly _tag: "Failure"; readonly error: unknown }; try { + await waitForRecordingStartupToSettle(recording); try { await bridge.recording.stopScreencast(tabId); } catch (cause) { @@ -368,30 +543,33 @@ const finalizeBrowserRecording = async ( cause, }); } - await waitForRecordingStartupToSettle(recording); - try { - await stopMediaRecorder(recording.recorder); - } catch (cause) { - throw new BrowserRecordingOperationError({ - operation: "stop-media-recorder", - tabId, - cause, - }); - } - try { - const blob = new Blob(recording.chunks, { type: recording.mimeType }); - const artifact = await bridge.recording.save( - tabId, - recording.mimeType, - new Uint8Array(await blob.arrayBuffer()), - ); - result = { _tag: "Success", artifact }; - } catch (cause) { - throw new BrowserRecordingOperationError({ - operation: "save-artifact", - tabId, - cause, - }); + if (!recording.recorder || !recording.mimeType) { + result = { _tag: "Success", artifact: null }; + } else { + try { + await stopMediaRecorder(recording.recorder); + } catch (cause) { + throw new BrowserRecordingOperationError({ + operation: "stop-media-recorder", + tabId, + cause, + }); + } + try { + const blob = new Blob(recording.chunks, { type: recording.mimeType }); + const artifact = await bridge.recording.save( + tabId, + recording.mimeType, + new Uint8Array(await blob.arrayBuffer()), + ); + result = { _tag: "Success", artifact }; + } catch (cause) { + throw new BrowserRecordingOperationError({ + operation: "save-artifact", + tabId, + cause, + }); + } } } catch (error) { result = { _tag: "Failure", error }; @@ -453,14 +631,14 @@ export function stopBrowserRecording( tabId: string, ): Promise { const bridge = previewBridge; - const recording = active; - if (!bridge || !recording || recording.tabId !== tabId) return Promise.resolve(null); + const recording = activeRecordings.get(tabId); + if (!bridge || !recording) return Promise.resolve(null); if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise; const stopPromise = Promise.resolve() .then(() => finalizeBrowserRecording(bridge, recording)) .catch((error) => { - if (isStartupWaitTimeout(error) && active === recording) { + if (isStartupWaitTimeout(error) && activeRecordings.get(recording.tabId) === recording) { const cleanupAfterStartup = recording.startupSettled .catch(() => undefined) .then(() => discardBrowserRecording(bridge, recording)); diff --git a/apps/web/src/browser/browserRecordingScope.test.ts b/apps/web/src/browser/browserRecordingScope.test.ts index 6b3adf2219ef..304adf4f19e0 100644 --- a/apps/web/src/browser/browserRecordingScope.test.ts +++ b/apps/web/src/browser/browserRecordingScope.test.ts @@ -3,14 +3,41 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope"; describe("resolveBrowserRecordingStopTarget", () => { - it("stops the active recording when no explicit tab was requested", () => { - expect(resolveBrowserRecordingStopTarget("tab-a")).toBe("tab-a"); - expect(resolveBrowserRecordingStopTarget("tab-b")).toBe("tab-b"); - expect(resolveBrowserRecordingStopTarget(null)).toBeNull(); + it("stops the only active recording when the implicit browser target changed", () => { + expect(resolveBrowserRecordingStopTarget(new Set(["tab-recording"]), "tab-browsing")).toBe( + "tab-recording", + ); }); - it("only stops an explicitly requested tab when it owns the recording", () => { - expect(resolveBrowserRecordingStopTarget("tab-a", "tab-a")).toBe("tab-a"); - expect(resolveBrowserRecordingStopTarget("tab-a", "tab-b")).toBeNull(); + it("prefers an implicit target that is actively recording", () => { + expect( + resolveBrowserRecordingStopTarget( + new Set(["tab-recording-a", "tab-recording-b"]), + "tab-recording-b", + ), + ).toBe("tab-recording-b"); + }); + + it("does not guess when multiple recordings are active and the implicit target is not one", () => { + expect( + resolveBrowserRecordingStopTarget( + new Set(["tab-recording-a", "tab-recording-b"]), + "tab-browsing", + ), + ).toBeNull(); + }); + + it("only stops an explicitly requested tab when that tab is recording", () => { + const activeTabIds = new Set(["tab-recording"]); + expect(resolveBrowserRecordingStopTarget(activeTabIds, "tab-browsing", "tab-recording")).toBe( + "tab-recording", + ); + expect(resolveBrowserRecordingStopTarget(activeTabIds, "tab-recording", "tab-browsing")).toBe( + null, + ); + }); + + it("returns null when no matching recording is active", () => { + expect(resolveBrowserRecordingStopTarget(new Set(), "tab-browsing")).toBeNull(); }); }); diff --git a/apps/web/src/browser/browserRecordingScope.ts b/apps/web/src/browser/browserRecordingScope.ts index 92f58016aa16..ffbcc19a5453 100644 --- a/apps/web/src/browser/browserRecordingScope.ts +++ b/apps/web/src/browser/browserRecordingScope.ts @@ -1,7 +1,14 @@ export function resolveBrowserRecordingStopTarget( - activeTabId: string | null, - requestedTabId?: string, + activeTabIds: ReadonlySet, + implicitTabId: string | null, + explicitTabId?: string, ): string | null { - if (activeTabId === null) return null; - return requestedTabId === undefined || requestedTabId === activeTabId ? activeTabId : null; + if (explicitTabId !== undefined) { + return activeTabIds.has(explicitTabId) ? explicitTabId : null; + } + if (implicitTabId !== null && activeTabIds.has(implicitTabId)) { + return implicitTabId; + } + if (activeTabIds.size !== 1) return null; + return activeTabIds.values().next().value ?? null; } diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index ecfce8cb4321..12b34dd4b52c 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -11,6 +11,62 @@ describe("browserSurfaceStore", () => { useBrowserSurfaceStore.setState({ byTabId: {} }); }); + it("freezes the source content dimensions for a fitted presentation", () => { + const tabId = "fitted-browser-surface"; + const sourceOwner = Symbol("source"); + const sourceContent = { + x: 10, + y: 20, + width: 1_280, + height: 720, + scale: 1, + scrollLeft: 0, + scrollTop: 0, + }; + useBrowserSurfaceStore.getState().claim(tabId, sourceOwner, false); + useBrowserSurfaceStore.getState().presentContent(tabId, sourceContent); + + const fittedLease = acquireBrowserSurface(tabId, true); + useBrowserSurfaceStore.getState().presentContent(tabId, { + ...sourceContent, + width: 360, + height: 203, + scale: 0.28125, + }); + + expect(useBrowserSurfaceStore.getState().byTabId[tabId]?.fittedSourceContent).toEqual( + sourceContent, + ); + fittedLease.release(); + }); + + it("freezes the first content dimensions when fitting starts before the browser is measured", () => { + const tabId = "pending-fitted-browser-surface"; + const fittedLease = acquireBrowserSurface(tabId, true); + const sourceContent = { + x: 0, + y: 0, + width: 1_280, + height: 720, + scale: 1, + scrollLeft: 0, + scrollTop: 0, + }; + + useBrowserSurfaceStore.getState().presentContent(tabId, sourceContent); + useBrowserSurfaceStore.getState().presentContent(tabId, { + ...sourceContent, + width: 320, + height: 180, + scale: 0.25, + }); + + expect(useBrowserSurfaceStore.getState().byTabId[tabId]?.fittedSourceContent).toEqual( + sourceContent, + ); + fittedLease.release(); + }); + it("tracks content dimensions for a browser that has never been visible", () => { const tabId = "hidden-browser-surface-content-test"; useBrowserSurfaceStore.getState().presentContent(tabId, { @@ -30,18 +86,36 @@ describe("browserSurfaceStore", () => { }); }); - it("uses the live panel rect for a hidden background tab", () => { + it("keeps a hidden background tab on its own last rect", () => { const staleRect = { x: 0, y: 0, width: 500, height: 700 }; const liveRect = { x: 10, y: 20, width: 900, height: 640 }; expect( resolveBrowserSurfacePanelRect( { - hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1, owner: null }, - active: { rect: liveRect, visible: true, content: null, updatedAt: 2, owner: null }, + hidden: { + rect: staleRect, + visible: false, + content: null, + fittedSourceContent: null, + fitSourceContent: false, + cornerRadius: 0, + updatedAt: 1, + owner: null, + }, + active: { + rect: liveRect, + visible: true, + content: null, + fittedSourceContent: null, + fitSourceContent: false, + cornerRadius: 0, + updatedAt: 2, + owner: null, + }, }, "hidden", ), - ).toEqual(liveRect); + ).toEqual(staleRect); }); it("ignores updates and releases from a stale surface lease", () => { @@ -53,7 +127,7 @@ describe("browserSurfaceStore", () => { const liveLease = acquireBrowserSurface(tabId); liveLease.present(liveRect, true); - staleLease.present(staleRect, true); + expect(staleLease.present(staleRect, true)).toBe(false); staleLease.release(); expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ @@ -75,4 +149,27 @@ describe("browserSurfaceStore", () => { owner: null, }); }); + + it("clears fitted presentation state when its lease is released", () => { + const tabId = "released-fitted-browser-surface"; + const fittedLease = acquireBrowserSurface(tabId, true); + useBrowserSurfaceStore.getState().presentContent(tabId, { + x: 0, + y: 0, + width: 1_280, + height: 800, + scale: 1, + scrollLeft: 0, + scrollTop: 0, + }); + + fittedLease.release(); + + expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ + fittedSourceContent: null, + fitSourceContent: false, + owner: null, + visible: false, + }); + }); }); diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index 58012a11a305..43ae0037c070 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -11,6 +11,9 @@ export interface BrowserSurfacePresentation { readonly rect: BrowserSurfaceRect | null; readonly visible: boolean; readonly content: BrowserSurfaceContentPresentation | null; + readonly fittedSourceContent: BrowserSurfaceContentPresentation | null; + readonly fitSourceContent: boolean; + readonly cornerRadius: number; readonly updatedAt: number; readonly owner: symbol | null; } @@ -27,19 +30,20 @@ export interface BrowserSurfaceContentPresentation { interface BrowserSurfaceStoreState { readonly byTabId: Record; - readonly claim: (tabId: string, owner: symbol) => void; + readonly claim: (tabId: string, owner: symbol, fitSourceContent: boolean) => void; readonly present: ( tabId: string, owner: symbol, rect: BrowserSurfaceRect, visible: boolean, + cornerRadius: number, ) => void; readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; readonly release: (tabId: string, owner: symbol) => void; } export interface BrowserSurfaceLease { - readonly present: (rect: BrowserSurfaceRect, visible: boolean) => void; + readonly present: (rect: BrowserSurfaceRect, visible: boolean, cornerRadius?: number) => boolean; readonly release: () => void; } @@ -48,19 +52,7 @@ export function resolveBrowserSurfacePanelRect( tabId: string, ): BrowserSurfaceRect | null { const current = byTabId[tabId]; - if (current?.visible && current.rect) return current.rect; - - let latestVisible: BrowserSurfacePresentation | undefined; - for (const presentation of Object.values(byTabId)) { - if ( - presentation.visible && - presentation.rect && - (!latestVisible || presentation.updatedAt > latestVisible.updatedAt) - ) { - latestVisible = presentation; - } - } - return latestVisible?.rect ?? current?.rect ?? null; + return current?.rect ?? null; } const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean => @@ -72,7 +64,7 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): export const useBrowserSurfaceStore = create()((set) => ({ byTabId: {}, - claim: (tabId, owner) => + claim: (tabId, owner, fitSourceContent) => set((state) => { const current = state.byTabId[tabId]; if (current?.owner === owner) return state; @@ -83,21 +75,31 @@ export const useBrowserSurfaceStore = create()((set) = rect: current?.rect ?? null, visible: false, content: current?.content ?? null, + fittedSourceContent: fitSourceContent ? (current?.content ?? null) : null, + fitSourceContent, + cornerRadius: current?.cornerRadius ?? 0, updatedAt: Date.now(), owner, }, }, }; }), - present: (tabId, owner, rect, visible) => + present: (tabId, owner, rect, visible, cornerRadius) => set((state) => { const current = state.byTabId[tabId]; if (current?.owner !== owner) return state; - if (current && current.visible === visible && rectEquals(current.rect, rect)) return state; + if ( + current && + current.visible === visible && + current.cornerRadius === cornerRadius && + rectEquals(current.rect, rect) + ) { + return state; + } return { byTabId: { ...state.byTabId, - [tabId]: { ...current, rect, visible, updatedAt: Date.now() }, + [tabId]: { ...current, rect, visible, cornerRadius, updatedAt: Date.now() }, }, }; }), @@ -112,6 +114,9 @@ export const useBrowserSurfaceStore = create()((set) = rect: null, visible: false, content, + fittedSourceContent: null, + fitSourceContent: false, + cornerRadius: 0, updatedAt: Date.now(), owner: null, }, @@ -134,7 +139,15 @@ export const useBrowserSurfaceStore = create()((set) = return { byTabId: { ...state.byTabId, - [tabId]: { ...current, content, updatedAt: Date.now() }, + [tabId]: { + ...current, + content, + fittedSourceContent: + current.fitSourceContent && current.fittedSourceContent === null + ? content + : current.fittedSourceContent, + updatedAt: Date.now(), + }, }, }; }), @@ -145,21 +158,33 @@ export const useBrowserSurfaceStore = create()((set) = return { byTabId: { ...state.byTabId, - [tabId]: { ...current, visible: false, updatedAt: Date.now(), owner: null }, + [tabId]: { + ...current, + visible: false, + fittedSourceContent: null, + fitSourceContent: false, + updatedAt: Date.now(), + owner: null, + }, }, }; }), })); -export function acquireBrowserSurface(tabId: string): BrowserSurfaceLease { +export function acquireBrowserSurface( + tabId: string, + fitSourceContent = false, +): BrowserSurfaceLease { const owner = Symbol(`browser-surface:${tabId}`); let released = false; - useBrowserSurfaceStore.getState().claim(tabId, owner); + useBrowserSurfaceStore.getState().claim(tabId, owner, fitSourceContent); return { - present: (rect, visible) => { - if (released) return; - useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible); + present: (rect, visible, cornerRadius = 0) => { + if (released) return false; + if (useBrowserSurfaceStore.getState().byTabId[tabId]?.owner !== owner) return false; + useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius); + return true; }, release: () => { if (released) return; diff --git a/apps/web/src/browser/browserViewportActions.test.ts b/apps/web/src/browser/browserViewportActions.test.ts index fd8f7809f307..a7633fd0963d 100644 --- a/apps/web/src/browser/browserViewportActions.test.ts +++ b/apps/web/src/browser/browserViewportActions.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS, commitBrowserViewportChange, + runBrowserViewportMutation, subscribeBrowserViewportChange, } from "./browserViewportActions"; @@ -71,6 +72,33 @@ describe("browserViewportActions", () => { unsubscribe(); }); + it("serializes background mutations with visible viewport commits", async () => { + let releaseBackground: (() => void) | undefined; + const backgroundPending = new Promise((resolve) => { + releaseBackground = resolve; + }); + const calls: string[] = []; + const background = runBrowserViewportMutation("tab-shared", async () => { + calls.push("background"); + await backgroundPending; + }); + const unsubscribe = subscribeBrowserViewportChange("tab-shared", async () => { + calls.push("visible"); + }); + const visible = commitBrowserViewportChange("tab-shared", { + _tag: "freeform", + width: 900, + height: 700, + }); + + await vi.waitFor(() => expect(calls).toEqual(["background"])); + releaseBackground?.(); + await Promise.all([background, visible]); + + expect(calls).toEqual(["background", "visible"]); + unsubscribe(); + }); + it("does not let a timed-out handler overtake a newer viewport commit", async () => { vi.useFakeTimers(); try { diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts index eea176ebbff2..b80f68af3f00 100644 --- a/apps/web/src/browser/browserViewportActions.ts +++ b/apps/web/src/browser/browserViewportActions.ts @@ -15,6 +15,41 @@ export class BrowserViewportCommitTimeoutError extends Error { const handlers = new Map(); const commitTails = new Map>(); +const queueBrowserViewportMutation = ( + tabId: string, + start: () => Promise, +): { + readonly started: Promise<{ readonly operation: Promise }>; + readonly execution: Promise; +} => { + const previous = commitTails.get(tabId) ?? Promise.resolve(); + const started = previous + .catch(() => undefined) + .then(() => ({ + operation: Promise.resolve().then(start), + })); + const execution = started.then(({ operation }) => operation); + const tail = execution.then(() => undefined); + commitTails.set(tabId, tail); + const clear = () => { + if (commitTails.get(tabId) === tail) commitTails.delete(tabId); + }; + void tail.then(clear, clear); + return { started, execution }; +}; + +/** + * Serializes every server-side viewport mutation for one desktop runtime tab. + * Both visible UI commits and background automation use this queue so a + * compensating rollback cannot overtake a newer resize. + */ +export function runBrowserViewportMutation( + tabId: string, + mutation: () => Promise, +): Promise { + return queueBrowserViewportMutation(tabId, mutation).execution; +} + const runHandlerWithTimeout = (tabId: string, operation: Promise): Promise => { let timeoutId: ReturnType | undefined; const timeout = new Promise((_resolve, reject) => { @@ -42,25 +77,14 @@ export function commitBrowserViewportChange( tabId: string, setting: PreviewViewportSetting, ): Promise { - const previous = commitTails.get(tabId) ?? Promise.resolve(); - const started = previous - .catch(() => undefined) - .then(() => { - const handler = handlers.get(tabId); - const operation = handler - ? Promise.resolve().then(() => handler(setting)) - : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); - return { operation }; - }); - // The queue follows the real handler lifetime, not the caller-facing timeout. - // A slow commit therefore cannot time out, release the queue, and overwrite a - // newer viewport after that newer request has already completed. - const execution = started.then(({ operation }) => operation); + const { started } = queueBrowserViewportMutation(tabId, () => { + const handler = handlers.get(tabId); + return handler + ? handler(setting) + : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); + }); + // The queue follows the real handler lifetime, while the caller-facing + // timeout starts only once this commit reaches the front of that queue. const result = started.then(({ operation }) => runHandlerWithTimeout(tabId, operation)); - commitTails.set(tabId, execution); - const clear = () => { - if (commitTails.get(tabId) === execution) commitTails.delete(tabId); - }; - void execution.then(clear, clear); return result; } diff --git a/apps/web/src/browser/browserViewportLayout.test.ts b/apps/web/src/browser/browserViewportLayout.test.ts index edd432a4e952..23c8a2996cc0 100644 --- a/apps/web/src/browser/browserViewportLayout.test.ts +++ b/apps/web/src/browser/browserViewportLayout.test.ts @@ -4,11 +4,27 @@ import { resizeBrowserViewportFromRail, resizeFreeformViewport, resolveBrowserDeviceViewportLayout, + resolveFittedBrowserViewport, resolveBrowserViewportLayout, resolveResponsiveBrowserViewportSize, } from "./browserViewportLayout"; describe("resolveBrowserViewportLayout", () => { + it("uses the current fixed viewport instead of stale fitted source dimensions", () => { + expect( + resolveFittedBrowserViewport( + { _tag: "freeform", width: 900, height: 600 }, + { width: 1280, height: 800, scale: 1 }, + ), + ).toEqual({ _tag: "freeform", width: 900, height: 600 }); + }); + + it("preserves the last logical viewport when fitting a fill-mode surface", () => { + expect( + resolveFittedBrowserViewport({ _tag: "fill" }, { width: 320, height: 200, scale: 0.25 }), + ).toEqual({ _tag: "freeform", width: 1280, height: 800 }); + }); + it("fills the available surface in fill mode", () => { expect(resolveBrowserViewportLayout({ width: 700, height: 500 }, { _tag: "fill" })).toEqual({ canvasWidth: 700, diff --git a/apps/web/src/browser/browserViewportLayout.ts b/apps/web/src/browser/browserViewportLayout.ts index ed5529fe1389..414701a816c5 100644 --- a/apps/web/src/browser/browserViewportLayout.ts +++ b/apps/web/src/browser/browserViewportLayout.ts @@ -40,6 +40,33 @@ export const browserViewportSettingKey = (setting: PreviewViewportSetting): stri const normalizeZoomFactor = (zoomFactor: number): number => Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; +export function resolveFittedBrowserViewport( + setting: PreviewViewportSetting, + sourceContent: { + readonly width: number; + readonly height: number; + readonly scale: number; + } | null, + zoomFactor = 1, +): Exclude { + if (setting._tag !== "fill") return setting; + const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); + if (sourceContent) { + return { + _tag: "freeform", + width: Math.max( + 1, + Math.round(sourceContent.width / sourceContent.scale / normalizedZoomFactor), + ), + height: Math.max( + 1, + Math.round(sourceContent.height / sourceContent.scale / normalizedZoomFactor), + ), + }; + } + return { _tag: "freeform", width: 1280, height: 800 }; +} + export function resolveBrowserDeviceViewportArea(container: { readonly width: number; readonly height: number; diff --git a/apps/web/src/browser/desktopTabLifetime.test.ts b/apps/web/src/browser/desktopTabLifetime.test.ts index 1e3b1632bcc1..feb015c91a42 100644 --- a/apps/web/src/browser/desktopTabLifetime.test.ts +++ b/apps/web/src/browser/desktopTabLifetime.test.ts @@ -1,20 +1,34 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -const { closeTab, createTab } = vi.hoisted(() => ({ - closeTab: vi.fn(async () => undefined), +const { closeTab, createTab, stopBrowserRecording } = vi.hoisted(() => ({ + closeTab: vi.fn<(tabId: string) => Promise>(async () => undefined), createTab: vi.fn<() => Promise>(), + stopBrowserRecording: vi.fn(async () => null), })); vi.mock("~/components/preview/previewBridge", () => ({ previewBridge: { closeTab, createTab }, })); +vi.mock("./browserRecording", () => ({ + stopBrowserRecording, +})); + import { acquireDesktopTab } from "./desktopTabLifetime"; +import { previewRuntimeTabId } from "./previewRuntimeTabId"; describe("desktopTabLifetime", () => { beforeEach(() => { closeTab.mockClear(); createTab.mockClear(); + stopBrowserRecording.mockClear(); + vi.stubGlobal("window", globalThis); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); }); it("shares tab creation readiness across concurrent leases", async () => { @@ -42,4 +56,86 @@ describe("desktopTabLifetime", () => { await first.ready; expect(ready).toBe(true); }); + + it("keeps identical server tab ids from two environments in separate desktop slots", async () => { + vi.useFakeTimers(); + createTab.mockResolvedValue(undefined); + const tabA = previewRuntimeTabId( + { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), + }, + "epoch-a", + "tab_1", + ); + const tabB = previewRuntimeTabId( + { + environmentId: EnvironmentId.make("environment-b"), + threadId: ThreadId.make("thread-b"), + }, + "epoch-b", + "tab_1", + ); + + const first = acquireDesktopTab(tabA); + const second = acquireDesktopTab(tabB); + await Promise.all([first.ready, second.ready]); + + expect(createTab).toHaveBeenCalledWith(tabA); + expect(createTab).toHaveBeenCalledWith(tabB); + expect(createTab).toHaveBeenCalledTimes(2); + + first.release(); + second.release(); + await vi.advanceTimersByTimeAsync(0); + }); + + it("stops recording before closing the final desktop tab lease", async () => { + vi.useFakeTimers(); + let resolveStop: (() => void) | undefined; + stopBrowserRecording.mockReturnValueOnce( + new Promise((resolve) => { + resolveStop = () => resolve(null); + }), + ); + createTab.mockResolvedValueOnce(undefined); + + const lease = acquireDesktopTab("tab_recording_cleanup"); + await lease.ready; + lease.release(); + await vi.advanceTimersByTimeAsync(0); + + expect(stopBrowserRecording).toHaveBeenCalledWith("tab_recording_cleanup"); + expect(closeTab).not.toHaveBeenCalled(); + + resolveStop?.(); + await Promise.resolve(); + await Promise.resolve(); + expect(closeTab).toHaveBeenCalledWith("tab_recording_cleanup"); + }); + + it("waits for an in-flight close before recreating a reacquired tab", async () => { + vi.useFakeTimers(); + let resolveClose: (() => void) | undefined; + createTab.mockResolvedValue(undefined); + closeTab.mockReturnValueOnce( + new Promise((resolve) => { + resolveClose = resolve; + }), + ); + + const initial = acquireDesktopTab("tab_close_reacquire"); + await initial.ready; + initial.release(); + await vi.advanceTimersByTimeAsync(0); + + expect(closeTab).toHaveBeenCalledWith("tab_close_reacquire"); + + const reacquired = acquireDesktopTab("tab_close_reacquire"); + expect(createTab).toHaveBeenCalledTimes(1); + + resolveClose?.(); + await reacquired.ready; + expect(createTab).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/web/src/browser/desktopTabLifetime.ts b/apps/web/src/browser/desktopTabLifetime.ts index d621f6dc30c2..98dffda0ea0e 100644 --- a/apps/web/src/browser/desktopTabLifetime.ts +++ b/apps/web/src/browser/desktopTabLifetime.ts @@ -1,5 +1,7 @@ import { previewBridge } from "~/components/preview/previewBridge"; +import { stopBrowserRecording } from "./browserRecording"; + interface DesktopTabLease { references: number; closeTimer: number | null; @@ -7,6 +9,26 @@ interface DesktopTabLease { } const leases = new Map(); +const pendingTabOperations = new Map>(); + +const enqueueDesktopTabOperation = ( + tabId: string, + operation: () => Promise | void, +): Promise => { + const previous = pendingTabOperations.get(tabId); + const pending = previous + ? previous.catch(() => undefined).then(operation) + : Promise.resolve(operation()); + pendingTabOperations.set(tabId, pending); + void pending + .finally(() => { + if (pendingTabOperations.get(tabId) === pending) { + pendingTabOperations.delete(tabId); + } + }) + .catch(() => undefined); + return pending; +}; export interface AcquiredDesktopTab { readonly ready: Promise; @@ -19,7 +41,7 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { ({ references: 0, closeTimer: null, - ready: previewBridge?.createTab(tabId) ?? Promise.resolve(), + ready: enqueueDesktopTabOperation(tabId, () => previewBridge?.createTab(tabId)), } satisfies DesktopTabLease); if (current.closeTimer !== null) window.clearTimeout(current.closeTimer); current.references += 1; @@ -37,7 +59,10 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { const latest = leases.get(tabId); if (!latest || latest.references > 0) return; leases.delete(tabId); - void previewBridge?.closeTab(tabId); + void enqueueDesktopTabOperation(tabId, async () => { + await stopBrowserRecording(tabId).catch(() => null); + await previewBridge?.closeTab(tabId); + }).catch(() => undefined); }, 0); }, }; diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts index 826684bb06f2..d0298dcdee7a 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts @@ -23,6 +23,23 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { }); }); + it("clips a floating webview to the mini-player frame", () => { + expect( + resolveHostedBrowserWebviewWrapperStyle({ + active: true, + cornerRadius: 12, + rect: { x: 12, y: 34, width: 360, height: 203 }, + hiddenSize: { width: 1280, height: 800 }, + }), + ).toMatchObject({ + left: 12, + top: 34, + width: 360, + height: 203, + borderRadius: 12, + }); + }); + it("keeps an inactive webview paintable while moving it offscreen", () => { const style = resolveHostedBrowserWebviewWrapperStyle({ active: false, diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index 4dade986e1ff..f96f4af0462a 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -12,6 +12,7 @@ export interface HostedBrowserWebviewWrapperStyle { readonly height: number; readonly zIndex: number; readonly pointerEvents: "auto" | "none"; + readonly borderRadius?: number; readonly visibility?: "visible"; } @@ -19,10 +20,11 @@ export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; export function resolveHostedBrowserWebviewWrapperStyle(input: { readonly active: boolean; + readonly cornerRadius?: number; readonly rect: BrowserSurfaceRect | null; readonly hiddenSize: HostedBrowserWebviewSize; }): HostedBrowserWebviewWrapperStyle { - const { active, hiddenSize, rect } = input; + const { active, cornerRadius = 0, hiddenSize, rect } = input; if (active && rect) { return { left: rect.x, @@ -31,6 +33,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { height: rect.height, zIndex: 30, pointerEvents: "auto", + ...(cornerRadius > 0 ? { borderRadius: cornerRadius } : {}), }; } diff --git a/apps/web/src/browser/previewRuntimeTabId.test.ts b/apps/web/src/browser/previewRuntimeTabId.test.ts new file mode 100644 index 000000000000..7518ede3e5e7 --- /dev/null +++ b/apps/web/src/browser/previewRuntimeTabId.test.ts @@ -0,0 +1,46 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { isCurrentPreviewRuntimeTab, previewRuntimeTabId } from "./previewRuntimeTabId"; + +describe("previewRuntimeTabId", () => { + it("scopes process-local tab ids to their environment, thread, and server epoch", () => { + const base = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), + }; + + expect(previewRuntimeTabId(base, "epoch-a", "tab_1")).not.toBe( + previewRuntimeTabId( + { ...base, environmentId: EnvironmentId.make("environment-b") }, + "epoch-a", + "tab_1", + ), + ); + expect(previewRuntimeTabId(base, "epoch-a", "tab_1")).not.toBe( + previewRuntimeTabId({ ...base, threadId: ThreadId.make("thread-b") }, "epoch-a", "tab_1"), + ); + expect(previewRuntimeTabId(base, "epoch-a", "tab_1")).not.toBe( + previewRuntimeTabId(base, "epoch-b", "tab_1"), + ); + }); + + it("is stable for the same runtime tab", () => { + const ref = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), + }; + expect(previewRuntimeTabId(ref, null, "tab_1")).toBe(previewRuntimeTabId(ref, null, "tab_1")); + }); + + it("rejects a pinned operation target after the server epoch changes", () => { + const ref = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), + }; + const runtimeTabId = previewRuntimeTabId(ref, "epoch-a", "tab_1"); + + expect(isCurrentPreviewRuntimeTab(ref, "epoch-a", "tab_1", runtimeTabId)).toBe(true); + expect(isCurrentPreviewRuntimeTab(ref, "epoch-b", "tab_1", runtimeTabId)).toBe(false); + }); +}); diff --git a/apps/web/src/browser/previewRuntimeTabId.ts b/apps/web/src/browser/previewRuntimeTabId.ts new file mode 100644 index 000000000000..64dc4f9839a2 --- /dev/null +++ b/apps/web/src/browser/previewRuntimeTabId.ts @@ -0,0 +1,23 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +/** + * The server only guarantees preview tab ids are unique within one process. + * Desktop resources live across every connected environment, so they need a + * stronger identity that also changes when a server process restarts. + */ +export function previewRuntimeTabId( + threadRef: ScopedThreadRef, + serverEpoch: string | null, + tabId: string, +): string { + return JSON.stringify([threadRef.environmentId, threadRef.threadId, serverEpoch, tabId]); +} + +export function isCurrentPreviewRuntimeTab( + threadRef: ScopedThreadRef, + serverEpoch: string | null, + tabId: string, + runtimeTabId: string, +): boolean { + return previewRuntimeTabId(threadRef, serverEpoch, tabId) === runtimeTabId; +} diff --git a/apps/web/src/browser/webviewCrashRecovery.test.ts b/apps/web/src/browser/webviewCrashRecovery.test.ts new file mode 100644 index 000000000000..a13e6ff06f6b --- /dev/null +++ b/apps/web/src/browser/webviewCrashRecovery.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, + planWebviewCrashRecovery, + WEBVIEW_CRASH_RECOVERY_WINDOW_MS, +} from "./webviewCrashRecovery"; + +describe("planWebviewCrashRecovery", () => { + it("backs off and stops after a bounded number of rapid crashes", () => { + const first = planWebviewCrashRecovery(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, 1_000); + expect(first).not.toBeNull(); + expect(first?.delayMs).toBe(250); + + const second = planWebviewCrashRecovery(first!.state, 1_100); + expect(second).not.toBeNull(); + expect(second?.delayMs).toBe(500); + + const third = planWebviewCrashRecovery(second!.state, 1_200); + expect(third).not.toBeNull(); + expect(third?.delayMs).toBe(1_000); + + expect(planWebviewCrashRecovery(third!.state, 1_300)).toBeNull(); + }); + + it("allows recovery again after the crash window expires", () => { + const first = planWebviewCrashRecovery(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, 1_000)!; + const second = planWebviewCrashRecovery(first.state, 1_100)!; + const third = planWebviewCrashRecovery(second.state, 1_200)!; + + expect(planWebviewCrashRecovery(third.state, 1_000 + WEBVIEW_CRASH_RECOVERY_WINDOW_MS)).toEqual( + { + delayMs: 250, + state: { + attempts: 1, + windowStartedAt: 1_000 + WEBVIEW_CRASH_RECOVERY_WINDOW_MS, + }, + }, + ); + }); +}); diff --git a/apps/web/src/browser/webviewCrashRecovery.ts b/apps/web/src/browser/webviewCrashRecovery.ts new file mode 100644 index 000000000000..2267f4a812dc --- /dev/null +++ b/apps/web/src/browser/webviewCrashRecovery.ts @@ -0,0 +1,38 @@ +export const WEBVIEW_CRASH_RECOVERY_WINDOW_MS = 30_000; +export const WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS = 3; +export const WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS = 250; + +export interface WebviewCrashRecoveryState { + readonly attempts: number; + readonly windowStartedAt: number | null; +} + +export interface WebviewCrashRecoveryPlan { + readonly delayMs: number; + readonly state: WebviewCrashRecoveryState; +} + +export const INITIAL_WEBVIEW_CRASH_RECOVERY_STATE: WebviewCrashRecoveryState = { + attempts: 0, + windowStartedAt: null, +}; + +export function planWebviewCrashRecovery( + state: WebviewCrashRecoveryState, + now: number, +): WebviewCrashRecoveryPlan | null { + const startsNewWindow = + state.windowStartedAt === null || + now - state.windowStartedAt >= WEBVIEW_CRASH_RECOVERY_WINDOW_MS; + const attempts = startsNewWindow ? 0 : state.attempts; + if (attempts >= WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS) return null; + + const nextAttempts = attempts + 1; + return { + delayMs: WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS * 2 ** attempts, + state: { + attempts: nextAttempts, + windowStartedAt: startsNewWindow ? now : state.windowStartedAt, + }, + }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ef281d91e780..328a3fa9423b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -131,8 +131,13 @@ import { } from "../previewStateStore"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; +import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; +import { + selectThreadPreviewMiniPlayer, + usePreviewMiniPlayerStore, +} from "../previewMiniPlayerStore"; import { RightPanelTabs } from "./RightPanelTabs"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; @@ -1484,6 +1489,9 @@ function ChatViewContent(props: ChatViewProps) { const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; const activePreviewState = useThreadPreviewState(activeThreadRef); + const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => + selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), + ); const panelTerminalIds = useMemo( () => new Set( @@ -1507,6 +1515,24 @@ function ChatViewContent(props: ChatViewProps) { .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); }, [activePreviewState.sessions, activeThreadRef]); + useEffect(() => { + if (!activeThreadRef || !activePreviewMiniPlayer) return; + const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]); + const sameTabOpenInPanel = + previewPanelOpen && + activeRightPanelSurface?.kind === "preview" && + activeRightPanelSurface.resourceId === activePreviewMiniPlayer.tabId; + if (!miniTabStillExists || sameTabOpenInPanel) { + usePreviewMiniPlayerStore.getState().close(activeThreadRef); + } + }, [ + activePreviewMiniPlayer, + activePreviewState.sessions, + activeRightPanelSurface, + activeThreadRef, + previewPanelOpen, + ]); + const planSidebarOpen = activeRightPanelKind === "plan"; const existingOpenTerminalThreadKeys = useMemo(() => { @@ -6016,6 +6042,15 @@ function ChatViewContent(props: ChatViewProps) {
+ {activeThreadRef && activePreviewMiniPlayer ? ( + + ) : null} + diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 8c5891ebe7e3..f87e7fbe5395 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -239,11 +239,8 @@ function SidebarV2ThreadTooltip({ side="right" align="start" sideOffset={8} - className="dropdown-glass max-w-80 border-0! text-left whitespace-normal shadow-lg/10 before:hidden dark:shadow-none" - style={{ - background: - "color-mix(in srgb, var(--popover) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))", - }} + variant="glass" + className="max-w-80 text-left whitespace-normal" >
{thread.title}
diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 74c92a044b3d..4e3fa7abcd52 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -39,6 +39,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { open?: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; + triggerAriaLabel?: string; onOpenChange?: (open: boolean) => void; getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; @@ -146,6 +147,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { => { - const deadline = Date.now() + timeoutMs; +const PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS = 500; + +const waitForPreviewPresentation = async (runtimeTabId: string): Promise => { + const deadline = Date.now() + PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS; while (Date.now() <= deadline) { - const state = readThreadPreviewState(threadRef); - if (state.desktopByTabId[tabId] && previewBridge) { - const status = await previewBridge.automation.status(tabId); - if (status.available) return; - } - await new Promise((resolve) => window.setTimeout(resolve, 50)); + if (useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.visible) return; + await new Promise((resolve) => window.setTimeout(resolve, 16)); } - throw new PreviewAutomationOverlayTimeoutError({ - requestId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - timeoutMs, - }); }; -const waitForNavigationReadiness = async ( +const waitForDesktopOverlay = async ( threadRef: ScopedThreadRef, requestId: string, tabId: string, - readiness: PreviewAutomationNavigateInput["readiness"], + runtimeTabId: string, + operation: PreviewAutomationRequest["operation"], timeoutMs: number, ): Promise => { - const targetReadiness = readiness ?? "load"; - if (!previewBridge || targetReadiness === "none") return; const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { - if (targetReadiness === "domContentLoaded") { - const readyState = await previewBridge.automation.evaluate(tabId, { - expression: "document.readyState", - }); - if (readyState === "interactive" || readyState === "complete") return; - } else { - const status = await previewBridge.automation.status(tabId); - if (!status.loading) return; + const state = assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, { + operation, + requestId, + }); + if (state.desktopByTabId[tabId] && previewBridge) { + const status = await previewBridge.automation.status(runtimeTabId); + if (status.available) return; } await new Promise((resolve) => window.setTimeout(resolve, 50)); } - throw new PreviewAutomationNavigationTimeoutError({ + throw new PreviewAutomationOverlayTimeoutError({ requestId, environmentId: threadRef.environmentId, threadId: threadRef.threadId, - tabId, - readiness: targetReadiness, timeoutMs, }); }; @@ -145,8 +138,10 @@ const readWebviewViewport = async ( : null; }; -const readRenderedViewport = async (tabId: string): Promise => { - const webview = findPreviewWebview(tabId); +const readRenderedViewport = async ( + runtimeTabId: string, +): Promise => { + const webview = findPreviewWebview(runtimeTabId); if (!webview) return null; return await readWebviewViewport(webview); }; @@ -162,19 +157,23 @@ const readDeclaredViewport = ( }; const waitForRenderedViewport = async ( + threadRef: ScopedThreadRef, tabId: string, + runtimeTabId: string, setting: PreviewViewportSetting, timeoutMs: number, context: { readonly requestId: PreviewAutomationRequest["requestId"]; + readonly operation: PreviewAutomationRequest["operation"]; readonly environmentId: EnvironmentId; readonly threadId: PreviewAutomationRequest["threadId"]; }, ): Promise => { const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { + assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, context); try { - const webview = findPreviewWebview(tabId); + const webview = findPreviewWebview(runtimeTabId); const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null; const declaredViewport = readDeclaredViewport(webview); const renderedViewport = webview ? await readWebviewViewport(webview) : null; @@ -208,18 +207,19 @@ const currentStatus = async ( ): Promise => { const state = readThreadPreviewState(threadRef); const { snapshot, tabId } = resolvePreviewAutomationTarget(state, requestedTabId); - const visible = tabId - ? (useBrowserSurfaceStore.getState().byTabId[tabId]?.visible ?? false) + const runtimeTabId = tabId ? previewRuntimeTabId(threadRef, state.serverEpoch, tabId) : null; + const visible = runtimeTabId + ? (useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.visible ?? false) : false; const viewportSetting = snapshot ? (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT) : undefined; - const viewport = tabId ? await readRenderedViewport(tabId).catch(() => null) : null; + const viewport = runtimeTabId ? await readRenderedViewport(runtimeTabId).catch(() => null) : null; const viewportStatus = { ...(viewportSetting === undefined ? {} : { viewportSetting }), ...(viewport === null ? {} : { viewport }), }; - if (tabId && previewBridge && state.desktopByTabId[tabId]) { - const status = await previewBridge.automation.status(tabId); - return { ...status, visible, ...viewportStatus }; + if (runtimeTabId && tabId && previewBridge && state.desktopByTabId[tabId]) { + const status = await previewBridge.automation.status(runtimeTabId); + return { ...status, tabId, visible, ...viewportStatus }; } const navStatus = snapshot?.navStatus; return { @@ -319,7 +319,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) if (result._tag === "Failure") { return raiseAtomCommandFailure(result); } - reconcilePreviewServerSessions(threadRef, result.value.sessions); + reconcilePreviewServerSessions(threadRef, result.value); state = readThreadPreviewState(threadRef); } tabId = request.tabId ?? state.snapshot?.tabId ?? null; @@ -340,8 +340,21 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) if (!state.sessions[readyTabId]) { throw new PreviewAutomationTargetUnavailableError(unavailableTarget); } - await waitForDesktopOverlay(threadRef, request.requestId, readyTabId, request.timeoutMs); - return { bridge, tabId: readyTabId }; + const readyState = readThreadPreviewState(threadRef); + const runtimeTabId = previewRuntimeTabId(threadRef, readyState.serverEpoch, readyTabId); + await waitForDesktopOverlay( + threadRef, + request.requestId, + readyTabId, + runtimeTabId, + request.operation, + request.timeoutMs, + ); + return { + bridge, + tabId: readyTabId, + runtimeTabId, + }; }; switch (request.operation) { case "status": @@ -384,23 +397,73 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) activeSnapshot = snapshot; tabId = activeTabId; } - if (input.show ?? true) { - useRightPanelStore.getState().openBrowser(threadRef, activeTabId); + const activeRuntimeTabId = previewRuntimeTabId( + threadRef, + readThreadPreviewState(threadRef).serverEpoch, + activeTabId, + ); + if (activeSnapshot) { + const defaultViewport = previewAutomationDefaultViewport( + reusedExistingTab, + activeSnapshot, + ); + if (defaultViewport) { + const resizeResult = await runBrowserViewportMutation( + activeRuntimeTabId, + async () => { + assertPreviewRuntimeCurrent( + threadRef, + activeTabId, + activeRuntimeTabId, + request, + ); + return await resize({ + environmentId, + input: { + threadId: request.threadId, + tabId: activeTabId, + viewport: defaultViewport, + }, + }); + }, + ); + if (resizeResult._tag === "Failure") { + return raiseAtomCommandFailure(resizeResult); + } + activeSnapshot = resizeResult.value; + updatePreviewServerSnapshot(threadRef, resizeResult.value); + } + } + const shouldPresentPreview = shouldOpenPreviewMiniPlayer(input); + if (shouldPresentPreview) { + usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId); } if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) { await waitForDesktopOverlay( threadRef, request.requestId, activeTabId, + activeRuntimeTabId, + request.operation, request.timeoutMs, ); } + if (shouldPresentPreview) { + // React commits the thread-bound surface asynchronously. Settle + // briefly so active-thread opens report visible=true, without + // turning a background thread's offscreen mini player into an + // operation failure. + await waitForPreviewPresentation(activeRuntimeTabId); + } if (reusedExistingTab && resolvedInputUrl && previewBridge) { - await previewBridge.navigate(activeTabId, resolvedInputUrl); + assertPreviewRuntimeCurrent(threadRef, activeTabId, activeRuntimeTabId, request); + await previewBridge.navigate(activeRuntimeTabId, resolvedInputUrl); await waitForNavigationReadiness( threadRef, request.requestId, activeTabId, + activeRuntimeTabId, + request.operation, "load", request.timeoutMs, ); @@ -417,11 +480,13 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) url: input.url!, }, ); - await ready.bridge.navigate(ready.tabId, resolution.resolvedUrl); + await ready.bridge.navigate(ready.runtimeTabId, resolution.resolvedUrl); await waitForNavigationReadiness( threadRef, request.requestId, ready.tabId, + ready.runtimeTabId, + request.operation, input.readiness ?? "load", input.timeoutMs ?? request.timeoutMs, ); @@ -431,28 +496,76 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const ready = await requireReadyTab(); const input = request.input as PreviewAutomationResizeInput; const setting = resolvePreviewViewport(input); - const result = await resize({ - environmentId, - input: { - threadId: request.threadId, - tabId: ready.tabId, - viewport: setting, - }, + const applied = await runBrowserViewportMutation(ready.runtimeTabId, async () => { + const operationState = assertPreviewRuntimeCurrent( + threadRef, + ready.tabId, + ready.runtimeTabId, + request, + ); + const previousSetting = + operationState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; + const result = await resize({ + environmentId, + input: { + threadId: request.threadId, + tabId: ready.tabId, + viewport: setting, + }, + }); + if (result._tag === "Failure") { + return raiseAtomCommandFailure(result); + } + updatePreviewServerSnapshot(threadRef, result.value); + return { + previousSetting, + serverEpoch: operationState.serverEpoch, + }; }); - if (result._tag === "Failure") { - return raiseAtomCommandFailure(result); + let viewport: PreviewRenderedViewportSize; + try { + viewport = await waitForRenderedViewport( + threadRef, + ready.tabId, + ready.runtimeTabId, + setting, + input.timeoutMs ?? request.timeoutMs, + { + requestId: request.requestId, + operation: request.operation, + environmentId, + threadId: request.threadId, + }, + ); + } catch (cause) { + await runBrowserViewportMutation(ready.runtimeTabId, async () => { + const latestState = readThreadPreviewState(threadRef); + const latestSetting = + latestState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; + if ( + shouldRollbackPreviewViewport( + applied.previousSetting, + setting, + latestSetting, + applied.serverEpoch, + latestState.serverEpoch, + ) + ) { + const rollback = await resize({ + environmentId, + input: { + threadId: request.threadId, + tabId: ready.tabId, + viewport: applied.previousSetting, + }, + }); + if (rollback._tag !== "Failure") { + updatePreviewServerSnapshot(threadRef, rollback.value); + } + } + }); + throw cause; } - updatePreviewServerSnapshot(threadRef, result.value); - const viewport = await waitForRenderedViewport( - ready.tabId, - setting, - input.timeoutMs ?? request.timeoutMs, - { - requestId: request.requestId, - environmentId, - threadId: request.threadId, - }, - ); return { tabId: ready.tabId, setting, @@ -462,7 +575,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) case "setColorScheme": { const ready = await requireReadyTab(); const input = request.input as PreviewAutomationSetColorSchemeInput; - await ready.bridge.setColorScheme(ready.tabId, input.colorScheme); + await ready.bridge.setColorScheme(ready.runtimeTabId, input.colorScheme); return { tabId: ready.tabId, colorScheme: input.colorScheme, @@ -470,53 +583,57 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } case "snapshot": { const ready = await requireReadyTab(); - return await ready.bridge.automation.snapshot(ready.tabId); + return await ready.bridge.automation.snapshot(ready.runtimeTabId); } case "click": { const ready = await requireReadyTab(); return await ready.bridge.automation.click( - ready.tabId, + ready.runtimeTabId, request.input as Parameters[1], ); } case "type": { const ready = await requireReadyTab(); return await ready.bridge.automation.type( - ready.tabId, + ready.runtimeTabId, request.input as Parameters[1], ); } case "press": { const ready = await requireReadyTab(); return await ready.bridge.automation.press( - ready.tabId, + ready.runtimeTabId, request.input as Parameters[1], ); } case "scroll": { const ready = await requireReadyTab(); return await ready.bridge.automation.scroll( - ready.tabId, + ready.runtimeTabId, request.input as Parameters[1], ); } case "evaluate": { const ready = await requireReadyTab(); return await ready.bridge.automation.evaluate( - ready.tabId, + ready.runtimeTabId, request.input as Parameters[1], ); } case "waitFor": { const ready = await requireReadyTab(); return await ready.bridge.automation.waitFor( - ready.tabId, + ready.runtimeTabId, request.input as Parameters[1], ); } case "recordingStart": { const ready = await requireReadyTab(); - const startedAt = await startBrowserRecording(ready.tabId); + const startedAt = await startBrowserRecording( + ready.runtimeTabId, + threadRef, + ready.tabId, + ); return { tabId: ready.tabId, recording: true, @@ -524,13 +641,21 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }; } case "recordingStop": { - const recordingTabId = readActiveBrowserRecordingTabId(); + const activeRecordings = readActiveBrowserRecordingTargets(threadRef); + const activeTabIds = new Set( + activeRecordings.map((recording) => recording.serverTabId), + ); const stopTabId = resolveBrowserRecordingStopTarget( - recordingTabId, + activeTabIds, + tabId, request.tabIdExplicit ? request.tabId : undefined, ); - const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null; - if (!artifact) { + tabId = stopTabId ?? tabId; + const stopRuntimeTabId = + activeRecordings.find((recording) => recording.serverTabId === stopTabId) + ?.runtimeTabId ?? null; + const artifact = stopRuntimeTabId ? await stopBrowserRecording(stopRuntimeTabId) : null; + if (!artifact || !stopTabId) { return raisePreviewAutomationHostError( new PreviewAutomationRecordingNotActiveError({ requestId: request.requestId, @@ -540,7 +665,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }), ); } - return artifact; + return { ...artifact, tabId: stopTabId }; } } } catch (cause) { diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index f2c3fdfea764..8044b4722643 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -4,6 +4,7 @@ import { Camera, ExternalLink, MousePointerClick, + PictureInPicture2, RotateCw, } from "lucide-react"; import { @@ -40,6 +41,9 @@ interface Props { onCapture?: ((record: boolean) => void) | undefined; captureDisabled?: boolean | undefined; recording?: boolean | undefined; + onPictureInPicture?: (() => void) | undefined; + pictureInPicture?: boolean | undefined; + pictureInPictureDisabled?: boolean | undefined; /** * When provided, renders an annotation-mode toggle button to the right of * the URL input. Pressed while annotation mode is active (button shows in `pressed` @@ -77,6 +81,9 @@ export function PreviewChromeRow({ onCapture, captureDisabled, recording, + onPictureInPicture, + pictureInPicture, + pictureInPictureDisabled, onPickElement, pickActive, pickDisabled, @@ -274,6 +281,30 @@ export function PreviewChromeRow({ ) : null} + {onPictureInPicture ? ( + + + } + > + + + + {pictureInPicture ? "Close floating preview" : "Float preview over chat"} + + + ) : null} {trailingActions} {loadProgress > 0 ? ( diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index 28fc2e22232e..a98d33304e88 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -46,6 +46,10 @@ interface Props { deviceToolbarVisible: boolean; /** Switches between fill-panel mode and a fixed responsive viewport. */ onToggleDeviceToolbar: () => void; + /** Whether the separate native always-on-top preview window is open. */ + nativePictureInPicture: boolean; + /** Toggles the optional native always-on-top preview window. */ + onNativePictureInPicture: () => void; } /** @@ -60,6 +64,8 @@ export function PreviewMoreMenu({ colorScheme, deviceToolbarVisible, onToggleDeviceToolbar, + nativePictureInPicture, + onNativePictureInPicture, }: Props) { if (!previewBridge) return null; const bridge = previewBridge; @@ -93,6 +99,11 @@ export function PreviewMoreMenu({ Open DevTools + + {nativePictureInPicture + ? "Close separate preview window" + : "Open separate preview window"} + {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 61111025fa4d..576c37d77b72 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -8,6 +8,16 @@ const mocks = vi.hoisted(() => ({ readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })), submittedUrl: null as ((url: string) => void) | null, emptyStateUrl: null as ((url: string) => void) | null, + togglePictureInPicture: null as (() => void) | null, + toggleNativePictureInPicture: null as (() => void) | null, + pictureInPicturePressed: false, + miniPlayerTabId: null as string | null, + openMiniPlayer: vi.fn(), + closeMiniPlayer: vi.fn(), + closeRightPanel: vi.fn(), + openPictureInPicture: vi.fn(async (_tabId: string): Promise => undefined), + closePictureInPicture: vi.fn(async (_tabId: string): Promise => undefined), + pictureInPicture: false, showEmptyState: false, })); @@ -36,10 +46,12 @@ vi.mock("~/previewStateStore", () => ({ activeTabId: "tab-1", desktopByTabId: { "tab-1": { + hasWebContents: true, canGoBack: false, canGoForward: false, loading: false, zoomFactor: 1, + pictureInPicture: mocks.pictureInPicture, colorScheme: "system", controller: "none", }, @@ -78,9 +90,10 @@ vi.mock("~/state/use-atom-command", () => ({ })); vi.mock("~/browser/browserRecording", () => ({ + findActiveBrowserRecordingRuntimeTabId: vi.fn(() => null), startBrowserRecording: vi.fn(), stopBrowserRecording: vi.fn(), - useActiveBrowserRecordingTabId: () => null, + useActiveBrowserRecordingTabIds: () => new Set(), })); vi.mock("~/browser/browserSurfaceStore", () => ({ @@ -89,18 +102,69 @@ vi.mock("~/browser/browserSurfaceStore", () => ({ ) => select({ byTabId: {} }), })); +vi.mock("~/previewMiniPlayerStore", () => { + const usePreviewMiniPlayerStore = Object.assign( + (select: (state: unknown) => unknown) => + select({ + byThreadKey: mocks.miniPlayerTabId + ? { + "environment-1:thread-1": { + tabId: mocks.miniPlayerTabId, + position: null, + }, + } + : {}, + }), + { + getState: () => ({ + open: mocks.openMiniPlayer, + close: mocks.closeMiniPlayer, + }), + }, + ); + return { + selectThreadPreviewMiniPlayer: ( + byThreadKey: Record, + ) => byThreadKey["environment-1:thread-1"] ?? null, + usePreviewMiniPlayerStore, + }; +}); + +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { + getState: () => ({ close: mocks.closeRightPanel }), + }, +})); + vi.mock("~/components/ui/toast", () => ({ stackedThreadToast: vi.fn(), toastManager: { add: vi.fn() }, })); vi.mock("./previewBridge", () => ({ - previewBridge: { navigate: mocks.navigate }, + previewBridge: { + navigate: mocks.navigate, + pictureInPicture: { + open: mocks.openPictureInPicture, + close: mocks.closePictureInPicture, + }, + }, })); vi.mock("./PreviewChromeRow", () => ({ - PreviewChromeRow: (props: { onSubmit: (url: string) => void }) => { + PreviewChromeRow: (props: { + onSubmit: (url: string) => void; + onPictureInPicture?: () => void; + pictureInPicture?: boolean; + trailingActions?: { + props: { onNativePictureInPicture?: () => void }; + }; + }) => { mocks.submittedUrl = props.onSubmit; + mocks.togglePictureInPicture = props.onPictureInPicture ?? null; + mocks.toggleNativePictureInPicture = + props.trailingActions?.props.onNativePictureInPicture ?? null; + mocks.pictureInPicturePressed = props.pictureInPicture ?? false; return null; }, })); @@ -111,7 +175,12 @@ vi.mock("./PreviewEmptyState", () => ({ return null; }, })); -vi.mock("./PreviewMoreMenu", () => ({ PreviewMoreMenu: () => null })); +vi.mock("./PreviewMoreMenu", () => ({ + PreviewMoreMenu: (props: { onNativePictureInPicture: () => void }) => { + mocks.toggleNativePictureInPicture = props.onNativePictureInPicture; + return null; + }, +})); vi.mock("./PreviewUnreachable", () => ({ PreviewUnreachable: () => null })); vi.mock("./ZoomIndicator", () => ({ ZoomIndicator: () => null })); vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); @@ -120,6 +189,13 @@ vi.mock("./useLoadingProgress", () => ({ useLoadingProgress: () => 0 })); vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() })); import { PreviewView } from "./PreviewView"; +import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; + +const TEST_THREAD_REF = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), +} as const; +const TEST_RUNTIME_TAB_ID = previewRuntimeTabId(TEST_THREAD_REF, null, "tab-1"); describe("PreviewView navigation", () => { beforeEach(() => { @@ -128,6 +204,16 @@ describe("PreviewView navigation", () => { mocks.readPreparedConnection.mockClear(); mocks.submittedUrl = null; mocks.emptyStateUrl = null; + mocks.togglePictureInPicture = null; + mocks.toggleNativePictureInPicture = null; + mocks.pictureInPicturePressed = false; + mocks.miniPlayerTabId = null; + mocks.openMiniPlayer.mockClear(); + mocks.closeMiniPlayer.mockClear(); + mocks.closeRightPanel.mockClear(); + mocks.openPictureInPicture.mockClear(); + mocks.closePictureInPicture.mockClear(); + mocks.pictureInPicture = false; mocks.showEmptyState = false; }); @@ -152,7 +238,9 @@ describe("PreviewView navigation", () => { expect(mocks.submittedUrl).not.toBeNull(); mocks.submittedUrl?.(submitted); - await vi.waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith("tab-1", expected)); + await vi.waitFor(() => + expect(mocks.navigate).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID, expected), + ); expect(mocks.rememberPreviewUrl).toHaveBeenCalledWith( { environmentId: "environment-1", @@ -180,7 +268,7 @@ describe("PreviewView navigation", () => { await vi.waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith( - "tab-1", + TEST_RUNTIME_TAB_ID, "http://172.25.85.75:5173/app?mode=test#top", ), ); @@ -192,4 +280,51 @@ describe("PreviewView navigation", () => { "http://172.25.85.75:5173/app?mode=test#top", ); }); + + it("opens and closes a thread-scoped floating preview for the active tab", async () => { + const props = { + threadRef: { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + }, + tabId: "tab-1", + visible: true, + } as const; + + renderToStaticMarkup(); + expect(mocks.pictureInPicturePressed).toBe(false); + mocks.togglePictureInPicture?.(); + expect(mocks.openMiniPlayer).toHaveBeenCalledWith(props.threadRef, "tab-1"); + expect(mocks.closeRightPanel).toHaveBeenCalledWith(props.threadRef); + + mocks.miniPlayerTabId = "tab-1"; + renderToStaticMarkup(); + expect(mocks.pictureInPicturePressed).toBe(true); + mocks.togglePictureInPicture?.(); + expect(mocks.closeMiniPlayer).toHaveBeenCalledWith(props.threadRef); + }); + + it("keeps the native preview window as a secondary action", async () => { + const props = { + threadRef: { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + }, + tabId: "tab-1", + visible: true, + } as const; + + renderToStaticMarkup(); + mocks.toggleNativePictureInPicture?.(); + await vi.waitFor(() => + expect(mocks.openPictureInPicture).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID), + ); + + mocks.pictureInPicture = true; + renderToStaticMarkup(); + mocks.toggleNativePictureInPicture?.(); + await vi.waitFor(() => + expect(mocks.closePictureInPicture).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID), + ); + }); }); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index daff913a6d54..a3e23c2c41c9 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -22,6 +22,8 @@ import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useAtomCommand } from "~/state/use-atom-command"; +import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { useRightPanelStore } from "~/rightPanelStore"; import { previewBridge } from "./previewBridge"; import { subscribePreviewAction } from "./previewActionBus"; @@ -35,6 +37,7 @@ import { subscribeBrowserViewportChange, } from "~/browser/browserViewportActions"; import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout"; +import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; @@ -45,9 +48,10 @@ import { usePreviewSession } from "./usePreviewSession"; import { ZoomIndicator } from "./ZoomIndicator"; import { AgentBrowserCursor } from "./AgentBrowserCursor"; import { + findActiveBrowserRecordingRuntimeTabId, startBrowserRecording, stopBrowserRecording, - useActiveBrowserRecordingTabId, + useActiveBrowserRecordingTabIds, } from "~/browser/browserRecording"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; @@ -67,10 +71,13 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) { const [focusUrlNonce, setFocusUrlNonce] = useState(undefined); const [pickActive, setPickActive] = useState(false); - const activeRecordingTabId = useActiveBrowserRecordingTabId(); + const activeRecordingTabIds = useActiveBrowserRecordingTabIds(); const pickActiveRef = useRef(false); const isMountedRef = useRef(true); const previewState = useThreadPreviewState(threadRef); + const miniPlayer = usePreviewMiniPlayerStore((state) => + selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), + ); const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); const addImage = useComposerDraftStore((store) => store.addImage); const environment = useEnvironment(threadRef.environmentId); @@ -88,6 +95,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }, []); const tabId = requestedTabId ?? previewState.activeTabId; + const runtimeTabId = tabId + ? previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId) + : null; + const recordingRuntimeTabId = + tabId && runtimeTabId + ? activeRecordingTabIds.has(runtimeTabId) + ? runtimeTabId + : findActiveBrowserRecordingRuntimeTabId(threadRef, tabId) + : null; const snapshot = tabId ? (previewState.sessions[tabId] ?? null) : null; const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null; const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const }; @@ -110,15 +126,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, : undefined; const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; const panelRect = useBrowserSurfaceStore((state) => - tabId ? (state.byTabId[tabId]?.rect ?? null) : null, + runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, ); const navigateToResolvedUrl = useCallback( async (resolvedUrl: string) => { - if (tabId && previewBridge) { + if (runtimeTabId && previewBridge) { // Drive the webview imperatively; `usePreviewBridge` mirrors the // resolved URL back to the server so other clients stay in sync. - await previewBridge.navigate(tabId, resolvedUrl); + await previewBridge.navigate(runtimeTabId, resolvedUrl); rememberPreviewUrl(threadRef, resolvedUrl); } else { await openPreviewSession({ @@ -128,7 +144,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }); } }, - [open, tabId, threadRef], + [open, runtimeTabId, threadRef], ); const handleSubmitUrl = useCallback( @@ -154,20 +170,20 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, ); const handleRefresh = useCallback(() => { - if (previewBridge && tabId) void previewBridge.refresh(tabId); - }, [tabId]); + if (previewBridge && runtimeTabId) void previewBridge.refresh(runtimeTabId); + }, [runtimeTabId]); const handleZoomIn = useCallback(() => { - if (previewBridge && tabId) void previewBridge.zoomIn(tabId); - }, [tabId]); + if (previewBridge && runtimeTabId) void previewBridge.zoomIn(runtimeTabId); + }, [runtimeTabId]); const handleZoomOut = useCallback(() => { - if (previewBridge && tabId) void previewBridge.zoomOut(tabId); - }, [tabId]); + if (previewBridge && runtimeTabId) void previewBridge.zoomOut(runtimeTabId); + }, [runtimeTabId]); const handleResetZoom = useCallback(() => { - if (previewBridge && tabId) void previewBridge.resetZoom(tabId); - }, [tabId]); + if (previewBridge && runtimeTabId) void previewBridge.resetZoom(runtimeTabId); + }, [runtimeTabId]); const handleViewportChange = useCallback( async (nextViewport: PreviewViewportSetting) => { @@ -195,45 +211,68 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, ); const handleToggleDeviceToolbar = () => { - if (!tabId) return; + if (!runtimeTabId) return; if (viewport._tag !== "fill") { - void commitBrowserViewportChange(tabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined); + void commitBrowserViewportChange(runtimeTabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined); return; } const responsiveSize = panelRect ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor) : { width: 1024, height: 768 }; - void commitBrowserViewportChange(tabId, { _tag: "freeform", ...responsiveSize }).catch( + void commitBrowserViewportChange(runtimeTabId, { _tag: "freeform", ...responsiveSize }).catch( () => undefined, ); }; useEffect(() => { - if (!tabId) return; - return subscribeBrowserViewportChange(tabId, handleViewportChange); - }, [handleViewportChange, tabId]); + if (!runtimeTabId) return; + return subscribeBrowserViewportChange(runtimeTabId, handleViewportChange); + }, [handleViewportChange, runtimeTabId]); const handleBack = useCallback(() => { - if (previewBridge && tabId) void previewBridge.goBack(tabId); - }, [tabId]); + if (previewBridge && runtimeTabId) void previewBridge.goBack(runtimeTabId); + }, [runtimeTabId]); const handleForward = useCallback(() => { - if (previewBridge && tabId) void previewBridge.goForward(tabId); - }, [tabId]); + if (previewBridge && runtimeTabId) void previewBridge.goForward(runtimeTabId); + }, [runtimeTabId]); const handleOpenInBrowser = useCallback(() => { if (!localApi || !url) return; void localApi.shell.openExternal(url).catch(() => undefined); }, [url]); + const handlePictureInPicture = useCallback(() => { + if (!tabId) return; + if (miniPlayer?.tabId === tabId) { + usePreviewMiniPlayerStore.getState().close(threadRef); + return; + } + usePreviewMiniPlayerStore.getState().open(threadRef, tabId); + useRightPanelStore.getState().close(threadRef); + }, [miniPlayer?.tabId, tabId, threadRef]); + + const handleNativePictureInPicture = useCallback(() => { + if (!previewBridge || !runtimeTabId) return; + const operation = desktopOverlay?.pictureInPicture + ? previewBridge.pictureInPicture.close + : previewBridge.pictureInPicture.open; + void operation(runtimeTabId).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to update popped-out preview", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + }, [desktopOverlay?.pictureInPicture, runtimeTabId]); + const handleCapture = useCallback( (record: boolean) => { - if (!previewBridge || !tabId) return; + if (!previewBridge || !runtimeTabId || !tabId) return; const bridge = previewBridge; - const recordingThisTab = activeRecordingTabId === tabId; - if (recordingThisTab) { - void stopBrowserRecording(tabId).then( + if (recordingRuntimeTabId) { + void stopBrowserRecording(recordingRuntimeTabId).then( (artifact) => { if (!artifact) return; let pathCopied = false; @@ -325,15 +364,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, return; } if (record) { - if (activeRecordingTabId !== null) { - toastManager.add({ - type: "warning", - title: "Another preview is recording", - description: "Stop the active recording before starting a new one.", - }); - return; - } - void startBrowserRecording(tabId).catch((error) => { + void startBrowserRecording(runtimeTabId, threadRef, tabId).catch((error) => { toastManager.add({ type: "error", title: "Unable to start recording", @@ -342,7 +373,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }); return; } - void bridge.captureScreenshot(tabId).then( + void bridge.captureScreenshot(runtimeTabId).then( (artifact) => { const revealAction = { children: revealInFileExplorerLabel(navigator.platform), @@ -472,13 +503,13 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }, ); }, - [activeRecordingTabId, tabId], + [recordingRuntimeTabId, runtimeTabId, tabId, threadRef], ); const handlePickElement = useCallback(() => { - if (!previewBridge || !tabId) return; + if (!previewBridge || !runtimeTabId) return; if (pickActiveRef.current) { - void previewBridge.cancelPickElement(tabId).catch(() => undefined); + void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined); return; } // Snapshot whatever the user was focused on (typically the chat @@ -492,7 +523,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, setPickActive(true); void (async () => { try { - const annotation = await previewBridge.pickElement(tabId); + const annotation = await previewBridge.pickElement(runtimeTabId); if (!annotation) return; addPreviewAnnotation(threadRef, annotation); const screenshotFile = await previewAnnotationScreenshotFile(annotation); @@ -530,7 +561,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, } } })(); - }, [addImage, addPreviewAnnotation, tabId, threadRef]); + }, [addImage, addPreviewAnnotation, runtimeTabId, threadRef]); // If the active tab changes mid-pick (close, thread switch, hot restart), // tell main to tear down the in-flight session AND reset our local toggle @@ -539,12 +570,12 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, return () => { if (!pickActiveRef.current) return; pickActiveRef.current = false; - if (previewBridge && tabId) { - void previewBridge.cancelPickElement(tabId).catch(() => undefined); + if (previewBridge && runtimeTabId) { + void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined); } if (isMountedRef.current) setPickActive(false); }; - }, [tabId]); + }, [runtimeTabId]); // Subscribe only while visible; `toggle-panel` is owned by ChatView's // URL-aware handler regardless of whether the panel is currently mounted. @@ -594,7 +625,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, onOpenInBrowser={tabId ? handleOpenInBrowser : undefined} onCapture={previewBridge && tabId ? handleCapture : undefined} captureDisabled={!desktopOverlay || isUnreachable} - recording={tabId !== null && activeRecordingTabId === tabId} + recording={recordingRuntimeTabId !== null} + onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined} + pictureInPicture={miniPlayer?.tabId === tabId} + pictureInPictureDisabled={!desktopOverlay?.hasWebContents || isUnreachable} onPickElement={previewBridge && tabId ? handlePickElement : undefined} pickActive={pickActive} // Disable when there's no tab (nothing to pick on) OR the page @@ -607,22 +641,24 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, trailingActions={ previewBridge ? ( ) : null } />
- {tabId && snapshot && !showEmptyState ? ( + {runtimeTabId && snapshot && !showEmptyState ? ( @@ -638,9 +674,9 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, {snapshot && desktopOverlay ? ( ) : null} - {tabId && desktopOverlay && !showEmptyState && !isUnreachable ? ( + {runtimeTabId && desktopOverlay && !showEmptyState && !isUnreachable ? ( diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx new file mode 100644 index 000000000000..3e7c46ef0e0a --- /dev/null +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -0,0 +1,315 @@ +"use client"; + +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; +import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef } from "react"; + +import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; +import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; +import { Button } from "~/components/ui/button"; +import { toastManager } from "~/components/ui/toast"; +import { useThreadPreviewState } from "~/previewStateStore"; +import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +import { previewBridge } from "./previewBridge"; +import { + clampPreviewMiniPlayerPosition, + clampPreviewMiniPlayerSize, + PREVIEW_MINI_PLAYER_DEFAULT_SIZE, +} from "./previewMiniPlayerLayout"; + +interface DragState { + readonly pointerId: number; + readonly pointerX: number; + readonly pointerY: number; + readonly playerX: number; + readonly playerY: number; +} + +interface ResizeState { + readonly pointerId: number; + readonly pointerX: number; + readonly pointerY: number; + readonly width: number; + readonly height: number; +} + +interface Props { + readonly threadRef: ScopedThreadRef; + readonly tabId: string; + readonly bottomInset: number; +} + +export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props) { + const rootRef = useRef(null); + const dragRef = useRef(null); + const resizeRef = useRef(null); + const miniPlayer = usePreviewMiniPlayerStore((state) => + selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), + ); + const previewState = useThreadPreviewState(threadRef); + const snapshot = previewState.sessions[tabId] ?? null; + const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId); + const desktopOverlay = previewState.desktopByTabId[tabId] ?? null; + const position = miniPlayer?.tabId === tabId ? miniPlayer.position : null; + const size = + miniPlayer?.tabId === tabId && miniPlayer.size + ? miniPlayer.size + : PREVIEW_MINI_PLAYER_DEFAULT_SIZE; + const close = () => { + usePreviewMiniPlayerStore.getState().close(threadRef); + }; + + const openInPanel = () => { + usePreviewMiniPlayerStore.getState().close(threadRef); + useRightPanelStore.getState().openBrowser(threadRef, tabId); + }; + + const toggleNativePictureInPicture = () => { + if (!previewBridge) return; + const operation = desktopOverlay?.pictureInPicture + ? previewBridge.pictureInPicture.close + : previewBridge.pictureInPicture.open; + void operation(runtimeTabId).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to update popped-out preview", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + }; + + useLayoutEffect(() => { + const clampAndMove = () => { + const root = rootRef.current; + const parent = root?.offsetParent; + if (!root || !(parent instanceof HTMLElement)) return; + const nextSize = clampPreviewMiniPlayerSize( + { width: root.offsetWidth, height: root.offsetHeight }, + { width: parent.clientWidth, height: parent.clientHeight }, + bottomInset, + ); + usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); + const next = clampPreviewMiniPlayerPosition( + position ?? { x: root.offsetLeft, y: root.offsetTop }, + { width: parent.clientWidth, height: parent.clientHeight }, + nextSize, + bottomInset, + ); + usePreviewMiniPlayerStore.getState().move(threadRef, tabId, next); + }; + clampAndMove(); + const root = rootRef.current; + const parent = root?.offsetParent; + if (!root || !(parent instanceof HTMLElement) || typeof ResizeObserver === "undefined") { + return; + } + const observer = new ResizeObserver(clampAndMove); + observer.observe(root); + observer.observe(parent); + return () => observer.disconnect(); + }, [bottomInset, position, tabId, threadRef]); + + const handlePointerDown = (event: ReactPointerEvent) => { + if (event.button !== 0) return; + const root = rootRef.current; + const parent = root?.offsetParent; + if (!root || !(parent instanceof HTMLElement)) return; + const rootRect = root.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); + dragRef.current = { + pointerId: event.pointerId, + pointerX: event.clientX, + pointerY: event.clientY, + playerX: rootRect.left - parentRect.left, + playerY: rootRect.top - parentRect.top, + }; + event.currentTarget.setPointerCapture(event.pointerId); + event.preventDefault(); + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + const drag = dragRef.current; + const root = rootRef.current; + const parent = root?.offsetParent; + if (!drag || drag.pointerId !== event.pointerId || !root || !(parent instanceof HTMLElement)) { + return; + } + const next = clampPreviewMiniPlayerPosition( + { + x: drag.playerX + event.clientX - drag.pointerX, + y: drag.playerY + event.clientY - drag.pointerY, + }, + { width: parent.clientWidth, height: parent.clientHeight }, + { width: root.offsetWidth, height: root.offsetHeight }, + bottomInset, + ); + usePreviewMiniPlayerStore.getState().move(threadRef, tabId, next); + }; + + const endDrag = (event: ReactPointerEvent) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + dragRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; + + const handleResizePointerDown = (event: ReactPointerEvent) => { + if (event.button !== 0) return; + const root = rootRef.current; + if (!root) return; + resizeRef.current = { + pointerId: event.pointerId, + pointerX: event.clientX, + pointerY: event.clientY, + width: root.offsetWidth, + height: root.offsetHeight, + }; + event.currentTarget.setPointerCapture(event.pointerId); + event.preventDefault(); + event.stopPropagation(); + }; + + const handleResizePointerMove = (event: ReactPointerEvent) => { + const resize = resizeRef.current; + const root = rootRef.current; + const parent = root?.offsetParent; + if ( + !resize || + resize.pointerId !== event.pointerId || + !root || + !(parent instanceof HTMLElement) + ) { + return; + } + const nextSize = clampPreviewMiniPlayerSize( + { + width: resize.width + event.clientX - resize.pointerX, + height: resize.height + event.clientY - resize.pointerY, + }, + { width: parent.clientWidth, height: parent.clientHeight }, + bottomInset, + ); + usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); + const nextPosition = clampPreviewMiniPlayerPosition( + position ?? { x: root.offsetLeft, y: root.offsetTop }, + { width: parent.clientWidth, height: parent.clientHeight }, + nextSize, + bottomInset, + ); + usePreviewMiniPlayerStore.getState().move(threadRef, tabId, nextPosition); + }; + + const endResize = (event: ReactPointerEvent) => { + if (resizeRef.current?.pointerId !== event.pointerId) return; + resizeRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; + + if (!snapshot || miniPlayer?.tabId !== tabId) return null; + + return ( +
+
+ + +
+
+ +
+ {!desktopOverlay?.hasWebContents ? ( +
+ Reconnecting preview… +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts index 90de86f799d4..9ead8cfe1c0f 100644 --- a/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts +++ b/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts @@ -1,7 +1,12 @@ import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { previewAutomationOpenNeedsOverlay } from "./previewAutomationOpenReadiness"; +import { + DEFAULT_PREVIEW_AUTOMATION_VIEWPORT, + previewAutomationDefaultViewport, + previewAutomationOpenNeedsOverlay, + shouldOpenPreviewMiniPlayer, +} from "./previewAutomationOpenReadiness"; const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({ threadId: "thread-1", @@ -13,6 +18,18 @@ const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessio }); describe("preview automation open readiness", () => { + it("opens the inline preview by default", () => { + expect(shouldOpenPreviewMiniPlayer({} as PreviewAutomationOpenInput)).toBe(true); + }); + + it("supports explicit opt-out and the legacy show alias", () => { + expect(shouldOpenPreviewMiniPlayer({ open: false } as PreviewAutomationOpenInput)).toBe(false); + expect(shouldOpenPreviewMiniPlayer({ show: false } as PreviewAutomationOpenInput)).toBe(false); + expect( + shouldOpenPreviewMiniPlayer({ open: true, show: false } as PreviewAutomationOpenInput), + ).toBe(true); + }); + it("does not wait for a desktop overlay when opening an empty tab", () => { expect( previewAutomationOpenNeedsOverlay( @@ -43,4 +60,20 @@ describe("preview automation open readiness", () => { ), ).toBe(true); }); + + it("gives newly-created automation tabs a stable desktop viewport", () => { + expect(previewAutomationDefaultViewport(false, snapshot({ _tag: "Idle" }))).toEqual( + DEFAULT_PREVIEW_AUTOMATION_VIEWPORT, + ); + }); + + it("preserves reused and already-fixed browser viewports", () => { + expect(previewAutomationDefaultViewport(true, snapshot({ _tag: "Idle" }))).toBeNull(); + expect( + previewAutomationDefaultViewport(false, { + ...snapshot({ _tag: "Idle" }), + viewport: { _tag: "freeform", width: 900, height: 600 }, + }), + ).toBeNull(); + }); }); diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.ts index 416c2f87c64c..c9d29fd44e1e 100644 --- a/apps/web/src/components/preview/previewAutomationOpenReadiness.ts +++ b/apps/web/src/components/preview/previewAutomationOpenReadiness.ts @@ -1,4 +1,19 @@ -import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts"; +import { + FILL_PREVIEW_VIEWPORT, + type PreviewAutomationOpenInput, + type PreviewSessionSnapshot, + type PreviewViewportSetting, +} from "@t3tools/contracts"; + +export const DEFAULT_PREVIEW_AUTOMATION_VIEWPORT = { + _tag: "freeform", + width: 1280, + height: 800, +} as const satisfies PreviewViewportSetting; + +export function shouldOpenPreviewMiniPlayer(input: PreviewAutomationOpenInput): boolean { + return input.open ?? input.show ?? true; +} export function previewAutomationOpenNeedsOverlay( input: PreviewAutomationOpenInput, @@ -6,3 +21,13 @@ export function previewAutomationOpenNeedsOverlay( ): boolean { return input.url !== undefined || snapshot.navStatus._tag !== "Idle"; } + +export function previewAutomationDefaultViewport( + reusedExistingTab: boolean, + snapshot: PreviewSessionSnapshot, +): PreviewViewportSetting | null { + const viewport = snapshot.viewport ?? FILL_PREVIEW_VIEWPORT; + return !reusedExistingTab && viewport._tag === "fill" + ? DEFAULT_PREVIEW_AUTOMATION_VIEWPORT + : null; +} diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts new file mode 100644 index 000000000000..98b26e8a0b2b --- /dev/null +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + clampPreviewMiniPlayerPosition, + clampPreviewMiniPlayerSize, + PREVIEW_MINI_PLAYER_EDGE_GAP, +} from "./previewMiniPlayerLayout"; + +describe("clampPreviewMiniPlayerPosition", () => { + it("keeps a dragged player within the chat viewport", () => { + expect( + clampPreviewMiniPlayerPosition( + { x: 900, y: -40 }, + { width: 1_000, height: 700 }, + { width: 360, height: 240 }, + ), + ).toEqual({ + x: 628, + y: PREVIEW_MINI_PLAYER_EDGE_GAP, + }); + }); + + it("keeps an edge gap when the player is larger than its container", () => { + expect( + clampPreviewMiniPlayerPosition( + { x: 20, y: 30 }, + { width: 200, height: 160 }, + { width: 360, height: 240 }, + ), + ).toEqual({ + x: PREVIEW_MINI_PLAYER_EDGE_GAP, + y: PREVIEW_MINI_PLAYER_EDGE_GAP, + }); + }); + + it("keeps the player above a growing composer inset", () => { + expect( + clampPreviewMiniPlayerPosition( + { x: 500, y: 448 }, + { width: 1_000, height: 700 }, + { width: 360, height: 240 }, + 160, + ), + ).toEqual({ + x: 500, + y: 288, + }); + }); +}); + +describe("clampPreviewMiniPlayerSize", () => { + it("allows resizing within the available chat viewport", () => { + expect( + clampPreviewMiniPlayerSize({ width: 520, height: 360 }, { width: 1_000, height: 700 }, 120), + ).toEqual({ width: 520, height: 360 }); + }); + + it("bounds oversized players above the composer", () => { + expect( + clampPreviewMiniPlayerSize( + { width: 2_000, height: 2_000 }, + { width: 1_000, height: 700 }, + 120, + ), + ).toEqual({ width: 976, height: 556 }); + }); + + it("lets a tiny container win over the preferred minimum", () => { + expect( + clampPreviewMiniPlayerSize({ width: 360, height: 239 }, { width: 250, height: 180 }, 20), + ).toEqual({ width: 226, height: 136 }); + }); +}); diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.ts new file mode 100644 index 000000000000..abdd5b8ca4d0 --- /dev/null +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.ts @@ -0,0 +1,46 @@ +import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; + +export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12; +export const PREVIEW_MINI_PLAYER_DEFAULT_SIZE = { width: 320, height: 200 } as const; +export const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; + +export function clampPreviewMiniPlayerSize( + size: PreviewMiniPlayerSize, + container: PreviewMiniPlayerSize, + bottomInset = 0, +): PreviewMiniPlayerSize { + const availableWidth = Math.max(1, container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2); + const availableHeight = Math.max( + 1, + container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, + ); + return { + width: Math.round( + Math.min(Math.max(PREVIEW_MINI_PLAYER_MIN_SIZE.width, size.width), availableWidth), + ), + height: Math.round( + Math.min(Math.max(PREVIEW_MINI_PLAYER_MIN_SIZE.height, size.height), availableHeight), + ), + }; +} + +export function clampPreviewMiniPlayerPosition( + position: PreviewMiniPlayerPosition, + container: PreviewMiniPlayerSize, + player: PreviewMiniPlayerSize, + bottomInset = 0, +): PreviewMiniPlayerPosition { + const reservedBottomSpace = Math.max(0, bottomInset); + const maxX = Math.max( + PREVIEW_MINI_PLAYER_EDGE_GAP, + container.width - player.width - PREVIEW_MINI_PLAYER_EDGE_GAP, + ); + const maxY = Math.max( + PREVIEW_MINI_PLAYER_EDGE_GAP, + container.height - reservedBottomSpace - player.height - PREVIEW_MINI_PLAYER_EDGE_GAP, + ); + return { + x: Math.min(Math.max(position.x, PREVIEW_MINI_PLAYER_EDGE_GAP), maxX), + y: Math.min(Math.max(position.y, PREVIEW_MINI_PLAYER_EDGE_GAP), maxY), + }; +} diff --git a/apps/web/src/components/preview/previewNavigationReadiness.test.ts b/apps/web/src/components/preview/previewNavigationReadiness.test.ts new file mode 100644 index 000000000000..cefff7184133 --- /dev/null +++ b/apps/web/src/components/preview/previewNavigationReadiness.test.ts @@ -0,0 +1,56 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + readThreadPreviewState: vi.fn(), +})); + +vi.mock("~/previewStateStore", () => ({ + applyPreviewServerSnapshot: vi.fn(), + readThreadPreviewState: mocks.readThreadPreviewState, + reconcilePreviewServerSessions: vi.fn(), + updatePreviewServerSnapshot: vi.fn(), +})); + +vi.mock("./previewBridge", () => ({ + previewBridge: { + automation: { + evaluate: vi.fn(), + status: vi.fn(), + }, + }, +})); + +import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; + +import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors"; +import { waitForNavigationReadiness } from "./previewNavigationReadiness"; + +describe("waitForNavigationReadiness", () => { + it("rejects a replaced runtime target even when readiness polling is disabled", async () => { + const threadRef = { + environmentId: EnvironmentId.make("environment-2"), + threadId: ThreadId.make("thread-1"), + }; + const tabId = "tab_1"; + const staleRuntimeTabId = previewRuntimeTabId(threadRef, "epoch-1", tabId); + mocks.readThreadPreviewState.mockReturnValue({ + serverEpoch: "epoch-2", + sessions: { + [tabId]: { tabId }, + }, + }); + + await expect( + waitForNavigationReadiness( + threadRef, + "request-1", + tabId, + staleRuntimeTabId, + "navigate", + "none", + 100, + ), + ).rejects.toBeInstanceOf(PreviewAutomationTargetUnavailableError); + }); +}); diff --git a/apps/web/src/components/preview/previewNavigationReadiness.ts b/apps/web/src/components/preview/previewNavigationReadiness.ts new file mode 100644 index 000000000000..dd5e7247b714 --- /dev/null +++ b/apps/web/src/components/preview/previewNavigationReadiness.ts @@ -0,0 +1,74 @@ +import { + type PreviewAutomationNavigateInput, + type PreviewAutomationRequest, + type ScopedThreadRef, +} from "@t3tools/contracts"; + +import { isCurrentPreviewRuntimeTab } from "~/browser/previewRuntimeTabId"; +import { readThreadPreviewState } from "~/previewStateStore"; + +import { previewBridge } from "./previewBridge"; +import { + PreviewAutomationNavigationTimeoutError, + PreviewAutomationTargetUnavailableError, +} from "./previewAutomationErrors"; + +export function assertPreviewRuntimeCurrent( + threadRef: ScopedThreadRef, + tabId: string, + runtimeTabId: string, + request: Pick, +) { + const state = readThreadPreviewState(threadRef); + if ( + state.sessions[tabId] && + isCurrentPreviewRuntimeTab(threadRef, state.serverEpoch, tabId, runtimeTabId) + ) { + return state; + } + throw new PreviewAutomationTargetUnavailableError({ + requestId: request.requestId, + operation: request.operation, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + bridgeAvailable: Boolean(previewBridge), + }); +} + +export async function waitForNavigationReadiness( + threadRef: ScopedThreadRef, + requestId: string, + tabId: string, + runtimeTabId: string, + operation: PreviewAutomationRequest["operation"], + readiness: PreviewAutomationNavigateInput["readiness"], + timeoutMs: number, +): Promise { + const targetReadiness = readiness ?? "load"; + if (!previewBridge) return; + assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, { operation, requestId }); + if (targetReadiness === "none") return; + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, { operation, requestId }); + if (targetReadiness === "domContentLoaded") { + const readyState = await previewBridge.automation.evaluate(runtimeTabId, { + expression: "document.readyState", + }); + if (readyState === "interactive" || readyState === "complete") return; + } else { + const status = await previewBridge.automation.status(runtimeTabId); + if (status.available && !status.loading) return; + } + await new Promise((resolve) => window.setTimeout(resolve, 50)); + } + throw new PreviewAutomationNavigationTimeoutError({ + requestId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + readiness: targetReadiness, + timeoutMs, + }); +} diff --git a/apps/web/src/components/preview/previewViewportRollback.test.ts b/apps/web/src/components/preview/previewViewportRollback.test.ts new file mode 100644 index 000000000000..287e2d87ce2a --- /dev/null +++ b/apps/web/src/components/preview/previewViewportRollback.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { shouldRollbackPreviewViewport } from "./previewViewportRollback"; + +describe("shouldRollbackPreviewViewport", () => { + const fill = { _tag: "fill" } as const; + const requested = { _tag: "freeform", width: 900, height: 600 } as const; + + it("rolls back a timed-out request that still owns the latest setting", () => { + expect(shouldRollbackPreviewViewport(fill, requested, requested, "server-a", "server-a")).toBe( + true, + ); + }); + + it("does not overwrite a newer resize, replacement server, or repeated setting", () => { + expect( + shouldRollbackPreviewViewport( + fill, + requested, + { + _tag: "freeform", + width: 1024, + height: 768, + }, + "server-a", + "server-a", + ), + ).toBe(false); + expect(shouldRollbackPreviewViewport(fill, requested, requested, "server-a", "server-b")).toBe( + false, + ); + expect( + shouldRollbackPreviewViewport(requested, requested, requested, "server-a", "server-a"), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/preview/previewViewportRollback.ts b/apps/web/src/components/preview/previewViewportRollback.ts new file mode 100644 index 000000000000..c36bf52fd02c --- /dev/null +++ b/apps/web/src/components/preview/previewViewportRollback.ts @@ -0,0 +1,18 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; + +import { browserViewportSettingKey } from "~/browser/browserViewportLayout"; + +export function shouldRollbackPreviewViewport( + previous: PreviewViewportSetting, + requested: PreviewViewportSetting, + latest: PreviewViewportSetting, + operationServerEpoch: string | null, + currentServerEpoch: string | null, +): boolean { + const requestedKey = browserViewportSettingKey(requested); + return ( + currentServerEpoch === operationServerEpoch && + browserViewportSettingKey(latest) === requestedKey && + browserViewportSettingKey(previous) !== requestedKey + ); +} diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts index 22d17f968baa..259748c41d55 100644 --- a/apps/web/src/components/preview/usePreviewBridge.ts +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -19,8 +19,12 @@ import { previewBridge } from "./previewBridge"; * Mirrors low-latency desktop state into the store and reflects navigation * events back to the server. Webview lifetime is owned by ElectronBrowserHost. */ -export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void { - const { threadRef, tabId } = input; +export function usePreviewBridge(input: { + threadRef: ScopedThreadRef; + tabId: string; + runtimeTabId: string; +}): void { + const { threadRef, tabId, runtimeTabId } = input; const clearBrowserPointer = useBrowserPointerStore((state) => state.clear); const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report"); const bridge = previewBridge; @@ -36,9 +40,9 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str lastReportedKind.current = null; lastDesktopNavStatus.current = null; const unsubscribe = bridge.onStateChange((changedTabId, state) => { - if (changedTabId !== tabId) return; + if (changedTabId !== runtimeTabId) return; if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) { - clearBrowserPointer(tabId); + clearBrowserPointer(runtimeTabId); } lastDesktopNavStatus.current = state.navStatus; applyPreviewDesktopState(threadRef, tabId, projectDesktopState(state)); @@ -58,7 +62,7 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str }); }); return unsubscribe; - }, [bridge, clearBrowserPointer, reportStatus, tabId, threadRef]); + }, [bridge, clearBrowserPointer, reportStatus, runtimeTabId, tabId, threadRef]); } function shouldClearBrowserPointer( @@ -73,10 +77,12 @@ function shouldClearBrowserPointer( function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay { return { + hasWebContents: state.webContentsId !== null, canGoBack: state.canGoBack, canGoForward: state.canGoForward, loading: state.navStatus.kind === "Loading", zoomFactor: state.zoomFactor, + pictureInPicture: state.pictureInPicture, colorScheme: state.colorScheme, controller: state.controller, }; diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts index 9bc2cc84c376..7f1a997f2c24 100644 --- a/apps/web/src/components/preview/usePreviewSession.ts +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -2,14 +2,12 @@ import { useAtomValue } from "@effect/atom-react"; import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import type { ScopedThreadRef } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { applyPreviewServerEvent, - applyPreviewServerSnapshot, readThreadPreviewState, reconcilePreviewServerSessions, } from "~/previewStateStore"; @@ -41,66 +39,28 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => { return Atom.make((get) => { let disposed = false; - let recoveryId = 0; - let recoveringUrl: string | null = null; - let sessionsVersion = 0; let eventsVersion = 0; const reconcileSessions = (result: Atom.Type) => { if (!AsyncResult.isSuccess(result)) return; - if (result.value.sessions.length > 0) { - recoveringUrl = null; - recoveryId += 1; - reconcilePreviewServerSessions(threadRef, result.value.sessions); - return; - } - - const localSnapshot = readThreadPreviewState(threadRef).snapshot; - const recoverableUrl = - localSnapshot && localSnapshot.navStatus._tag !== "Idle" - ? localSnapshot.navStatus.url - : null; - if (!recoverableUrl) { - applyPreviewServerSnapshot(threadRef, null); - return; - } - if (recoveringUrl === recoverableUrl) return; - - recoveringUrl = recoverableUrl; - const currentRecoveryId = ++recoveryId; - void runAtomCommand( - get.registry, - previewEnvironment.open, - { - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, url: recoverableUrl }, - }, - { reportDefect: false, reportFailure: false }, - ).then((openResult) => { - if (disposed || currentRecoveryId !== recoveryId) return; - recoveringUrl = null; - if (openResult._tag === "Failure") return; - applyPreviewServerSnapshot(threadRef, openResult.value); - get.refresh(sessionsAtom); - }); + reconcilePreviewServerSessions(threadRef, result.value); }; const applyLatestEvent = (result: Atom.Type) => { if (!AsyncResult.isSuccess(result) || result.value.threadId !== threadRef.threadId) return; - applyPreviewServerEvent(threadRef, result.value); - if (result.value.type === "opened" || result.value.type === "closed") { + const currentEpoch = readThreadPreviewState(threadRef).serverEpoch; + if (currentEpoch !== null && currentEpoch !== result.value.serverEpoch) { get.refresh(sessionsAtom); + return; } + applyPreviewServerEvent(threadRef, result.value); }; get.addFinalizer(() => { disposed = true; - recoveryId += 1; }); - const initialSessions = get.once(sessionsAtom); const initialEvent = get.once(eventsAtom); get.subscribe(sessionsAtom, (result) => { - sessionsVersion += 1; reconcileSessions(result); }); get.subscribe(eventsAtom, (result) => { @@ -109,7 +69,10 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => { }); queueMicrotask(() => { if (disposed) return; - if (sessionsVersion === 0) reconcileSessions(initialSessions); + // The cached list can predate an automation-created tab. Keep the local + // snapshot visible until an authoritative refresh arrives instead of + // reconciling against a stale empty result when the panel first mounts. + get.refresh(sessionsAtom); if (eventsVersion === 0) applyLatestEvent(initialEvent); }); }).pipe(Atom.setIdleTTL(1_000), Atom.withLabel(`preview:session-sync:${threadKey}`)); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fa7c7299667e..310cdd5164c4 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -392,7 +392,7 @@ export function useSettingsRestore(onRestored?: () => void) { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); - const isGitWritingModelDirty = !Equal.equals( + const isTextGenerationModelDirty = !Equal.equals( settings.textGenerationModelSelection ?? null, DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); @@ -445,10 +445,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), - ...(isGitWritingModelDirty ? ["Git writing model"] : []), + ...(isTextGenerationModelDirty ? ["Text generation model"] : []), ], [ - isGitWritingModelDirty, + isTextGenerationModelDirty, settings.autoOpenPlanSidebar, settings.confirmThreadArchive, settings.confirmThreadDelete, @@ -533,21 +533,21 @@ export function GeneralSettingsPanel() { const textGenInstanceId = textGenerationModelSelection.instanceId; const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; - const gitModelInstanceEntries = sortProviderInstanceEntries( + const textGenerationModelInstanceEntries = sortProviderInstanceEntries( applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), ); - const textGenInstanceEntry = gitModelInstanceEntries.find( + const textGenInstanceEntry = textGenerationModelInstanceEntries.find( (entry) => entry.instanceId === textGenInstanceId, ); const textGenProvider: ProviderDriverKind = textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND; - const gitModelOptionsByInstance = getCustomModelOptionsByInstance( + const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance( settings, serverProviders, textGenInstanceId, textGenModel, ); - const isGitWritingModelDirty = !Equal.equals( + const isTextGenerationModelDirty = !Equal.equals( settings.textGenerationModelSelection ?? null, DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); @@ -1000,9 +1000,9 @@ export function GeneralSettingsPanel() { @@ -1020,8 +1020,8 @@ export function GeneralSettingsPanel() { activeInstanceId={textGenInstanceId} model={textGenModel} lockedProvider={null} - instanceEntries={gitModelInstanceEntries} - modelOptionsByInstance={gitModelOptionsByInstance} + instanceEntries={textGenerationModelInstanceEntries} + modelOptionsByInstance={textGenerationModelOptionsByInstance} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onInstanceModelChange={(instanceId, model) => { diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index e0387f1973ef..764f682fbc5d 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -48,6 +48,7 @@ import { type Icon, } from "../Icons"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; +import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout"; const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { @@ -195,7 +196,7 @@ function itemSummary({ {item.label} is not authenticated on this server. Sign in or configure credentials using the {item.executable}{" "} - tool on the server host to enable pull request features. + tool on the server host to enable change request features. ); } @@ -513,6 +514,8 @@ export function SourceControlSettingsPanel() { onScan={handleScan} /> )} + + {environmentId !== null ? : null} ); } diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx new file mode 100644 index 000000000000..d7c094af372b --- /dev/null +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -0,0 +1,213 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useRef } from "react"; +import type { SourceControlWritingStyleMode } from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { createModelSelection } from "@t3tools/shared/model"; +import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; + +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + sortProviderInstanceEntries, +} from "../../providerInstances"; +import { + getCustomModelOptionsByInstance, + resolveAppModelSelectionState, +} from "../../modelSelection"; +import { primaryServerProvidersAtom } from "../../state/server"; +import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { Textarea } from "../ui/textarea"; +import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; + +const MODE_OPTIONS: Record = + { + repo_conventions: { + label: "Repository conventions", + description: "In each project, matches recent change descriptions and change request titles.", + }, + conventional_commits: { + label: "Conventional Commits", + description: + "Uses Conventional Commit prefixes for change descriptions; change request titles and descriptions stay concise.", + }, + custom: { + label: "Custom instructions", + description: + "Applies your instructions to change descriptions and change request titles and descriptions in every project.", + }, + }; + +export function SourceControlWritingSettingsSection() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const serverProviders = useAtomValue(primaryServerProvidersAtom); + const customInstructionsRef = useRef(null); + const style = settings.sourceControlWritingStyle; + const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle; + const isSourceControlWritingStyleDirty = + style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions; + + const defaultModelSelection = resolveAppModelSelectionState(settings, serverProviders); + const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null; + const resolvedSourceControlWriterSelection = resolveSourceControlWriterModelSelection( + settings, + serverProviders, + ); + const activeSelection = + resolvedSourceControlWriterSelection === settings.textGenerationModelSelection + ? defaultModelSelection + : resolvedSourceControlWriterSelection; + const instanceEntries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), + ); + const modelOptionsByInstance = getCustomModelOptionsByInstance( + settings, + serverProviders, + activeSelection.instanceId, + activeSelection.model, + ); + + return ( + + + updateSettings({ + sourceControlWritingStyle: { + mode: defaults.mode, + customInstructions: defaults.customInstructions, + }, + }) + } + /> + ) : null + } + control={ + + } + > + {style.mode === "custom" ? ( +
+