diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index aa97c018bd21..9be6d4ecf55b 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,3 +1,4 @@ +import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts"; import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor, @@ -10,20 +11,6 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -/** - * Deep links to individual System Settings panes. These are app-fixed, not - * renderer-supplied, so they skip `parseSafeExternalUrl` — which exists to keep - * arbitrary link schemes from reaching the OS handler — and open through their - * own path below. The pane rather than the URL crosses the IPC boundary, so a - * renderer can only ask for one of these known destinations. - * - * Full Disk Access uses the post-Ventura `PrivacySecurity.extension` anchor. - */ -const SYSTEM_SETTINGS_URLS: Record = { - "full-disk-access": - "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", -}; - // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, // `zed://ssh//`) must reach the OS handler; every other non-web // scheme stays blocked. @@ -88,7 +75,7 @@ export const make = ElectronShell.of({ }), openSystemSettings: (pane) => Effect.promise(() => - Electron.shell.openExternal(SYSTEM_SETTINGS_URLS[pane]).then( + Electron.shell.openExternal(MAC_PERMISSION_SETTINGS_URLS[pane]).then( () => true, () => false, ), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index dc3769bb814f..c6ca676fc467 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -39,6 +39,7 @@ import { getWindowFullscreenState, openExternal, openSystemSettings, + checkSystemPermission, probeRemoteEditors, pickFolder, pickProjectFavicon, @@ -122,6 +123,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); yield* ipc.handle(openSystemSettings); + yield* ipc.handle(checkSystemPermission); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 43ecee06c0ca..5489e56fea1c 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -105,3 +105,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; + +export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper"; + +export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index f46522ba2f47..b86053c7919b 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -34,6 +34,9 @@ import * as ElectronMenu from "../../electron/ElectronMenu.ts"; import * as ElectronShell from "../../electron/ElectronShell.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as Electron from "electron"; +import * as MacPermissions from "../../permissions/MacPermissions.ts"; +import { safariPermissionCheck } from "../../preview/BrowserImport/SafariPermission.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; import { @@ -322,7 +325,16 @@ export const openSystemSettings = DesktopIpc.makeIpcMethod({ result: Schema.Boolean, handler: Effect.fn("desktop.ipc.window.openSystemSettings")(function* (pane) { const shell = yield* ElectronShell.ElectronShell; - return yield* shell.openSystemSettings(pane); + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (environment.platform !== "darwin") return false; + const owner = Electron.BrowserWindow.getFocusedWindow(); + const opened = yield* shell.openSystemSettings(pane); + if (opened && environment.isPackaged) { + const permissions = yield* MacPermissions.MacPermissions; + const isGranted = yield* safariPermissionCheck; + yield* permissions.showHelper(pane, owner, isGranted); + } + return opened; }), }); @@ -396,3 +408,15 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ }); }), }); + +export const checkSystemPermission = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, + payload: SystemSettingsPaneSchema, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.checkSystemPermission")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (environment.platform !== "darwin") return false; + const check = yield* safariPermissionCheck; + return yield* Effect.promise(check); + }), +}); diff --git a/apps/desktop/src/mac-permission-preload.ts b/apps/desktop/src/mac-permission-preload.ts new file mode 100644 index 000000000000..97a7f98827dd --- /dev/null +++ b/apps/desktop/src/mac-permission-preload.ts @@ -0,0 +1,17 @@ +import { ipcRenderer } from "electron"; +import { MAC_PERMISSION_HELPER_CHANNEL } from "./ipc/channels.ts"; + +// This preload belongs only to the static permission panel. No general desktop bridge is exposed. +window.addEventListener("DOMContentLoaded", () => { + const send = (action: "drag" | "finder" | "close") => + ipcRenderer.send(MAC_PERMISSION_HELPER_CHANNEL, action); + document.getElementById("app")?.addEventListener("dragstart", (event) => { + event.preventDefault(); + send("drag"); + }); + document.getElementById("app")?.addEventListener("click", () => send("finder")); + document.getElementById("close")?.addEventListener("click", () => send("close")); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") send("close"); + }); +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ed920abcdc8f..939b88c7d0d0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,3 +1,4 @@ +import * as MacPermissions from "./permissions/MacPermissions.ts"; for (const stream of [process.stdout, process.stderr]) { stream.on("error", (err: NodeJS.ErrnoException) => { if (err.code !== "EPIPE") throw err; @@ -133,6 +134,7 @@ const electronLayer = Layer.mergeAll( ); const desktopFoundationLayer = Layer.mergeAll( + MacPermissions.layer, DesktopState.layer, DesktopShutdown.layer, DesktopAppSettings.layer, diff --git a/apps/desktop/src/permissions/MacPermission.ts b/apps/desktop/src/permissions/MacPermission.ts new file mode 100644 index 000000000000..e72d796c876a --- /dev/null +++ b/apps/desktop/src/permissions/MacPermission.ts @@ -0,0 +1,15 @@ +export const MAC_PERMISSION_SETTINGS_URLS = { + "screen-recording": + "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture", + accessibility: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", + "full-disk-access": + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", +}; + +export type MacPermission = keyof typeof MAC_PERMISSION_SETTINGS_URLS; + +export const MAC_PERMISSION_TITLES: Record = { + "screen-recording": "Screen Recording", + accessibility: "Accessibility", + "full-disk-access": "Full Disk Access", +}; diff --git a/apps/desktop/src/permissions/MacPermissionHelper.test.ts b/apps/desktop/src/permissions/MacPermissionHelper.test.ts new file mode 100644 index 000000000000..e1160d9bbe38 --- /dev/null +++ b/apps/desktop/src/permissions/MacPermissionHelper.test.ts @@ -0,0 +1,314 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import * as Electron from "electron"; +import { MacPermissionHelper, macAppBundlePath } from "./MacPermissionHelper.ts"; +import type { SettingsWindow } from "./MacSettingsWindow.ts"; +import { MAC_PERMISSION_HELPER_CHANNEL } from "../ipc/channels.ts"; + +const mocks = vi.hoisted(() => ({ + granted: false, + createFromPath: vi.fn(), + startDrag: vi.fn(), + showItemInFolder: vi.fn(), + send: vi.fn(), + loadURL: vi.fn(), + stopTracking: vi.fn(), + trackingFailed: undefined as (() => void) | undefined, + settingsChanged: undefined as ((state: SettingsWindow) => void) | undefined, +})); +const windows = vi.hoisted( + () => + [] as Array<{ + destroyed: boolean; + webContents: { mainFrame: object }; + setBounds: ReturnType; + hide: ReturnType; + showInactive: ReturnType; + }>, +); +vi.mock("electron", async () => { + const { EventEmitter } = await import("node:events"); + class MockWindow extends EventEmitter { + destroyed = false; + webContents = Object.assign(new EventEmitter(), { + mainFrame: {}, + startDrag: mocks.startDrag, + send: mocks.send, + setWindowOpenHandler: vi.fn(), + }); + constructor(_options: unknown) { + super(); + windows.push(this); + } + isDestroyed() { + return this.destroyed; + } + destroy() { + this.destroyed = true; + this.emit("closed"); + } + close() { + this.destroy(); + } + loadURL = mocks.loadURL; + showInactive = vi.fn(); + hide = vi.fn(); + setBounds = vi.fn(); + isVisible = () => false; + isFocused = () => false; + show = vi.fn(); + focus = vi.fn(); + getBounds = () => ({ x: 0, y: 0, width: 800, height: 600 }); + } + return { + app: { + getPath: () => "/Applications/T3 Code (Nightly).app/Contents/MacOS/T3 Code", + }, + nativeImage: { createFromPath: mocks.createFromPath }, + BrowserWindow: class extends MockWindow {}, + ipcMain: new EventEmitter(), + screen: { + getDisplayMatching: () => ({ workArea: { x: 0, y: 0, width: 1200, height: 900 } }), + getCursorScreenPoint: () => ({ x: 10, y: 10 }), + getDisplayNearestPoint: () => ({ workArea: { x: -1200, y: 0, width: 1200, height: 900 } }), + }, + systemPreferences: { + getMediaAccessStatus: () => (mocks.granted ? "granted" : "denied"), + isTrustedAccessibilityClient: () => mocks.granted, + }, + shell: { showItemInFolder: mocks.showItemInFolder }, + }; +}); +vi.mock("./MacSettingsWindow.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + watchMacSettingsWindow: ( + onChange: (state: SettingsWindow) => void, + onUnavailable: () => void, + ) => { + mocks.settingsChanged = onChange; + mocks.trackingFailed = onUnavailable; + return mocks.stopTracking; + }, + }; +}); +let helper: MacPermissionHelper; +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + mocks.granted = false; + const icon = { toDataURL: () => "data:image/png;base64,abc" }; + mocks.createFromPath.mockReturnValue({ isEmpty: () => false, resize: () => icon }); + mocks.loadURL.mockResolvedValue(undefined); + windows.length = 0; + helper = new MacPermissionHelper(); +}); +afterEach(() => { + helper.close(); + vi.useRealTimers(); +}); +const iconPaths = ["/bundle/prod-resources/icon.png"]; +const open = () => + helper.show("accessibility", "/bundle/mac-permission-preload.cjs", null, iconPaths); +function send(action: string, trusted = true) { + const window = windows.at(-1)!; + Electron.ipcMain.emit( + MAC_PERMISSION_HELPER_CHANNEL, + { + sender: trusted ? window.webContents : {}, + senderFrame: window.webContents.mainFrame, + }, + action, + ); +} + +describe("macAppBundlePath", () => { + it("resolves bundles with spaces and refuses non-bundle executables", () => { + expect(macAppBundlePath("/Applications/T3 Code.app/Contents/MacOS/T3 Code")).toBe( + "/Applications/T3 Code.app", + ); + expect(macAppBundlePath("/usr/local/bin/electron")).toBeUndefined(); + expect(macAppBundlePath("/Applications/T3 Code.app/other/MacOS/T3 Code")).toBeUndefined(); + }); +}); +it("drags the running app bundle only for the helper's own renderer", async () => { + await open(); + send("drag", false); + expect(mocks.startDrag).not.toHaveBeenCalled(); + send("drag"); + expect(mocks.createFromPath).toHaveBeenCalledWith("/bundle/prod-resources/icon.png"); + expect(mocks.startDrag).toHaveBeenCalledWith({ + file: "/Applications/T3 Code (Nightly).app", + icon: mocks.createFromPath.mock.results[0]!.value.resize(), + }); + send("finder"); + expect(mocks.showItemInFolder).toHaveBeenCalledWith("/Applications/T3 Code (Nightly).app"); +}); +it("rechecks permissions and releases resources when granted", async () => { + await open(); + mocks.granted = true; + await vi.advanceTimersByTimeAsync(1000); + expect(windows[0]!.destroyed).toBe(true); + expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(0); + expect(vi.getTimerCount()).toBe(0); +}); +it("keeps only one helper and cleans up on dismissal", async () => { + await open(); + await helper.show("screen-recording", "/preload.cjs", null, iconPaths); + expect(windows[0]!.destroyed).toBe(true); + expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(1); + send("close"); + expect(windows[1]!.destroyed).toBe(true); + expect(vi.getTimerCount()).toBe(0); +}); +it("does not open for a permission already granted", async () => { + mocks.granted = true; + await open(); + expect(windows).toHaveLength(0); +}); +it("does not show a helper with a missing packaged icon", async () => { + mocks.createFromPath.mockReturnValueOnce({ isEmpty: () => true }); + await expect(open()).rejects.toThrow("packaged T3 Code icon is missing"); + expect(windows).toHaveLength(0); +}); +it("cleans up when the helper page fails to load", async () => { + mocks.loadURL.mockRejectedValueOnce(new Error("load failed")); + await expect(open()).rejects.toThrow("load failed"); + expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(0); + expect(vi.getTimerCount()).toBe(0); +}); + +it("offers the Finder fallback when native dragging fails", async () => { + await open(); + mocks.startDrag.mockImplementationOnce(() => { + throw new Error("drag failed"); + }); + send("drag"); + expect(mocks.showItemInFolder).toHaveBeenCalledWith("/Applications/T3 Code (Nightly).app"); + expect(windows[0]!.destroyed).toBe(false); +}); + +it("returns focus to onboarding when the permission is granted", async () => { + const owner = new Electron.BrowserWindow({}); + await helper.show("screen-recording", "/preload.cjs", owner, iconPaths); + mocks.granted = true; + await vi.advanceTimersByTimeAsync(1000); + expect(owner.show).toHaveBeenCalledOnce(); + expect(owner.focus).toHaveBeenCalledOnce(); + expect(windows[1]!.destroyed).toBe(true); + expect(owner.listenerCount("closed")).toBe(0); +}); +it("closes the helper and stops checking when onboarding's window closes", async () => { + const owner = new Electron.BrowserWindow({}); + await helper.show("accessibility", "/preload.cjs", owner, iconPaths); + owner.destroy(); + expect(windows[1]!.destroyed).toBe(true); + expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(0); + expect(vi.getTimerCount()).toBe(0); +}); + +it("uses the packaged PNG when an earlier resource candidate is absent", async () => { + mocks.createFromPath.mockReturnValueOnce({ isEmpty: () => true }); + await helper.show("accessibility", "/preload.cjs", null, ["/missing/icon.png", ...iconPaths]); + send("drag"); + expect(mocks.startDrag).toHaveBeenCalled(); + expect(mocks.createFromPath).toHaveBeenLastCalledWith(iconPaths[0]); +}); + +it("docks inside Settings and hides when it is covered or closed", async () => { + await open(); + const window = windows[0]!; + const settings = { x: 367, y: 100, width: 723, height: 719, frontmost: true }; + mocks.settingsChanged!(settings); + expect(window.setBounds).toHaveBeenLastCalledWith( + { x: 599, y: 663, width: 475, height: 140 }, + false, + ); + expect(window.showInactive).toHaveBeenCalledOnce(); + mocks.settingsChanged!({ ...settings, x: -800, y: 200 }); + expect(window.setBounds).toHaveBeenLastCalledWith( + { x: -568, y: 763, width: 475, height: 140 }, + false, + ); + mocks.settingsChanged!({ ...settings, frontmost: false }); + expect(window.hide).toHaveBeenCalledOnce(); + mocks.settingsChanged!(null); + expect(window.destroyed).toBe(true); + helper.close(); + expect(mocks.stopTracking).toHaveBeenCalledOnce(); +}); + +it("returns to onboarding when the Settings window disappears", async () => { + const owner = new Electron.BrowserWindow({}); + owner.hide(); + await helper.show("accessibility", "/preload.cjs", owner, iconPaths); + mocks.settingsChanged!({ x: 100, y: 100, width: 723, height: 719, frontmost: true }); + mocks.settingsChanged!(null); + expect(owner.show).toHaveBeenCalledOnce(); + expect(owner.focus).toHaveBeenCalledOnce(); +}); +it("hides on tracking failure and resumes on a valid update", async () => { + await open(); + const state = { x: 100, y: 100, width: 723, height: 719, frontmost: true }; + mocks.settingsChanged!(state); + mocks.trackingFailed!(); + expect(windows[0]!.destroyed).toBe(false); + expect(windows[0]!.hide).toHaveBeenCalledOnce(); + mocks.settingsChanged!(state); + expect(windows[0]!.showInactive).toHaveBeenCalledTimes(2); +}); + +it("waits for an asynchronous Full Disk Access check and returns to the owner", async () => { + const owner = new Electron.BrowserWindow(); + const probe = vi.fn<() => Promise>().mockResolvedValue(false); + await helper.show( + "full-disk-access", + "/bundle/mac-permission-preload.cjs", + owner, + iconPaths, + probe, + ); + const pending = Promise.withResolvers(); + probe.mockReturnValue(pending.promise); + await vi.advanceTimersByTimeAsync(3000); + expect(probe).toHaveBeenCalledTimes(2); + expect(windows[1]!.destroyed).toBe(false); + pending.resolve(true); + await pending.promise; + expect(windows[1]!.destroyed).toBe(true); + expect(owner.focus).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); +}); + +it("keeps the helper open after a failed access check and retries", async () => { + const probe = vi.fn<() => Promise>().mockResolvedValue(false); + await helper.show( + "full-disk-access", + "/bundle/mac-permission-preload.cjs", + null, + iconPaths, + probe, + ); + probe.mockRejectedValueOnce(new Error("temporarily unavailable")); + await vi.advanceTimersByTimeAsync(1000); + expect(windows[0]!.destroyed).toBe(false); + probe.mockResolvedValue(true); + await vi.advanceTimersByTimeAsync(1000); + expect(windows[0]!.destroyed).toBe(true); +}); + +it("does not reopen a superseded helper when its initial probe completes", async () => { + const pending = Promise.withResolvers(); + const first = helper.show( + "full-disk-access", + "/bundle/mac-permission-preload.cjs", + null, + iconPaths, + () => pending.promise, + ); + await open(); + pending.resolve(false); + await first; + expect(windows).toHaveLength(1); + expect(windows[0]!.destroyed).toBe(false); +}); diff --git a/apps/desktop/src/permissions/MacPermissionHelper.ts b/apps/desktop/src/permissions/MacPermissionHelper.ts new file mode 100644 index 000000000000..c02b338c556b --- /dev/null +++ b/apps/desktop/src/permissions/MacPermissionHelper.ts @@ -0,0 +1,220 @@ +// @effect-diagnostics globalTimers:off -- Poll TCC only while the native permission helper is open. +import * as Electron from "electron"; +import { MAC_PERMISSION_HELPER_CHANNEL } from "../ipc/channels.ts"; + +import { + settingsHelperBounds, + watchMacSettingsWindow, + type SettingsWindow, +} from "./MacSettingsWindow.ts"; + +import { MAC_PERMISSION_TITLES, type MacPermission } from "./MacPermission.ts"; + +const permissionGranted = (permission: MacPermission) => { + if (permission === "screen-recording") + return Electron.systemPreferences.getMediaAccessStatus("screen") === "granted"; + if (permission === "accessibility") + return Electron.systemPreferences.isTrustedAccessibilityClient(false); + return false; +}; + +/** Resolve the outer app bundle, never the executable or the ASAR inside it. */ +export function macAppBundlePath(executable: string): string | undefined { + return /^(.+\.app)\/Contents\/MacOS\/[^/]+$/.exec(executable)?.[1]; +} + +const escapeHtml = (value: string) => + value.replace(/[&<>"']/g, (character) => { + switch (character) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); + +function helperHtml(permission: MacPermission, icon: string) { + const title = MAC_PERMISSION_TITLES[permission]; + return ` + +Set up ${title}
+ +
↑ Drag T3 Code into the list above
+ +
`; +} + +/** Owns one temporary panel and its IPC listener. Closing it releases all resources. */ +export class MacPermissionHelper { + private generation = 0; + private window: Electron.BrowserWindow | undefined; + + close() { + this.generation++; + this.window?.destroy(); + this.window = undefined; + } + + async show( + permission: MacPermission, + preload: string, + owner: Electron.BrowserWindow | null, + iconPaths: readonly string[], + isGranted: () => boolean | Promise = () => permissionGranted(permission), + ) { + this.close(); + const generation = this.generation; + if (await isGranted()) return; + if (generation !== this.generation) return; + const bundle = macAppBundlePath(Electron.app.getPath("exe")); + if (!bundle) return; + if (owner?.isDestroyed()) return; + // Finder's bundle-icon lookup can return the generic app icon for mounted artifacts. + // Use the same PNG that packaging uses to generate the app's macOS icon. + const appIcon = iconPaths + .map((iconPath) => Electron.nativeImage.createFromPath(iconPath)) + .find((image) => !image.isEmpty()); + if (!appIcon) throw new Error("The packaged T3 Code icon is missing."); + const icon = appIcon.resize({ width: 64, height: 64 }); + const window = new Electron.BrowserWindow({ + width: 560, + height: 140, + show: false, + frame: false, + transparent: true, + roundedCorners: false, + backgroundColor: "#00000000", + hasShadow: false, + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + alwaysOnTop: true, + skipTaskbar: true, + title: `Set up ${MAC_PERMISSION_TITLES[permission]}`, + webPreferences: { preload, sandbox: true, contextIsolation: true, nodeIntegration: false }, + }); + this.window = window; + const finish = () => { + window.close(); + if (owner && !owner.isDestroyed()) { + owner.show(); + owner.focus(); + } + }; + let checking = false; + const check = async () => { + if (checking || window.isDestroyed()) return; + checking = true; + try { + if ((await isGranted()) && !window.isDestroyed()) finish(); + } catch { + // An unavailable probe is not evidence of a grant; the wizard can retry. + } finally { + checking = false; + } + }; + const onMessage = (event: Electron.IpcMainEvent, action: unknown) => { + if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) + return; + if (action === "drag") { + try { + window.webContents.startDrag({ file: bundle, icon }); + } catch { + Electron.shell.showItemInFolder(bundle); + } + } else if (action === "finder") { + Electron.shell.showItemInFolder(bundle); + } else if (action === "close") { + finish(); + } + }; + let settingsWindow: SettingsWindow = null; + let foundSettings = false; + let trackingAvailable = true; + const syncPosition = () => { + if (window.isDestroyed()) return; + if (!trackingAvailable) { + window.hide(); + return; + } + if (!settingsWindow && foundSettings) { + finish(); + return; + } + if (!settingsWindow || (!settingsWindow.frontmost && !window.isFocused())) { + window.hide(); + return; + } + const bounds = settingsHelperBounds(settingsWindow); + const current = window.getBounds(); + if ( + current.x !== bounds.x || + current.y !== bounds.y || + current.width !== bounds.width || + current.height !== bounds.height + ) { + window.setBounds(bounds, false); + } + if (!window.isVisible()) window.showInactive(); + }; + let stopTracking = () => {}; + window.on("blur", syncPosition); + const onOwnerClosed = () => window.destroy(); + Electron.ipcMain.on(MAC_PERMISSION_HELPER_CHANNEL, onMessage); + const timer = setInterval(check, 1_000); + owner?.once("closed", onOwnerClosed); + window.once("closed", () => { + clearInterval(timer); + stopTracking(); + Electron.ipcMain.removeListener(MAC_PERMISSION_HELPER_CHANNEL, onMessage); + owner?.removeListener("closed", onOwnerClosed); + if (this.window === window) this.window = undefined; + }); + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", (event) => event.preventDefault()); + try { + await window.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent(helperHtml(permission, icon.toDataURL()))}`, + ); + if (!window.isDestroyed()) { + stopTracking = watchMacSettingsWindow( + (current) => { + trackingAvailable = true; + settingsWindow = current; + if (current) foundSettings = true; + syncPosition(); + }, + () => { + trackingAvailable = false; + syncPosition(); + }, + ); + } + } catch (error) { + if (!window.isDestroyed()) window.destroy(); + throw error; + } + } +} diff --git a/apps/desktop/src/permissions/MacPermissions.ts b/apps/desktop/src/permissions/MacPermissions.ts new file mode 100644 index 000000000000..0a7bb3a2c447 --- /dev/null +++ b/apps/desktop/src/permissions/MacPermissions.ts @@ -0,0 +1,45 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Electron from "electron"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { MacPermissionHelper } from "./MacPermissionHelper.ts"; +import type { MacPermission } from "./MacPermission.ts"; + +export class MacPermissions extends Context.Service< + MacPermissions, + { + readonly showHelper: ( + permission: MacPermission, + owner: Electron.BrowserWindow | null, + isGranted?: () => boolean | Promise, + ) => Effect.Effect; + } +>()("@t3tools/desktop/permissions/MacPermissions") {} + +export const layer = Layer.effect( + MacPermissions, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const path = yield* Path.Path; + const helper = new MacPermissionHelper(); + yield* Effect.addFinalizer(() => Effect.sync(() => helper.close())); + return MacPermissions.of({ + showHelper: Effect.fn("MacPermissions.showHelper")(function* (permission, owner, isGranted) { + if (environment.platform !== "darwin" || !environment.isPackaged) return; + yield* Effect.tryPromise(() => + helper.show( + permission, + path.join(environment.dirname, "mac-permission-preload.cjs"), + owner, + environment.resolveResourcePathCandidates("icon.png"), + isGranted, + ), + ).pipe( + Effect.catch((cause) => Effect.logWarning("Could not show permission helper", cause)), + ); + }), + }); + }), +); diff --git a/apps/desktop/src/permissions/MacSettingsWindow.test.ts b/apps/desktop/src/permissions/MacSettingsWindow.test.ts new file mode 100644 index 000000000000..3071b00c3264 --- /dev/null +++ b/apps/desktop/src/permissions/MacSettingsWindow.test.ts @@ -0,0 +1,55 @@ +import * as NodeEvents from "node:events"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; +import { settingsHelperBounds, watchMacSettingsWindow } from "./MacSettingsWindow.ts"; + +const mocks = vi.hoisted(() => ({ spawn: vi.fn() })); +vi.mock("node:child_process", () => ({ spawn: mocks.spawn })); +let child: NodeEvents.EventEmitter & { + stdout: NodeEvents.EventEmitter & { setEncoding: ReturnType }; + kill: ReturnType; +}; +beforeEach(() => { + child = Object.assign(new NodeEvents.EventEmitter(), { + stdout: Object.assign(new NodeEvents.EventEmitter(), { setEncoding: vi.fn() }), + kill: vi.fn(), + }); + mocks.spawn.mockReturnValue(child); +}); +it("places the helper inside small and large Settings windows across displays", () => { + for (const x of [-900, 20]) { + for (const width of [668, 1000]) { + const state = { x, y: 30, width, height: 700, frontmost: true }; + const helper = settingsHelperBounds(state); + expect(helper.x).toBeGreaterThanOrEqual(x + 216); + expect(helper.x + helper.width).toBeLessThanOrEqual(x + width - 16); + expect(helper.y + helper.height).toBe(714); + } + } +}); +it("decodes partial updates and stops the one watcher process on disposal", () => { + const changed = vi.fn(); + const unavailable = vi.fn(); + const stop = watchMacSettingsWindow(changed, unavailable); + const state = { x: 10, y: 20, width: 723, height: 719, frontmost: true }; + const line = JSON.stringify(state); + child.stdout.emit("data", line.slice(0, 8)); + expect(changed).not.toHaveBeenCalled(); + child.stdout.emit("data", line.slice(8) + "\nnull\n"); + expect(changed.mock.calls).toEqual([[state], [null]]); + stop(); + expect(child.kill).toHaveBeenCalledOnce(); + child.emit("exit", 0); + expect(changed).toHaveBeenCalledTimes(2); +}); +it("distinguishes unavailable tracking from a valid absent window", () => { + const changed = vi.fn(); + const unavailable = vi.fn(); + const stop = watchMacSettingsWindow(changed, unavailable); + child.stdout.emit("data", '{"x":"bad"}\n'); + expect(changed).not.toHaveBeenCalled(); + expect(unavailable).toHaveBeenCalledOnce(); + child.emit("error", new Error("spawn failed")); + expect(changed).not.toHaveBeenCalled(); + expect(unavailable).toHaveBeenCalledTimes(2); + stop(); +}); diff --git a/apps/desktop/src/permissions/MacSettingsWindow.ts b/apps/desktop/src/permissions/MacSettingsWindow.ts new file mode 100644 index 000000000000..02d8180e0785 --- /dev/null +++ b/apps/desktop/src/permissions/MacSettingsWindow.ts @@ -0,0 +1,114 @@ +// @effect-diagnostics nodeBuiltinImport:off -- This native boundary keeps one JXA process open while the permission helper tracks System Settings. +import * as NodeChildProcess from "node:child_process"; +import * as Schema from "effect/Schema"; +import type * as Electron from "electron"; + +const SettingsWindow = Schema.NullOr( + Schema.Struct({ + x: Schema.Finite, + y: Schema.Finite, + width: Schema.Finite, + height: Schema.Finite, + frontmost: Schema.Boolean, + }), +); +export type SettingsWindow = typeof SettingsWindow.Type; +const decodeSettingsWindow = Schema.decodeUnknownSync(Schema.fromJsonString(SettingsWindow)); + +// Window bounds and owner PIDs are available before Screen Recording is granted. +// Use the bundle identifier rather than the localized app/window title. A single +// process avoids launching osascript repeatedly while the user moves Settings. +const SETTINGS_WINDOW_SCRIPT = ` +ObjC.import("CoreGraphics"); +ObjC.import("AppKit"); +function run() { + let previous = ""; + while (true) { + const apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier("com.apple.systempreferences"); + let result = null; + if (apps.count > 0) { + const pid = apps.objectAtIndex(0).processIdentifier; + const front = $.NSWorkspace.sharedWorkspace.frontmostApplication; + const list = $.CGWindowListCopyWindowInfo( + $.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements, + $.kCGNullWindowID + ); + if (list) { + $.CFMakeCollectable(list); + const count = $.CFArrayGetCount(list); + for (let i = 0; i < count; i++) { + const w = ObjC.castRefToObject($.CFArrayGetValueAtIndex(list, i)); + if (w.objectForKey("kCGWindowOwnerPID").js !== pid || w.objectForKey("kCGWindowLayer").js !== 0) continue; + const b = ObjC.deepUnwrap(w.objectForKey("kCGWindowBounds")); + if (b.Width < 500 || b.Height < 350) continue; + result = { x: b.X, y: b.Y, width: b.Width, height: b.Height, frontmost: !front.isNil() && front.processIdentifier === pid }; + break; + } + } + } + const line = JSON.stringify(result); + if (line !== previous) { + const data = $(line + "\\n").dataUsingEncoding($.NSUTF8StringEncoding); + $.NSFileHandle.fileHandleWithStandardOutput.writeData(data); + previous = line; + } + $.NSThread.sleepForTimeInterval(result && result.frontmost ? 0.5 : 1); + } +}`; + +/** Place the panel inside Settings' content column, above its bottom edge. */ +export function settingsHelperBounds(settings: NonNullable): Electron.Rectangle { + const sidebarWidth = 216; + const inset = 16; + const width = Math.min(560, settings.width - sidebarWidth - inset * 2); + return { + x: Math.round(settings.x + sidebarWidth + (settings.width - sidebarWidth - width) / 2), + y: Math.round(settings.y + settings.height - 140 - inset), + width: Math.round(width), + height: 140, + }; +} + +/** Track only metadata; this does not request Accessibility or Screen Recording. */ +export function watchMacSettingsWindow( + onChange: (window: SettingsWindow) => void, + onUnavailable: () => void, +): () => void { + const child = NodeChildProcess.spawn( + "/usr/bin/osascript", + ["-l", "JavaScript", "-e", SETTINGS_WINDOW_SCRIPT], + { + stdio: ["ignore", "pipe", "ignore"], + }, + ); + let pending = ""; + let closed = false; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + pending += chunk; + let end: number; + while ((end = pending.indexOf("\n")) !== -1) { + const line = pending.slice(0, end); + pending = pending.slice(end + 1); + if (closed) return; + let settings: SettingsWindow; + try { + settings = decodeSettingsWindow(line); + } catch { + onUnavailable(); + continue; + } + onChange(settings); + } + }); + const onExit = () => { + if (!closed) onUnavailable(); + }; + child.on("error", onExit); + child.on("exit", onExit); + return () => { + closed = true; + child.stdout.removeAllListeners("data"); + child.kill(); + }; +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7da32d7913ae..63041db98c28 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -152,6 +152,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + checkSystemPermission: (pane: string) => + ipcRenderer.invoke(IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, pane), openSystemSettings: (pane: string) => ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts index f5d07f765943..587c045422bb 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -11,6 +11,7 @@ import { parseBinaryCookies, readSafariCookies, safariAccessDenied, + safariAccessGranted, SafariCookieReadError, } from "./SafariCookies.ts"; @@ -441,3 +442,36 @@ describe("isPermissionDenied", () => { expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false); }); }); + +describe("safariAccessGranted", () => { + it.effect("only reports a successful read-only open as granted", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-safari-permission-", + }); + const jar = `${directory}/Cookies.binarycookies`; + assert.isFalse(yield* safariAccessGranted(jar)); + yield* fileSystem.writeFileString(jar, "no cookie parsing needed"); + assert.isTrue(yield* safariAccessGranted(jar)); + yield* fileSystem.remove(jar); + assert.isFalse(yield* safariAccessGranted(jar)); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("does not mistake TCC denial for a grant", () => + Effect.gen(function* () { + const denied = PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "open", + cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), + }); + assert.isFalse( + yield* safariAccessGranted("/protected/Cookies.binarycookies").pipe( + Effect.provide(FileSystem.layerNoop({ open: () => Effect.fail(denied) })), + ), + ); + }), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts index 54df5b959496..36d0417c79e8 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -225,6 +225,16 @@ export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: strin ); }); +/** A missing or unreadable jar is never evidence that access was granted. */ +export const safariAccessGranted = Effect.fnUntraced(function* (cookiePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + Effect.scoped, + ); +}); + export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* ( cookiePath: string, ) { diff --git a/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts b/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts new file mode 100644 index 000000000000..cd41673a3f14 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts @@ -0,0 +1,59 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; +import { safariPermissionCheck } from "./SafariPermission.ts"; + +it.effect("detects Safari access becoming available without reading or importing cookies", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-safari-access-" }); + const directory = `${home}/Library/Containers/com.apple.Safari/Data/Library/Cookies`; + yield* fs.makeDirectory(directory, { recursive: true }); + const jar = `${directory}/Cookies.binarycookies`; + yield* fs.writeFileString(jar, "not a valid cookie database"); + let allowed = false; + const guardedFs = FileSystem.FileSystem.of({ + ...fs, + open: (path, options) => + allowed + ? fs.open(path, options) + : Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "open", + cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), + }), + ), + }); + const check = yield* safariPermissionCheck.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(FileSystem.FileSystem, guardedFs), + ); + assert.isFalse(yield* Effect.promise(check)); + allowed = true; + assert.isTrue(yield* Effect.promise(check)); + yield* fs.remove(jar); + assert.isFalse(yield* Effect.promise(check)); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), +); + +it.effect("recognizes access when cookies exist only in a named Safari profile", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-safari-named-access-" }); + const check = yield* safariPermissionCheck.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + assert.isFalse(yield* Effect.promise(check)); + const directory = `${home}/Library/Containers/com.apple.Safari/Data/Library/WebKit/WebsiteDataStore/12345678-1234-1234-1234-123456789abc/Cookies`; + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString(`${directory}/Cookies.binarycookies`, "not parsed"); + assert.isTrue(yield* Effect.promise(check)); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), +); diff --git a/apps/desktop/src/preview/BrowserImport/SafariPermission.ts b/apps/desktop/src/preview/BrowserImport/SafariPermission.ts new file mode 100644 index 000000000000..1aa72bcc0a0f --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariPermission.ts @@ -0,0 +1,31 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import { safariAccessGranted } from "./SafariCookies.ts"; +import { + BROWSER_IMPORT_SOURCES, + resolveCookieDatabase, + listSourceProfiles, + sourcePathContext, +} from "./Sources.ts"; + +export const safariPermissionCheck = Effect.gen(function* () { + const context = yield* sourcePathContext; + const services = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(services); + const safari = BROWSER_IMPORT_SOURCES.find((source) => source.engine === "safari"); + const check = Effect.gen(function* () { + if (!safari || context.platform !== "darwin") return false; + const defaultJar = yield* resolveCookieDatabase(safari, context, "."); + if (defaultJar !== undefined) return yield* safariAccessGranted(defaultJar); + // A Safari installation can have cookies only in a named profile. Rediscover + // those stores after a grant, since TCC may have hidden their metadata before. + const profiles = yield* listSourceProfiles(safari, context); + for (const profile of profiles) { + const jar = yield* resolveCookieDatabase(safari, context, profile.directory); + if (jar !== undefined && (yield* safariAccessGranted(jar))) return true; + } + return false; + }); + // Open and close the jar without reading cookies or attempting an import. + return () => runPromise(check); +}); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index c062d91329b7..66c91981b667 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,3 +1,4 @@ +import * as NodeVM from "node:vm"; import { it as effectIt } from "@effect/vitest"; import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; @@ -3925,29 +3926,92 @@ describe("PreviewManager", () => { withManager((manager) => Effect.gen(function* () { let failKeyDown = false; + let routeToIframe = false; + let interruptFrameKeyDown = false; + let holdKeyUp = false; + let releaseKeyUp: (() => void) | undefined; + let notifyKeyUpQueued: (() => void) | undefined; + const keyUpQueued = new Promise((resolve) => { + notifyKeyUpQueued = resolve; + }); + const listeners = new Map void>(); + const eventCounts = new Map(); + const animationFrames = new Map void>(); + let animationFrameId = 0; + const renderFrame = () => { + const callbacks = [...animationFrames.values()]; + animationFrames.clear(); + callbacks.forEach((callback) => callback()); + }; + const frameContext = NodeVM.createContext({ + performance: { eventCounts }, + requestAnimationFrame: (callback: () => void) => { + const id = ++animationFrameId; + animationFrames.set(id, callback); + return id; + }, + cancelAnimationFrame: (id: number) => animationFrames.delete(id), + window: { + addEventListener: (type: string, listener: (event: unknown) => void) => + listeners.set(type, listener), + removeEventListener: (type: string) => listeners.delete(type), + }, + }); + const frame = { + executeJavaScript: vi.fn(async (expression: string) => + NodeVM.runInContext(expression, frameContext), + ), + }; let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; - const sendCommand = vi.fn(async (method: string, params?: Record) => { - if ( - failKeyDown && - method === "Input.dispatchKeyEvent" && - (params?.["type"] === "keyDown" || params?.["type"] === "rawKeyDown") - ) { - throw new Error("key dispatch failed"); - } - if ( - method === "Input.dispatchKeyEvent" && - (params?.["type"] === "keyDown" || params?.["type"] === "rawKeyDown") - ) { - humanInput?.( - {}, - { - kind: "key", - key: params["key"], - code: params["code"] ?? "Digit1", - }, - ); + const sendCommand = vi.fn( + async (method: string, params?: Record, sessionId?: string) => { + if (method === "Runtime.evaluate") { + if (params?.["returnByValue"] === true) return { result: { value: { ok: true } } }; + return { + result: + routeToIframe && !sessionId + ? { objectId: "focused-iframe-object" } + : { subtype: "null" }, + }; + } + if (method === "DOM.describeNode") return { node: { frameId: "focused-frame" } }; + if (method === "Target.getTargets") + return { + targetInfos: [ + { targetId: "unrelated-frame", type: "iframe" }, + { targetId: "focused-frame", type: "iframe" }, + ], + }; + if (method === "Target.attachToTarget") return { sessionId: "child-session" }; + if (method === "Input.dispatchKeyEvent" && params?.["type"] !== "keyUp") { + if (failKeyDown) throw new Error("key dispatch failed"); + if (interruptFrameKeyDown) + humanInput?.({}, { kind: "pointer", x: 80, y: 40, button: 0 }); + } + return undefined; + }, + ); + const sendInputEvent = vi.fn((input: Electron.KeyboardInputEvent) => { + const signal = { + kind: "key", + key: input.keyCode, + code: input.keyCode === "!" ? "Digit1" : `Key${input.keyCode.toUpperCase()}`, + }; + if (input.type === "keyUp") { + const deliver = () => { + eventCounts.set("keyup", (eventCounts.get("keyup") ?? 0) + 1); + renderFrame(); + }; + if (holdKeyUp) { + releaseKeyUp = deliver; + notifyKeyUpQueued?.(); + } else { + queueMicrotask(deliver); + } } - return method === "Runtime.evaluate" ? { result: { value: { ok: true } } } : undefined; + if (input.type !== "keyDown") return; + if (failKeyDown) throw new Error("key dispatch failed"); + humanInput?.({}, signal); }); const restoreFocus = vi.fn(); const focus = vi.fn(); @@ -3958,6 +4022,7 @@ describe("PreviewManager", () => { } as never); fromId.mockReturnValue({ id: 42, + mainFrame: { framesInSubtree: [frame] }, isDestroyed: () => false, getType: () => "webview", getURL: () => "https://example.com", @@ -3965,6 +4030,7 @@ describe("PreviewManager", () => { isLoading: () => false, isDevToolsOpened: () => false, focus, + sendInputEvent, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -4003,13 +4069,6 @@ describe("PreviewManager", () => { ([method, params]) => method === "Emulation.setFocusEmulationEnabled" && params?.["enabled"] === true, ); - const keyDownIndex = calls.findIndex( - ([method, params]) => - method === "Input.dispatchKeyEvent" && params?.["type"] === "keyDown", - ); - const keyUpIndex = calls.findIndex( - ([method, params]) => method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp", - ); const focusOffIndex = calls.findIndex( ([method, params]) => method === "Emulation.setFocusEmulationEnabled" && params?.["enabled"] === false, @@ -4037,61 +4096,102 @@ describe("PreviewManager", () => { expect(clearOnlyEvaluation).toBeDefined(); expect(methods).not.toContain("Input.insertText"); expect(enableIndex).toBeGreaterThanOrEqual(0); - expect(focus).toHaveBeenCalledOnce(); - expect(restoreFocus).toHaveBeenCalledOnce(); - expect(methods).toContain("Page.bringToFront"); + expect(methods).not.toContain("Page.bringToFront"); + expect(methods).not.toContain("Input.dispatchKeyEvent"); expect(enableIndex).toBeLessThan(focusOnIndex); - expect(focusOnIndex).toBeLessThan(keyDownIndex); - expect(keyDownIndex).toBeLessThan(keyUpIndex); - expect(keyUpIndex).toBeLessThan(focusOffIndex); - expect( - calls.filter( - ([method, params]) => - method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp", - ), - ).toHaveLength(1); + expect(sendCommand.mock.invocationCallOrder[focusOnIndex]).toBeLessThan( + sendInputEvent.mock.invocationCallOrder[0]!, + ); + expect(sendInputEvent.mock.invocationCallOrder[2]).toBeLessThan( + sendCommand.mock.invocationCallOrder[focusOffIndex]!, + ); + expect(sendInputEvent.mock.calls.map(([input]) => input.type)).toEqual([ + "keyDown", + "char", + "keyUp", + ]); expect(sendCommand).toHaveBeenCalledWith("Input.setIgnoreInputEvents", { ignore: false }); + expect(listeners.size).toBe(0); + expect(animationFrames.size).toBe(0); sendCommand.mockClear(); - failKeyDown = true; - const failedPress = yield* Effect.exit(manager.automationPress("tab_input", { key: "y" })); - - expect(Exit.isFailure(failedPress)).toBe(true); - expect(sendCommand).toHaveBeenCalledWith("Input.dispatchKeyEvent", { - type: "keyUp", - key: "y", - code: "KeyY", - modifiers: 0, - windowsVirtualKeyCode: 89, - location: 0, - isKeypad: false, + sendInputEvent.mockClear(); + getFocusedWebContents.mockReturnValue(null); + holdKeyUp = true; + const backgroundPress = yield* manager + .automationPress("tab_input", { key: "x" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => keyUpQueued); + expect(sendCommand).not.toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", { + enabled: false, }); + renderFrame(); + expect(animationFrames.size).toBe(1); + releaseKeyUp?.(); + yield* Fiber.join(backgroundPress); + expect(listeners.size).toBe(0); + expect(animationFrames.size).toBe(0); expect(sendCommand).toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", { enabled: false, }); - expect(restoreFocus).toHaveBeenCalledTimes(2); - expect( - sendCommand.mock.calls.filter( - ([method, params]) => - method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp", - ), - ).toHaveLength(1); + holdKeyUp = false; + + // Both native failures and expected-input matching must leave focus emulation off. + for (const key of ["y", "!"]) { + sendCommand.mockClear(); + sendInputEvent.mockClear(); + failKeyDown = key === "y"; + const exit = yield* Effect.exit(manager.automationPress("tab_input", { key })); + expect(Exit.isFailure(exit)).toBe(failKeyDown); + expect(sendInputEvent.mock.calls.map(([input]) => input.type)).toEqual( + failKeyDown ? ["keyDown", "keyUp"] : ["keyDown", "char", "keyUp"], + ); + expect(sendCommand).toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", { + enabled: false, + }); + } - sendCommand.mockClear(); - failKeyDown = false; - yield* manager.automationPress("tab_input", { key: "!" }); - expect(sendCommand).toHaveBeenCalledWith("Input.dispatchKeyEvent", { - type: "keyDown", - key: "!", - code: "Digit1", - modifiers: 0, - windowsVirtualKeyCode: 49, - location: 0, - isKeypad: false, - text: "!", - unmodifiedText: "!", - }); - expect(restoreFocus).toHaveBeenCalledTimes(3); + routeToIframe = true; + sendInputEvent.mockClear(); + for (const outcome of ["success", "failure", "interrupted"]) { + sendCommand.mockClear(); + failKeyDown = outcome === "failure"; + interruptFrameKeyDown = outcome === "interrupted"; + const exit = yield* Effect.exit( + manager.automationPress("tab_input", { + key: outcome === "success" ? "Enter" : "x", + }), + ); + expect(Exit.isSuccess(exit)).toBe(outcome === "success"); + if (outcome === "interrupted" && Exit.isFailure(exit)) { + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewAutomationControlInterruptedError", + }); + } + expect(sendInputEvent).not.toHaveBeenCalled(); + expect(sendCommand).toHaveBeenCalledWith("Target.attachToTarget", { + targetId: "focused-frame", + flatten: true, + }); + expect( + sendCommand.mock.calls + .filter(([method]) => method === "Input.dispatchKeyEvent") + .map(([, params, sessionId]) => ({ type: params?.["type"], sessionId })), + ).toEqual([ + { type: "keyDown", sessionId: "child-session" }, + { type: "keyUp", sessionId: "child-session" }, + ]); + expect(sendCommand).toHaveBeenCalledWith( + "Emulation.setFocusEmulationEnabled", + { enabled: false }, + "child-session", + ); + expect(sendCommand).toHaveBeenCalledWith("Target.detachFromTarget", { + sessionId: "child-session", + }); + } + expect(focus).not.toHaveBeenCalled(); + expect(restoreFocus).not.toHaveBeenCalled(); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 6c4a00053141..7cf73af91e92 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -5,6 +5,7 @@ * elements live in the renderer; we only attach listeners and forward state * here). Single layer-scoped browser session partition. */ +import * as NodeCrypto from "node:crypto"; import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; import type { DesktopPreviewAnnotationTheme, @@ -74,7 +75,11 @@ import { } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts"; -import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts"; +import { + makePreviewAutomationKeySequence, + makePreviewAutomationNativeKeySequence, + previewAutomationEditingCommandExpression, +} from "./PreviewKeyboard.ts"; import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; export type PreviewNavStatus = @@ -1391,6 +1396,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function type SendCommand = ( method: string, commandParams?: Record, + sessionId?: string, ) => Effect.Effect; const prepareAutomationInput = Effect.fn("PreviewManager.prepareAutomationInput")(function* ( @@ -1410,7 +1416,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId: string, wc: Electron.WebContents, action: string, - use: (send: SendCommand, sendCleanup: SendCommand) => Effect.Effect, + use: ( + send: SendCommand, + sendCleanup: SendCommand, + checkControl: Effect.Effect, + ) => Effect.Effect, ) { const sequence = yield* nextCounter(actionSequenceRef); const startedAt = yield* currentIso; @@ -1426,28 +1436,27 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const control = yield* ensureControlSession(wc); const execute = Effect.fn("PreviewManager.executeControlAction")(function* () { yield* update(tabId, { controller: "agent" }); + const checkControl = Effect.gen(function* () { + const currentEpoch = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; + if (currentEpoch !== epoch) { + return yield* new PreviewAutomationControlInterruptedError({ + operation: action, + tabId, + webContentsId: wc.id, + }); + } + }); const send: SendCommand = Effect.fn("PreviewManager.sendCommand")( - function* (method, commandParams) { - const before = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; - if (before !== epoch) { - return yield* new PreviewAutomationControlInterruptedError({ - operation: action, - tabId, - webContentsId: wc.id, - }); - } + function* (method, commandParams, sessionId) { + yield* checkControl; const result = yield* attemptPromise( { operation: `${action}.${method}`, tabId, webContentsId: wc.id }, - () => control.debugger.sendCommand(method, commandParams), + () => + sessionId === undefined + ? control.debugger.sendCommand(method, commandParams) + : control.debugger.sendCommand(method, commandParams, sessionId), ); - const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; - if (after !== epoch) { - return yield* new PreviewAutomationControlInterruptedError({ - operation: action, - tabId, - webContentsId: wc.id, - }); - } + yield* checkControl; return result; }, ); @@ -1455,18 +1464,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function // control epoch. Otherwise a partially dispatched input can leave Chromium // with a held key or focus emulation enabled for subsequent actions. const sendCleanup: SendCommand = Effect.fn("PreviewManager.sendCleanupCommand")( - function* (method, commandParams) { + function* (method, commandParams, sessionId) { return yield* attemptPromise( { operation: `${action}.cleanup.${method}`, tabId, webContentsId: wc.id, }, - () => control.debugger.sendCommand(method, commandParams), + () => + sessionId === undefined + ? control.debugger.sendCommand(method, commandParams) + : control.debugger.sendCommand(method, commandParams, sessionId), ); }, ); - return yield* use(send, sendCleanup); + return yield* use(send, sendCleanup, checkControl); }); const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* ( exit: Exit.Exit, @@ -3912,56 +3924,326 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const withNativeKeyReceipt = Effect.fn("PreviewManager.withNativeKeyReceipt")(function* ( + tabId: string, + wc: Electron.WebContents, + dispatch: Effect.Effect, + checkControl: Effect.Effect, + ) { + const context = { operation: "automationPress.awaitNativeKey", tabId, webContentsId: wc.id }; + const evaluate = (frame: Electron.WebFrameMain, expression: string) => + attemptPromise(context, () => frame.executeJavaScript(expression)); + const { frames, receiptKey } = yield* Effect.acquireRelease( + attempt(context, () => ({ + frames: wc.mainFrame.framesInSubtree, + receiptKey: JSON.stringify(`__t3NativeKey_${NodeCrypto.randomUUID()}`), + })), + ({ frames, receiptKey }) => + Effect.all( + frames.map((frame) => + evaluate(frame, `globalThis[${receiptKey}]?.dispose()`).pipe( + Effect.timeoutOption(1_000), + Effect.ignore, + ), + ), + { concurrency: "unbounded", discard: true }, + ), + ); + yield* Effect.gen(function* () { + for (const frame of frames) { + yield* checkControl; + yield* evaluate( + frame, + `(() => { + const receiptKey = ${receiptKey}; + const counts = performance.eventCounts; + if (!counts) throw new Error("Native key delivery counters are unavailable."); + const keyUpsBefore = counts.get("keyup") ?? 0; + const keyDownsBefore = counts.get("keydown") ?? 0; + let settle; + let animationFrame = 0; + const promise = new Promise(resolve => { settle = resolve; }); + const finish = delivered => { + cancelAnimationFrame(animationFrame); + window.removeEventListener("pagehide", onPageHide, true); + settle(delivered); + }; + // Chromium counts trusted keys before dispatching page listeners, + // so stopImmediatePropagation cannot hide completed input. + const observe = () => { + if ((counts.get("keyup") ?? 0) > keyUpsBefore) finish(true); + else animationFrame = requestAnimationFrame(observe); + }; + const onPageHide = () => finish( + (counts.get("keyup") ?? 0) > keyUpsBefore || + (counts.get("keydown") ?? 0) > keyDownsBefore, + ); + globalThis[receiptKey] = { promise, dispose: () => { + finish(false); + delete globalThis[receiptKey]; + }}; + window.addEventListener("pagehide", onPageHide, true); + animationFrame = requestAnimationFrame(observe); + })()`, + ); + } + yield* checkControl; + yield* dispatch; + yield* attemptPromise(context, () => + Promise.any( + frames.map(async (frame) => { + const delivered: unknown = await frame.executeJavaScript( + `globalThis[${receiptKey}]?.promise`, + ); + if (delivered !== true) + throw new Error( + "The preview document changed before native key delivery was confirmed.", + ); + }), + ), + ); + yield* checkControl; + }).pipe( + Effect.timeout(5_000), + Effect.catchTags({ + TimeoutError: () => + Effect.fail(new PreviewAutomationTimeoutError({ tabId, timeoutMs: 5_000 })), + }), + ); + }, Effect.scoped); + + const resolveKeyboardTarget = Effect.fn("PreviewManager.resolveKeyboardTarget")(function* ( + tabId: string, + send: SendCommand, + sendCleanup: SendCommand, + checkControl: Effect.Effect, + ) { + const context = { operation: "automationPress.resolveFocusedFrame", tabId }; + let sessionId: string | undefined; + let contextId: number | undefined; + while (true) { + const evaluated = (yield* send( + "Runtime.evaluate", + { + expression: `(() => { + let element = document.activeElement; + while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement; + return element?.tagName === "IFRAME" ? element : null; + })()`, + ...(contextId === undefined ? {} : { contextId }), + }, + sessionId, + )) as { result?: { objectId?: string; subtype?: string } }; + if (evaluated.result?.subtype === "null") break; + const objectId = evaluated.result?.objectId; + if (!objectId) + return yield* new PreviewOperationError({ + ...context, + cause: new Error("The focused preview frame could not be resolved."), + }); + const described = (yield* send("DOM.describeNode", { objectId }, sessionId).pipe( + Effect.ensuring( + sendCleanup("Runtime.releaseObject", { objectId }, sessionId).pipe(Effect.ignore), + ), + )) as { node?: { frameId?: string } }; + const frameId = described.node?.frameId; + if (!frameId) + return yield* new PreviewOperationError({ + ...context, + cause: new Error("The focused preview iframe is unavailable."), + }); + const targets = (yield* send("Target.getTargets")) as { + targetInfos?: ReadonlyArray<{ targetId: string; type: string }>; + }; + if ( + targets.targetInfos?.some( + (target) => target.type === "iframe" && target.targetId === frameId, + ) + ) { + yield* checkControl; + // Register cleanup before checking the epoch again: a successful + // attach must be released even when human input interrupts its reply. + sessionId = yield* Effect.acquireRelease( + sendCleanup("Target.attachToTarget", { targetId: frameId, flatten: true }).pipe( + Effect.flatMap((response) => + attempt(context, () => { + const attached = response as { sessionId?: string }; + if (!attached.sessionId) + throw new Error("The focused preview iframe could not be attached."); + return attached.sessionId; + }), + ), + ), + (attachedSessionId) => + sendCleanup("Target.detachFromTarget", { sessionId: attachedSessionId }).pipe( + Effect.ignore, + ), + ); + yield* checkControl; + contextId = undefined; + } else { + const world = (yield* send( + "Page.createIsolatedWorld", + { + frameId, + worldName: "t3-preview-key-target", + }, + sessionId, + )) as { executionContextId?: number }; + if (typeof world.executionContextId !== "number") + return yield* new PreviewOperationError({ + ...context, + cause: new Error("The focused preview iframe context is unavailable."), + }); + contextId = world.executionContextId; + } + } + return { sessionId, contextId }; + }); + const performAutomationPress = Effect.fn("PreviewManager.performAutomationPress")(function* ( tabId: string, wc: Electron.WebContents, input: PreviewAutomationPressInput, send: SendCommand, sendCleanup: SendCommand, + checkControl: Effect.Effect, ) { yield* prepareAutomationInput(send, false); - const keySequence = makePreviewAutomationKeySequence(input, { + const keySequence = makePreviewAutomationNativeKeySequence(input, { isMac: hostPlatform === "darwin", }); - const previouslyFocused = yield* attempt( - { operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id }, - () => webContents.getFocusedWebContents(), - ); - let keyDownAttempted = false; - const releaseInput = Effect.gen(function* () { - if (keyDownAttempted) { - yield* sendCleanup("Input.dispatchKeyEvent", keySequence.keyUp).pipe(Effect.ignore); - } - yield* sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }).pipe( - Effect.ignore, + // CDP keyboard dispatch follows the embedder's focused renderer, and + // WebContents.focus() is a no-op for webview guests. Native input targets + // this guest's widget directly, so Enter cannot submit the host composer. + yield* Effect.gen(function* () { + const { sessionId, contextId } = yield* resolveKeyboardTarget( + tabId, + send, + sendCleanup, + checkControl, ); - if (previouslyFocused && previouslyFocused.id !== wc.id && !previouslyFocused.isDestroyed()) { - yield* attempt( - { - operation: "automationPress.restoreFocusedWebContents", + // Only descendant renderer sessions bypass Chromium's desktop focus lookup. + if (sessionId) { + const keys = makePreviewAutomationKeySequence(input, { isMac: hostPlatform === "darwin" }); + yield* Effect.acquireRelease(Effect.void, () => + sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }, sessionId).pipe( + Effect.ignore, + ), + ); + yield* send("Emulation.setFocusEmulationEnabled", { enabled: true }, sessionId); + yield* Effect.acquireRelease(Effect.void, () => + sendCleanup("Input.dispatchKeyEvent", keys.keyUp, sessionId).pipe(Effect.ignore), + ); + yield* send("Input.dispatchKeyEvent", keys.keyDown, sessionId); + return; + } + if (keySequence.commands?.length) { + const context = { + operation: "automationPress.editFocusedFrame", + tabId, + webContentsId: wc.id, + }; + const evaluate = (expression: string, cleanup = false) => + evaluateWithDebugger( tabId, - webContentsId: previouslyFocused.id, - }, - () => previouslyFocused.focus(), - ).pipe(Effect.ignore); + (method, params) => + (cleanup ? sendCleanup : send)(method, { + ...params, + ...(contextId === undefined ? {} : { contextId }), + }), + expression, + true, + ); + const clipboardData = keySequence.commands.includes("paste") + ? yield* attemptPromise(context, async () => { + const formats: Array<{ type: string; data: string }> = []; + for (const item of await clipboard.read()) { + for (const type of item.types) { + if (type.startsWith("electron ")) continue; + const blob = await item.getType(type); + if (!("arrayBuffer" in blob)) continue; + formats.push({ + type, + data: type.startsWith("text/") + ? await blob.text() + : Buffer.from(await blob.arrayBuffer()).toString("base64"), + }); + } + } + return formats; + }) + : []; + yield* checkControl; + const expression = previewAutomationEditingCommandExpression( + input, + keySequence, + clipboardData, + ); + const selectionKey = yield* encodeJson( + context, + `__t3EditingSelection_${NodeCrypto.randomUUID()}`, + ); + // Editing requires an active document. Preserve the target + // and selection across focus handlers without focusing the desktop. + yield* Effect.acquireUseRelease( + evaluate(`(() => { + let element = document.activeElement; + while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement; + const selection = document.getSelection(); + const range = selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null; + const backward = selection?.direction === "backward"; + const start = element?.selectionStart; + const end = element?.selectionEnd; + const direction = element?.selectionDirection; + globalThis[${selectionKey}] = () => { + element?.focus({ preventScroll: true }); + if (typeof start === "number") element.setSelectionRange(start, end, direction); + else { + selection?.removeAllRanges(); + if (range && backward) selection.setBaseAndExtent( + range.endContainer, range.endOffset, range.startContainer, range.startOffset, + ); + else if (range) selection.addRange(range); + } + }; + })()`), + () => + Effect.gen(function* () { + yield* send("Emulation.setFocusEmulationEnabled", { enabled: true }); + yield* evaluate(`globalThis[${selectionKey}]();${expression}`); + }), + () => evaluate(`delete globalThis[${selectionKey}]`, true).pipe(Effect.ignore), + ); + yield* checkControl; + return; } - }); - - // Focus the guest WebContents itself, not its containing BrowserWindow. This - // activates native keyboard behavior for hidden/background previews without - // changing which thread is mounted in the UI. Restore the previous renderer - // after dispatch so automation never leaves the app's input focus behind. - yield* Effect.gen(function* () { - yield* attempt( - { operation: "automationPress.focusWebContents", tabId, webContentsId: wc.id }, - () => wc.focus(), - ); - yield* send("Page.bringToFront"); yield* send("Emulation.setFocusEmulationEnabled", { enabled: true }); - yield* expectAgentInput(tabId, keySequence.signal); - keyDownAttempted = true; - yield* send("Input.dispatchKeyEvent", keySequence.keyDown); - }).pipe(Effect.ensuring(releaseInput)); + yield* withNativeKeyReceipt( + tabId, + wc, + Effect.gen(function* () { + yield* expectAgentInput(tabId, keySequence.signal); + yield* attempt( + { operation: "automationPress.sendInputEvent", tabId, webContentsId: wc.id }, + () => { + try { + wc.sendInputEvent(keySequence.keyDown); + if (keySequence.char) wc.sendInputEvent(keySequence.char); + } finally { + wc.sendInputEvent(keySequence.keyUp); + } + }, + ); + }), + checkControl, + ); + }).pipe( + Effect.scoped, + Effect.ensuring( + sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }).pipe(Effect.ignore), + ), + ); }); const automationPress = Effect.fn("PreviewManager.automationPress")(function* ( @@ -3969,8 +4251,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: PreviewAutomationPressInput, ) { const wc = yield* requireWebContents(tabId); - yield* withControlSession(tabId, wc, "press", (send, sendCleanup) => - performAutomationPress(tabId, wc, input, send, sendCleanup), + yield* withControlSession(tabId, wc, "press", (send, sendCleanup, checkControl) => + performAutomationPress(tabId, wc, input, send, sendCleanup, checkControl), ); }); diff --git a/apps/desktop/src/preview/PreviewKeyboard.test.ts b/apps/desktop/src/preview/PreviewKeyboard.test.ts index 7a9a7373fe32..39b5420876fb 100644 --- a/apps/desktop/src/preview/PreviewKeyboard.test.ts +++ b/apps/desktop/src/preview/PreviewKeyboard.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts"; +import { + makePreviewAutomationKeySequence, + makePreviewAutomationNativeKeySequence, +} from "./PreviewKeyboard.ts"; describe("preview keyboard packets", () => { it("includes the Chromium virtual key code and Enter text", () => { @@ -94,4 +97,88 @@ describe("preview keyboard packets", () => { }); expect(sequence.signal).toEqual({ kind: "key", key: "!", code: "Digit1" }); }); + + it.each([ + ["Enter", "\r"], + ["z", "z"], + ])("converts %s into native down, char, and up packets", (key, text) => { + const sequence = makePreviewAutomationNativeKeySequence({ key }); + const shared = { keyCode: key, modifiers: [], skipIfUnhandled: true }; + expect(sequence.keyDown).toEqual({ type: "keyDown", ...shared }); + expect(sequence.char).toEqual({ type: "char", ...shared, keyCode: text }); + expect(sequence.keyUp).toEqual({ type: "keyUp", ...shared }); + }); + + it("suppresses text for shortcuts and retains macOS editing commands", () => { + const sequence = makePreviewAutomationNativeKeySequence( + { key: "a", modifiers: ["Meta"] }, + { isMac: true }, + ); + expect(sequence.keyDown).toEqual({ + type: "keyDown", + keyCode: "a", + modifiers: ["meta"], + skipIfUnhandled: true, + }); + expect(sequence.char).toBeUndefined(); + expect(sequence.commands).toEqual(["selectAll"]); + }); + + it.each([ + ["ArrowLeft", "Left"], + ["ArrowRight", "Right"], + ["ArrowUp", "Up"], + ["ArrowDown", "Down"], + ])("maps %s to Electron's %s accelerator", (key, keyCode) => { + const sequence = makePreviewAutomationNativeKeySequence({ key }); + expect(sequence.keyDown.keyCode).toBe(keyCode); + expect(sequence.keyUp.keyCode).toBe(keyCode); + expect(sequence.signal.key).toBe(key); + expect(sequence.char).toBeUndefined(); + }); + + it("matches native uppercase key signals without inventing shortcut modifiers", () => { + const plain = makePreviewAutomationNativeKeySequence({ key: "X" }); + expect(plain.signal).toEqual({ kind: "key", key: "x", code: "KeyX" }); + expect(plain.char?.keyCode).toBe("X"); + const shortcut = makePreviewAutomationNativeKeySequence({ key: "A", modifiers: ["Control"] }); + expect(shortcut.signal).toEqual({ kind: "key", key: "a", code: "KeyA" }); + expect(shortcut.keyDown.modifiers).toEqual(["control"]); + expect(shortcut.char).toBeUndefined(); + expect( + makePreviewAutomationNativeKeySequence({ key: "X", modifiers: ["Shift"] }).signal, + ).toEqual({ + kind: "key", + key: "X", + code: "KeyX", + }); + }); + + it("matches native signals for Unicode text and literal spaces", () => { + const unicode = makePreviewAutomationNativeKeySequence({ key: "é" }); + expect(unicode.signal).toEqual({ kind: "key", key: "", code: "" }); + expect(unicode.char?.keyCode).toBe("é"); + expect(makePreviewAutomationNativeKeySequence({ key: " " }).signal).toEqual({ + kind: "key", + key: " ", + code: "Space", + }); + }); + + it("preserves text and editing commands for isolated child renderer targets", () => { + const text = makePreviewAutomationKeySequence({ key: "é" }); + expect(text.keyDown).toMatchObject({ type: "keyDown", text: "é", key: "é" }); + expect(text.keyUp).toMatchObject({ type: "keyUp", key: "é" }); + const shortcut = makePreviewAutomationKeySequence( + { key: "a", modifiers: ["Meta"] }, + { isMac: true }, + ); + expect(shortcut.keyDown).toMatchObject({ + type: "rawKeyDown", + modifiers: 4, + commands: ["selectAll"], + }); + expect(shortcut.keyDown).not.toHaveProperty("text"); + expect(shortcut.keyDown).not.toHaveProperty("nativeVirtualKeyCode"); + }); }); diff --git a/apps/desktop/src/preview/PreviewKeyboard.ts b/apps/desktop/src/preview/PreviewKeyboard.ts index 0d231b86f4cc..69f2daad006e 100644 --- a/apps/desktop/src/preview/PreviewKeyboard.ts +++ b/apps/desktop/src/preview/PreviewKeyboard.ts @@ -131,7 +131,7 @@ const modifierMask = (modifiers: PreviewAutomationPressInput["modifiers"]): numb }, 0); function resolveKeyDefinition(input: PreviewAutomationPressInput): KeyDefinition { - const named = NAMED_KEYS[input.key]; + const named = NAMED_KEYS[input.key === " " ? "Space" : input.key]; if (named) return named; const functionKey = /^F([1-9]|1[0-2])$/.exec(input.key); @@ -201,3 +201,163 @@ export function makePreviewAutomationKeySequence( signal: { kind: "key", key: definition.key, code: definition.code }, }; } + +/** Root CDP input can retarget the embedder; native packets address the guest widget. */ +export function makePreviewAutomationNativeKeySequence( + input: PreviewAutomationPressInput, + options?: { readonly isMac?: boolean }, +) { + const { keyDown, signal } = makePreviewAutomationKeySequence(input, options); + const modifiers = ( + [ + [1, "alt"], + [2, "control"], + [4, "meta"], + [8, "shift"], + ] as const + ) + .filter(([mask]) => keyDown.modifiers & mask) + .map(([, modifier]) => modifier); + const shared = { + keyCode: keyDown.key.startsWith("Arrow") ? keyDown.key.slice(5) : keyDown.key, + modifiers, + skipIfUnhandled: true as const, + }; + // Electron lowercases unshifted letters and reports no key for Unicode accelerators. + const key = + keyDown.windowsVirtualKeyCode === 0 && keyDown.key.length === 1 + ? "" + : /^[A-Z]$/.test(keyDown.key) && !modifiers.includes("shift") + ? keyDown.key.toLowerCase() + : keyDown.key; + return { + keyDown: { type: "keyDown" as const, ...shared }, + ...(keyDown.text ? { char: { type: "char" as const, ...shared, keyCode: keyDown.text } } : {}), + keyUp: { type: "keyUp" as const, ...shared }, + ...(keyDown.commands ? { commands: keyDown.commands } : {}), + signal: { ...signal, key }, + }; +} + +/** Keep macOS editing shortcuts inside the target page without native focus. */ +export function previewAutomationEditingCommandExpression( + input: PreviewAutomationPressInput, + sequence: ReturnType, + clipboardData: ReadonlyArray<{ readonly type: string; readonly data: string }> = [], +): string { + const definition = resolveKeyDefinition(input); + const event = { + key: definition.key, + code: definition.code, + keyCode: definition.keyCode, + which: definition.keyCode, + location: definition.location ?? 0, + altKey: input.modifiers?.includes("Alt") ?? false, + ctrlKey: input.modifiers?.includes("Control") ?? false, + metaKey: input.modifiers?.includes("Meta") ?? false, + shiftKey: input.modifiers?.includes("Shift") ?? false, + bubbles: true, + cancelable: true, + composed: true, + }; + return `(() => { + let element = document.activeElement; + while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement; + if (!element) return; + const event = ${JSON.stringify(event)}; + try { + if (!element.dispatchEvent(new KeyboardEvent("keydown", event))) return; + for (const command of ${JSON.stringify(sequence.commands ?? [])}) { + // Main-process clipboard reads also work on insecure HTTP previews. + // Let the page's paste handler consume the clipboard MIME formats. + if (command === "paste") { + const transfer = new DataTransfer(); + for (const { type, data } of ${JSON.stringify(clipboardData)}) { + if (type === "text/html") { + // Match native paste sanitization before page handlers or insertion. + const container = document.createElement("div"); + container.setHTML(data); + transfer.setData(type, container.innerHTML); + } else if (type.startsWith("text/")) transfer.setData(type, data); + else { + const bytes = Uint8Array.from(atob(data), character => character.charCodeAt(0)); + transfer.items.add(new File([bytes], "clipboard", { type })); + } + } + if (!element.dispatchEvent(new ClipboardEvent("paste", { + clipboardData: transfer, bubbles: true, cancelable: true, composed: true, + }))) continue; + const text = transfer.getData("text/plain"); + if (!element.dispatchEvent(new InputEvent("beforeinput", { + inputType: "insertFromPaste", data: text, dataTransfer: transfer, + bubbles: true, cancelable: true, composed: true, + }))) continue; + const html = element.isContentEditable ? transfer.getData("text/html") : ""; + document.execCommand(html ? "insertHTML" : "insertText", false, html || text); + continue; + } + const inputType = command === "deleteToBeginningOfLine" ? "deleteSoftLineBackward" + : command === "undo" ? "historyUndo" + : command === "redo" ? "historyRedo" : null; + // execCommand emits input without beforeinput. Let controlled editors + // perform the edit before applying the browser's default operation. + if (inputType && !element.dispatchEvent(new InputEvent("beforeinput", { + inputType, bubbles: true, cancelable: true, composed: true, + }))) continue; + const selection = document.getSelection(); + if (command === "deleteToBeginningOfLine") { + const collapsed = typeof element.selectionStart === "number" + ? element.selectionStart === element.selectionEnd + : selection?.isCollapsed; + if (collapsed) selection?.modify("extend", "backward", "lineboundary"); + document.execCommand("delete"); + } else if (command.startsWith("moveTo")) { + const selectionElement = selection?.anchorNode?.nodeType === Node.ELEMENT_NODE + ? selection.anchorNode : selection?.anchorNode?.parentElement; + const editable = element.isContentEditable || selectionElement?.isContentEditable || + (((element instanceof HTMLInputElement && element.selectionStart !== null) || + element instanceof HTMLTextAreaElement) && + !element.readOnly && !element.disabled); + if (!editable && (command === "moveToBeginningOfDocument" || command === "moveToEndOfDocument")) { + let scrollable = element === document.body ? selectionElement ?? element : element; + while (scrollable && !(scrollable.scrollHeight > scrollable.clientHeight && + /^(auto|scroll|overlay)$/.test(getComputedStyle(scrollable).overflowY))) { + scrollable = scrollable.parentElement ?? scrollable.getRootNode().host; + } + scrollable ??= document.scrollingElement; + if (scrollable) scrollable.scrollTop = command === "moveToBeginningOfDocument" + ? 0 : scrollable.scrollHeight; + continue; + } + const direction = command.includes("Beginning") ? "backward" + : command.includes("Left") ? "left" + : command.includes("Right") ? "right" : "forward"; + selection?.modify( + command.endsWith("AndModifySelection") ? "extend" : "move", + direction, + command.includes("Document") ? "documentboundary" : "lineboundary", + ); + if (element instanceof HTMLInputElement && element.selectionStart !== null) { + if (command.includes("Left") || command.includes("Beginning")) element.scrollLeft = 0; + else element.scrollLeft = element.scrollWidth; + } + // Programmatic selection changes do not reveal the caret like native editing commands. + if (editable && command.includes("Document")) { + const beginning = command.includes("Beginning"); + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + element.scrollTop = beginning ? 0 : element.scrollHeight; + } else { + const caretElement = selection?.focusNode?.nodeType === Node.ELEMENT_NODE + ? selection.focusNode : selection?.focusNode?.parentElement; + caretElement?.scrollIntoView({ block: beginning ? "start" : "end", inline: "nearest" }); + } + } + } else { + document.execCommand(command); + } + } + } finally { + element.dispatchEvent(new KeyboardEvent("keyup", event)); + } + })()`; +} diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 7b25a2d8e942..a345ef3f9a3b 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -1,3 +1,4 @@ +import * as MacPermissions from "../permissions/MacPermissions.ts"; import { assert, it } from "@effect/vitest"; import { DEFAULT_CLIENT_SETTINGS, @@ -488,41 +489,45 @@ const testLayer = ( DesktopClientSettings.DesktopClientSettingsReadError > = Effect.succeed(initialSettings), ) => - Layer.mergeAll( - Layer.succeed( - DesktopEnvironment.DesktopEnvironment, - DesktopEnvironment.DesktopEnvironment.of({ - platform, - stateDir: "/state", - linuxDesktopEntryName: "com.t3tools.T3Code.desktop", - appRoot: "/repo", - linuxApplicationsDir: "/test-data/applications", - } as DesktopEnvironment.DesktopEnvironment["Service"]), - ), - Layer.succeed( - DesktopClientSettings.DesktopClientSettings, - DesktopClientSettings.DesktopClientSettings.of({ - get: settingsGet, - set: () => Effect.void, - }), - ), - Layer.succeed( - DesktopWindow.DesktopWindow, - DesktopWindow.DesktopWindow.of({ - activate: Effect.void, - prepareCaptureReveal: Effect.sync(prepareCaptureRevealMock), - dispatchMenuAction: () => Effect.void, - dispatchSnapShotEvent: () => Effect.void, - } as unknown as DesktopWindow.DesktopWindow["Service"]), - ), - FileSystem.layerNoop(fileSystemOverrides), - Path.layer, - Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size), - digest: (_algorithm, data) => Effect.succeed(data), - }), + MacPermissions.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of({ + platform, + stateDir: "/state", + linuxDesktopEntryName: "com.t3tools.T3Code.desktop", + appRoot: "/repo", + linuxApplicationsDir: "/test-data/applications", + } as DesktopEnvironment.DesktopEnvironment["Service"]), + ), + Layer.succeed( + DesktopClientSettings.DesktopClientSettings, + DesktopClientSettings.DesktopClientSettings.of({ + get: settingsGet, + set: () => Effect.void, + }), + ), + Layer.succeed( + DesktopWindow.DesktopWindow, + DesktopWindow.DesktopWindow.of({ + activate: Effect.void, + prepareCaptureReveal: Effect.sync(prepareCaptureRevealMock), + dispatchMenuAction: () => Effect.void, + dispatchSnapShotEvent: () => Effect.void, + } as unknown as DesktopWindow.DesktopWindow["Service"]), + ), + FileSystem.layerNoop(fileSystemOverrides), + Path.layer, + Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: (_algorithm, data) => Effect.succeed(data), + }), + ), + ), ), ); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index e1d51397ed91..d7634f980e25 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -76,6 +76,8 @@ import { type AccessibilityProcessPool, makeSnapShotAccessibilityProcessPool, } from "./SnapShotAccessibilityProcess.ts"; +import * as MacPermissions from "../permissions/MacPermissions.ts"; +import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts"; import { showWindowsCaptureOverlay } from "./WindowsCaptureFeedback.ts"; import { @@ -96,8 +98,7 @@ const FLASH_ANIMATION_DURATION_MS = 180; const FLASH_STATIC_DURATION_MS = 60; const FLASH_FRAME_INTERVAL_MS = 16; const FLASH_PEAK_OPACITY = 0.08; -const MAC_SCREEN_CAPTURE_SETTINGS_URL = - "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"; +const MAC_SCREEN_CAPTURE_SETTINGS_URL = MAC_PERMISSION_SETTINGS_URLS["screen-recording"]; const MAC_SCREEN_CAPTURE_PERMISSION_MESSAGE = "Allow Screen Recording in System Settings, then restart T3 Code."; const MAC_ACCESSIBILITY_PERMISSION_MESSAGE = @@ -704,6 +705,7 @@ function probeGlobalShortcut(accelerator: string): DesktopSnapShotShortcutAvaila export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const permissions = yield* MacPermissions.MacPermissions; const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const desktopWindow = yield* DesktopWindow.DesktopWindow; const fileSystem = yield* FileSystem.FileSystem; @@ -1300,14 +1302,26 @@ export const make = Effect.gen(function* () { yield* configurationMutex.withPermits(1)(applySettings(settings, null)); }); - const requestPermissions = (includeAccessibility: boolean) => - configurationMutex.withPermits(1)( - environment.platform === "darwin" - ? Effect.promise(() => requestMacSnapShotPermissions(includeAccessibility)).pipe( - Effect.asVoid, - ) - : Effect.void, - ); + const requestPermissions = Effect.fn("desktop.snapShot.requestPermissions")(function* ( + includeAccessibility: boolean, + ) { + if (environment.platform !== "darwin") return; + const owner = Electron.BrowserWindow.getFocusedWindow(); + yield* Effect.promise(() => requestMacSnapShotPermissions(includeAccessibility)); + if (Electron.systemPreferences.getMediaAccessStatus("screen") !== "granted") { + yield* permissions.showHelper("screen-recording", owner); + } else if ( + includeAccessibility && + !Electron.systemPreferences.isTrustedAccessibilityClient(false) + ) { + yield* Effect.promise(() => + Electron.shell + .openExternal(MAC_PERMISSION_SETTINGS_URLS.accessibility) + .catch(() => undefined), + ); + yield* permissions.showHelper("accessibility", owner); + } + }, configurationMutex.withPermits(1)); const setup = Effect.fn("desktop.snapShot.setup")(function* (action: DesktopSnapShotSetupAction) { if (action === "test-mac-capture") { @@ -1372,9 +1386,21 @@ export const make = Effect.gen(function* () { action, reason: "unsupported-session", }); - if (action === "allow-accessibility") - Electron.systemPreferences.isTrustedAccessibilityClient(true); - else yield* Effect.promise(requestMacScreenCapturePermission); + const owner = Electron.BrowserWindow.getFocusedWindow(); + if (action === "allow-accessibility") { + const granted = Electron.systemPreferences.isTrustedAccessibilityClient(true); + if (!granted && environment.isPackaged) { + yield* Effect.promise(() => + Electron.shell + .openExternal(MAC_PERMISSION_SETTINGS_URLS.accessibility) + .catch(() => undefined), + ); + } + } else yield* Effect.promise(requestMacScreenCapturePermission); + yield* permissions.showHelper( + action === "allow-accessibility" ? "accessibility" : "screen-recording", + owner, + ); } else if (action !== "retry-shortcut") { if (!hasGnomeSetup()) return yield* new DesktopSnapShotSetupError({ diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index 58809d8eb14d..47f3ec5298ec 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -178,14 +178,35 @@ describe("makeQuitShortcutHandler", () => { await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); harness.preventDefault.mockClear(); - await harness.send(makeInput({ meta: false, isAutoRepeat: true })); - expect(harness.preventDefault).toHaveBeenCalledTimes(1); - vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS * 2); + // Repeats without the modifier prove Q is still down, so they hold the + // quit back for as long as they keep arriving. + await harness.holdFor(QUIT_HOLD_RELEASE_GRACE_MS * 2, { meta: false }); + expect(harness.preventDefault).toHaveBeenCalled(); expect(harness.quit).not.toHaveBeenCalled(); await harness.send(makeInput({ type: "keyUp", meta: false })); expect(harness.quit).toHaveBeenCalledTimes(1); }); + it("commits a concealed hold when the last Q repeat is never released", async () => { + // macOS can drop the final Q keyUp. The quit must land on its own once + // repeats stop, rather than sitting armed until an unrelated key arrives. + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); + await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); + await harness.send(makeInput({ meta: false, isAutoRepeat: true })); + + vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); + expect(harness.quit).toHaveBeenCalledTimes(1); + + // A lone Cmd tap afterwards must not quit a second time. + harness.quit.mockClear(); + await harness.send(makeInput({ key: "Meta" })); + await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); + vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS * 4); + expect(harness.quit).not.toHaveBeenCalled(); + }); + it("does not quit when the hold stops before the duration", async () => { const harness = makeHarness(); await harness.send(makeInput({})); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index a995184ddd70..f656183f1375 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -125,13 +125,9 @@ export function makeQuitShortcutHandler( } if (quitOnRelease) { event.preventDefault(); - if (key === "q") { - if (modifierDown) { - quitAfterQuietPeriod(); - } else { - clearWatchdog(); - } - } + // A Q keydown proves the key is still down whether or not the modifier + // is still held, so it only pushes the quiet period back. + if (key === "q") quitAfterQuietPeriod(); return; } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 7595cef32845..ad12326a6946 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -96,6 +96,15 @@ export default defineConfig({ outExtensions: () => ({ js: ".cjs" }), entry: ["src/preview-pip-preload.ts"], }, + { + // Sandboxed preloads must be self-contained, without shared runtime chunks. + format: "cjs", + outDir: "dist-electron", + dts: false, + sourcemap: true, + outExtensions: () => ({ js: ".cjs" }), + entry: ["src/mac-permission-preload.ts"], + }, ], test: { // The Windows lane runs workspace suites concurrently; filesystem-heavy diff --git a/apps/mobile/modules/t3-markdown-text/android/build.gradle b/apps/mobile/modules/t3-markdown-text/android/build.gradle index 13584a00be42..5b7e372006f3 100644 --- a/apps/mobile/modules/t3-markdown-text/android/build.gradle +++ b/apps/mobile/modules/t3-markdown-text/android/build.gradle @@ -8,6 +8,10 @@ android { namespace 'expo.modules.t3markdowntext' compileSdk rootProject.ext.compileSdkVersion + testOptions { + unitTests.includeAndroidResources = true + } + defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion @@ -17,4 +21,12 @@ android { dependencies { implementation project(':expo-modules-core') implementation 'com.facebook.react:react-android' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} + +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } } diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt index 8e9fa3894aa9..63a4d59f93fb 100644 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt +++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt @@ -28,19 +28,23 @@ private object MarkdownSpannableFactory : Spannable.Factory() { SpannableStringBuilder(source) } -private fun copyTextWithoutInlineImages( +internal fun copyTextWithoutInlineImages( text: CharSequence, start: Int, end: Int ): String { if (text !is Spanned) return text.subSequence(start, end).toString() + fun isInlineImage(index: Int): Boolean = + index >= 0 && text[index].toString() == OBJECT_REPLACEMENT_CHARACTER && + text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty() + return buildString { for (index in start until end) { - val isInlineImage = - text[index].toString() == OBJECT_REPLACEMENT_CHARACTER && - text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty() - if (!isInlineImage) append(text[index]) + // The renderer inserts one NBSP after each image to keep its label on the same line. + // Inspect the original text even when selection starts after the image. + val isIconSpacer = text[index] == '\u00A0' && isInlineImage(index - 1) + if (!isInlineImage(index) && !isIconSpacer) append(text[index]) } } } diff --git a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt new file mode 100644 index 000000000000..da9012ee1665 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt @@ -0,0 +1,48 @@ +package expo.modules.t3markdowntext + +import android.graphics.drawable.ColorDrawable +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ImageSpan +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +class MarkdownSelectionCopyTest { + private fun withIcon(value: String): SpannableString = SpannableString(value).apply { + val index = value.indexOf('\uFFFC') + setSpan(ImageSpan(ColorDrawable()), index, index + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + @Test + fun removesIconAndInjectedSpacer() { + val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.") + assertEquals("main.go:12 starts the server.", copyTextWithoutInlineImages(text, 0, text.length)) + } + + @Test + fun removesSpacerWhenSelectionStartsAfterIcon() { + val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.") + assertEquals("main.go:12", copyTextWithoutInlineImages(text, 1, 12)) + } + + @Test + fun preservesAuthoredWhitespaceAndLiteralObjectCharacters() { + val text = withIcon("before\u00A0 \uFFFC\u00A0\u00A0 main.go after\u00A0\uFFFC\u00A0") + assertEquals( + "before\u00A0 \u00A0 main.go after\u00A0\uFFFC\u00A0", + copyTextWithoutInlineImages(text, 0, text.length) + ) + } + + @Test + fun preservesTextWithoutImageSpans() { + val text = "\uFFFC\u00A0main.go" + assertEquals(text, copyTextWithoutInlineImages(text, 0, text.length)) + assertEquals(text, copyTextWithoutInlineImages(SpannableString(text), 0, text.length)) + } +} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx index a5c6cf540f1c..f0686bc574dc 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx @@ -222,6 +222,12 @@ export function NativeMarkdownSelectableText(props: { } } + // Android renders the icon as an inline Image before the text. A regular space + // lets the line break between them, stranding the icon on the previous line. + if (Platform.OS === "android" && (run.fileIcon || linkIcon)) { + text = `\u00A0${text}`; + } + return { key: `${signature}:${occurrence}`, run, text, linkIcon }; }); // T3MarkdownText only rebuilds its attributed string during native layout. A diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index d0687e7e7b66..58fc8f1e3fa3 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -467,7 +467,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; const startFromOrigin = draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin; - const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const defaultRuntimeMode = editingPendingTask + ? (editingPendingTask.runtimeMode ?? DEFAULT_RUNTIME_MODE) + : projectSettings.settings.defaultRuntimeMode; + const runtimeMode = selectedProjectDraft.runtimeMode ?? defaultRuntimeMode; // Antigravity keeps unavailable selections so sign-out or a catalog change // cannot switch the user's model. Other providers retain their fallback @@ -978,7 +981,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { text, attachments: draft.attachments, modelSelection: draftModelSelection, - runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE, + runtimeMode: draft.runtimeMode ?? defaultRuntimeMode, interactionMode: resolvePendingTaskInteractionMode({ preferenceLoaded: planModePreferenceLoaded, planModeEnabled: legacyPlanModeEnabled, @@ -1014,6 +1017,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }; }, [ + defaultRuntimeMode, editingPendingProject, editingPendingTask, selectedEnvironmentServerConfig, diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index a577fc59ebc2..abe00f16bb81 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -888,7 +888,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git ); it.live( - "appends checkpoint.revert.failed activity when revert is requested without an active session", + "appends checkpoint.revert.failed activity when revert is requested without a provider binding", () => withHarness((harness) => Effect.gen(function* () { @@ -917,7 +917,7 @@ it.live( assert.equal( String( (failureActivity?.payload as { readonly detail?: string } | undefined)?.detail, - ).includes("No active provider session"), + ).includes("no persisted provider binding exists"), true, ); }), diff --git a/apps/server/src/claudeHistoryWorker.ts b/apps/server/src/claudeHistoryWorker.ts new file mode 100644 index 000000000000..d00282bb77f0 --- /dev/null +++ b/apps/server/src/claudeHistoryWorker.ts @@ -0,0 +1,25 @@ +import { forkSession, getSessionMessages } from "@anthropic-ai/claude-agent-sdk"; +import * as Schema from "effect/Schema"; + +// A separate process gives SDK history helpers the provider's environment without +// mutating the server's environment. This entry is bundled alongside the server. +const [method, sessionId, rawOptions] = process.argv.slice(2); +const options = Schema.decodeSync( + Schema.fromJsonString( + Schema.Struct({ + dir: Schema.optionalKey(Schema.String), + includeSystemMessages: Schema.optionalKey(Schema.Boolean), + upToMessageId: Schema.optionalKey(Schema.String), + }), + ), +)(rawOptions ?? "{}"); +if (!sessionId) throw new Error("Claude history session id is required."); +const result = + method === "getSessionMessages" + ? await getSessionMessages(sessionId, options) + : method === "forkSession" + ? await forkSession(sessionId, options) + : (() => { + throw new Error("Unknown Claude history operation."); + })(); +process.stdout.write(JSON.stringify(result)); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index b88f7f012d46..07fb9cdcc0cd 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -1683,86 +1683,132 @@ describe("CheckpointReactor", () => { }), ); - it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => { - const harness = await createHarness(); - const createdAt = "2026-01-01T00:00:00.000Z"; + it.each([ + { commandType: "thread.checkpoint.revert", initializeGit: true }, + { commandType: "thread.conversation.revert", initializeGit: true }, + { commandType: "thread.conversation.revert", initializeGit: false }, + ] as const)( + "$commandType rewinds history with the requested filesystem behavior (git: $initializeGit)", + async ({ commandType, initializeGit }) => { + const harness = await createHarness({ + initializeGit, + seedFilesystemCheckpoints: initializeGit, + }); + const createdAt = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-set"), - threadId: ThreadId.make("thread-1"), - session: { + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set"), threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.diff.complete", - commandId: CommandId.make("cmd-diff-1"), - threadId: ThreadId.make("thread-1"), - turnId: asTurnId("turn-1"), - completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), - status: "ready", - files: [], - checkpointTurnCount: 1, - createdAt, - }), - ); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.diff.complete", - commandId: CommandId.make("cmd-diff-2"), - threadId: ThreadId.make("thread-1"), - turnId: asTurnId("turn-2"), - completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), - status: "ready", - files: [], - checkpointTurnCount: 2, - createdAt, - }), - ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-diff-1"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-1"), + completedAt: createdAt, + checkpointRef: initializeGit + ? checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1) + : CheckpointRef.make("provider-diff:thread-1:turn-1"), + status: initializeGit ? "ready" : "missing", + files: [], + checkpointTurnCount: 1, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-diff-2"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-2"), + completedAt: createdAt, + checkpointRef: initializeGit + ? checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2) + : CheckpointRef.make("provider-diff:thread-1:turn-2"), + status: initializeGit ? "ready" : "missing", + files: [], + checkpointTurnCount: 2, + createdAt, + }), + ); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: CommandId.make("cmd-revert-request"), - threadId: ThreadId.make("thread-1"), - turnCount: 1, - createdAt, - }), - ); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "staged edit\n"); + if (initializeGit) { + NodeChildProcess.execFileSync("git", ["add", "README.md"], { cwd: harness.cwd }); + } + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "unstaged edit\n"); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "scratch.txt"), "untracked edit\n"); + const indexBefore = initializeGit + ? NodeChildProcess.execFileSync("git", ["ls-files", "--stage"], { + cwd: harness.cwd, + encoding: "utf8", + }) + : undefined; + + await Effect.runPromise( + harness.engine.dispatch({ + type: commandType, + commandId: CommandId.make("cmd-revert-request"), + threadId: ThreadId.make("thread-1"), + turnCount: 1, + createdAt, + }), + ); - await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); - const thread = await waitForThread( - harness.readModel, - (entry) => entry.checkpoints.length === 1, - ); + await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); + const thread = await waitForThread( + harness.readModel, + (entry) => entry.checkpoints.length === 1, + ); - expect(thread.latestTurn?.turnId).toBe("turn-1"); - expect(thread.checkpoints).toHaveLength(1); - expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); - expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); - expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ - threadId: ThreadId.make("thread-1"), - numTurns: 1, - }); - expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); - expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)), - ).toBe(false); - }); + expect(thread.latestTurn?.turnId).toBe("turn-1"); + expect(thread.checkpoints).toHaveLength(1); + expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); + expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); + expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ + threadId: ThreadId.make("thread-1"), + numTurns: 1, + }); + expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe( + commandType === "thread.conversation.revert" ? "unstaged edit\n" : "v2\n", + ); + if (commandType === "thread.conversation.revert") { + expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "scratch.txt"), "utf8")).toBe( + "untracked edit\n", + ); + if (initializeGit) { + expect( + NodeChildProcess.execFileSync("git", ["ls-files", "--stage"], { + cwd: harness.cwd, + encoding: "utf8", + }), + ).toBe(indexBefore); + } + } + if (initializeGit) { + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)), + ).toBe(false); + } else { + expect(NodeFS.existsSync(NodePath.join(harness.cwd, ".git"))).toBe(false); + } + }, + ); it("executes provider revert and emits thread.reverted for claude sessions", async () => { const harness = await createHarness({ providerName: ProviderDriverKind.make("claudeAgent") }); @@ -1916,27 +1962,45 @@ describe("CheckpointReactor", () => { }); }); - it("appends an error activity when revert is requested without an active session", async () => { - const harness = await createHarness({ hasSession: false }); - const createdAt = "2026-01-01T00:00:00.000Z"; - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: CommandId.make("cmd-revert-no-session"), - threadId: ThreadId.make("thread-1"), - turnCount: 1, - createdAt, - }), - ); + it.each([false, true])( + "reverts without an active session using project cwd fallback: %s", + async (useProjectCwd) => { + const harness = await createHarness({ + hasSession: false, + ...(useProjectCwd ? { threadWorktreePath: null } : {}), + }); + const createdAt = "2026-01-01T00:00:00.000Z"; - const thread = await waitForThread(harness.readModel, (entry) => - entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), - ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-diff-before-session-recovery"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-1"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), + status: "ready", + files: [], + checkpointTurnCount: 1, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.make("cmd-revert-no-session"), + threadId: ThreadId.make("thread-1"), + turnCount: 0, + createdAt, + }), + ); - expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe( - true, - ); - expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); - }); + await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); + expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ + threadId: ThreadId.make("thread-1"), + numTurns: 1, + }); + expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v1\n"); + }, + ); }); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index d4d6b9409808..9cfcc2e74915 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -699,25 +699,16 @@ const make = Effect.gen(function* () { return; } - const sessionRuntime = yield* resolveSessionRuntimeForThread(event.payload.threadId); - if (Option.isNone(sessionRuntime)) { - yield* appendRevertFailureActivity({ - threadId: event.payload.threadId, - turnCount: event.payload.turnCount, - detail: "No active provider session with workspace cwd is bound to this thread.", - createdAt: now, - }).pipe(Effect.catch(() => Effect.void)); - return; - } - if (!(yield* checkpointStore.isGitRepository(sessionRuntime.value.cwd))) { - yield* appendRevertFailureActivity({ - threadId: event.payload.threadId, - turnCount: event.payload.turnCount, - detail: "Checkpoints are unavailable because this project is not a git repository.", - createdAt: now, - }).pipe(Effect.catch(() => Effect.void)); - return; - } + const checkpointCwd = yield* resolveCheckpointCwd({ + threadId: event.payload.threadId, + thread, + projects: yield* resolveThreadProjects(thread.projectId), + preferSessionRuntime: true, + }).pipe( + Effect.catch((error) => + event.payload.restoreFiles === false ? Effect.succeed(undefined) : Effect.fail(error), + ), + ); const currentTurnCount = thread.checkpoints.reduce( (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), @@ -734,48 +725,60 @@ const make = Effect.gen(function* () { return; } - const targetCheckpointRef = - event.payload.turnCount === 0 - ? checkpointRefForThreadTurn(event.payload.threadId, 0) - : thread.checkpoints.find( - (checkpoint) => checkpoint.checkpointTurnCount === event.payload.turnCount, - )?.checkpointRef; + yield* providerService.assertConversationRollbackSupported(event.payload.threadId); - if (!targetCheckpointRef) { - yield* appendRevertFailureActivity({ - threadId: event.payload.threadId, - turnCount: event.payload.turnCount, - detail: `Checkpoint ref for turn ${event.payload.turnCount} is unavailable in read model.`, - createdAt: now, - }).pipe(Effect.catch(() => Effect.void)); - return; - } + if (event.payload.restoreFiles !== false) { + if (!checkpointCwd) { + yield* appendRevertFailureActivity({ + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + detail: "Checkpoint workspace is unavailable or is not a git repository.", + createdAt: now, + }).pipe(Effect.catch(() => Effect.void)); + return; + } - yield* providerService.assertConversationRollbackSupported(event.payload.threadId); + const targetCheckpointRef = + event.payload.turnCount === 0 + ? checkpointRefForThreadTurn(event.payload.threadId, 0) + : thread.checkpoints.find( + (checkpoint) => checkpoint.checkpointTurnCount === event.payload.turnCount, + )?.checkpointRef; + + if (!targetCheckpointRef) { + yield* appendRevertFailureActivity({ + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + detail: `Checkpoint ref for turn ${event.payload.turnCount} is unavailable in read model.`, + createdAt: now, + }).pipe(Effect.catch(() => Effect.void)); + return; + } - const restored = yield* checkpointStore.restoreCheckpoint({ - cwd: sessionRuntime.value.cwd, - checkpointRef: targetCheckpointRef, - fallbackToHead: event.payload.turnCount === 0, - }); - if (!restored) { - yield* appendRevertFailureActivity({ - threadId: event.payload.threadId, - turnCount: event.payload.turnCount, - detail: `Filesystem checkpoint is unavailable for turn ${event.payload.turnCount}.`, - createdAt: now, - }).pipe(Effect.catch(() => Effect.void)); - return; - } + const restored = yield* checkpointStore.restoreCheckpoint({ + cwd: checkpointCwd, + checkpointRef: targetCheckpointRef, + fallbackToHead: event.payload.turnCount === 0, + }); + if (!restored) { + yield* appendRevertFailureActivity({ + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + detail: `Filesystem checkpoint is unavailable for turn ${event.payload.turnCount}.`, + createdAt: now, + }).pipe(Effect.catch(() => Effect.void)); + return; + } - // Refresh the workspace entry index so the @-mention file picker - // reflects the reverted filesystem state. - yield* workspaceEntries.refresh(sessionRuntime.value.cwd); + // Refresh the workspace entry index so the @-mention file picker + // reflects the reverted filesystem state. + yield* workspaceEntries.refresh(checkpointCwd); + } const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); if (rolledBackTurns > 0) { yield* providerService.rollbackConversation({ - threadId: sessionRuntime.value.threadId, + threadId: event.payload.threadId, numTurns: rolledBackTurns, }); } @@ -787,9 +790,9 @@ const make = Effect.gen(function* () { } } - if (staleCheckpointRefs.length > 0) { + if (checkpointCwd && staleCheckpointRefs.length > 0) { yield* checkpointStore.deleteCheckpointRefs({ - cwd: sessionRuntime.value.cwd, + cwd: checkpointCwd, checkpointRefs: staleCheckpointRefs, }); } diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index d0ea8f5bef9c..096b1f6a49d9 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1653,6 +1653,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.conversation.revert": case "thread.checkpoint.revert": { yield* requireThread({ readModel, @@ -1670,6 +1671,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, turnCount: command.turnCount, + ...(command.type === "thread.conversation.revert" ? { restoreFiles: false } : {}), createdAt: command.createdAt, }, }; diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 071fb20674a8..22dd047f5c69 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -186,7 +186,6 @@ export const CodexDriver: ProviderDriver = { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }); - const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv); // Build a managed snapshot whose settings never change — mutations come // in as instance rebuilds from the registry rather than in-place @@ -242,6 +241,11 @@ export const CodexDriver: ProviderDriver = { }), ), ); + const textGeneration = yield* makeCodexTextGeneration( + effectiveConfig, + processEnv, + snapshot.getSnapshot.pipe(Effect.map((value) => value.models)), + ); const snapshotForCwd = (cwd: string) => !effectiveConfig.enabled ? snapshot.getSnapshot diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index c4136156df10..38bdb7f1f2e7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -170,8 +170,11 @@ function makeHarness(config?: { readonly instanceId?: ProviderInstanceId; readonly scopedLimitNames?: ClaudeAdapterLiveOptions["scopedLimitNames"]; readonly environment?: ClaudeAdapterLiveOptions["environment"]; + readonly getSessionMessages?: ClaudeAdapterLiveOptions["getSessionMessages"]; + readonly forkSession?: ClaudeAdapterLiveOptions["forkSession"]; }) { const query = new FakeClaudeQuery(); + const queries = [query]; let createInput: | { readonly prompt: AsyncIterable; @@ -184,9 +187,12 @@ function makeHarness(config?: { ...(config?.instanceId ? { instanceId: config.instanceId } : {}), ...(config?.scopedLimitNames ? { scopedLimitNames: config.scopedLimitNames } : {}), modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), + ...(config?.getSessionMessages ? { getSessionMessages: config.getSessionMessages } : {}), + ...(config?.forkSession ? { forkSession: config.forkSession } : {}), createQuery: (input) => { + if (createInput && config?.getSessionMessages) queries.push(new FakeClaudeQuery()); createInput = input; - return query; + return queries.at(-1)!; }, ...(config?.nativeEventLogger ? { @@ -218,6 +224,7 @@ function makeHarness(config?: { Layer.provideMerge(NodeServices.layer), ), query, + queries, getLastCreateQueryInput: () => createInput, }; } @@ -6256,87 +6263,226 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect( - "supports rollbackThread by trimming in-memory turns and preserving earlier turns", - () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - runtimeMode: "full-access", - }); + it.effect("rewinds a steered Claude turn after recovery and preserves fork boundaries", () => { + const forkCalls: Array>> = []; + let firstTurnId = ""; + let secondTurnId = ""; + let missingBoundary = false; + let legacyHistory = false; + const harness = makeHarness({ + forkSession: async (...args) => { + forkCalls.push(args); + return { sessionId: "550e8400-e29b-41d4-a716-446655440020" }; + }, + getSessionMessages: async (sessionId) => { + const history: Awaited< + ReturnType> + > = [ + { + type: "user", + uuid: firstTurnId, + session_id: "550e8400-e29b-41d4-a716-446655440010", + parent_tool_use_id: null, + parent_agent_id: null, + message: { content: "first" }, + }, + { + type: "assistant", + uuid: "assistant-1", + session_id: "550e8400-e29b-41d4-a716-446655440010", + parent_tool_use_id: null, + parent_agent_id: null, + message: { content: [] }, + }, + { + type: "user", + uuid: "tool-result-1", + session_id: "550e8400-e29b-41d4-a716-446655440010", + parent_tool_use_id: null, + parent_agent_id: null, + message: { content: [{ type: "tool_result" }] }, + }, + { + type: "assistant", + uuid: "assistant-1-final", + session_id: "550e8400-e29b-41d4-a716-446655440010", + parent_tool_use_id: null, + parent_agent_id: null, + message: { content: [] }, + }, + { + type: "user", + uuid: secondTurnId, + session_id: "550e8400-e29b-41d4-a716-446655440010", + parent_tool_use_id: null, + parent_agent_id: null, + message: { content: "second" }, + }, + { + type: "assistant", + uuid: "assistant-2", + session_id: "550e8400-e29b-41d4-a716-446655440010", + parent_tool_use_id: null, + parent_agent_id: null, + message: { content: [] }, + }, + { + type: "user", + uuid: "steer", + session_id: sessionId, + parent_tool_use_id: null, + parent_agent_id: null, + message: { content: "steer the second turn" }, + }, + { + type: "assistant", + uuid: "assistant-steer", + session_id: sessionId, + parent_tool_use_id: null, + parent_agent_id: null, + message: { content: [] }, + }, + ]; + return sessionId.endsWith("0020") + ? history.slice(0, 4).map((message) => ({ ...message, uuid: `fork-${message.uuid}` })) + : legacyHistory + ? history.slice(0, 6) + : missingBoundary + ? history.filter((message) => message.uuid !== secondTurnId) + : history; + }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; - const firstTurn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "first", - attachments: [], - }); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); - const firstCompletedFiber = yield* Stream.filter( - adapter.streamEvents, - (event) => event.type === "turn.completed", - ).pipe(Stream.runHead, Effect.forkChild); + const firstTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "first", + attachments: [], + }); + firstTurnId = firstTurn.turnId; - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - session_id: "sdk-session-rollback", - uuid: "result-first", - } as unknown as SDKMessage); + const firstCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); - const firstCompleted = yield* Fiber.join(firstCompletedFiber); - assert.equal(firstCompleted._tag, "Some"); - if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") { - assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId)); - } + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "550e8400-e29b-41d4-a716-446655440010", + uuid: "result-first", + } as unknown as SDKMessage); - const secondTurn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "second", - attachments: [], - }); + const firstCompleted = yield* Fiber.join(firstCompletedFiber); + assert.equal(firstCompleted._tag, "Some"); + if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") { + assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId)); + } - const secondCompletedFiber = yield* Stream.filter( - adapter.streamEvents, - (event) => event.type === "turn.completed", - ).pipe(Stream.runHead, Effect.forkChild); + const secondTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "second", + attachments: [], + }); + secondTurnId = secondTurn.turnId; + const steer = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "steer the second turn", + attachments: [], + }); + assert.equal(steer.turnId, secondTurn.turnId); - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - session_id: "sdk-session-rollback", - uuid: "result-second", - } as unknown as SDKMessage); + const secondCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); - const secondCompleted = yield* Fiber.join(secondCompletedFiber); - assert.equal(secondCompleted._tag, "Some"); - if (secondCompleted._tag === "Some" && secondCompleted.value.type === "turn.completed") { - assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId)); - } + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "550e8400-e29b-41d4-a716-446655440010", + uuid: "result-second", + } as unknown as SDKMessage); - const threadBeforeRollback = yield* adapter.readThread(session.threadId); - assert.equal(threadBeforeRollback.turns.length, 2); + const secondCompleted = yield* Fiber.join(secondCompletedFiber); + assert.equal(secondCompleted._tag, "Some"); + if (secondCompleted._tag === "Some" && secondCompleted.value.type === "turn.completed") { + assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId)); + } - const rolledBack = yield* adapter.rollbackThread(session.threadId, 1); - assert.equal(rolledBack.turns.length, 1); - assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId); + const threadBeforeRollback = yield* adapter.readThread(session.threadId); + assert.equal(threadBeforeRollback.turns.length, 2); + const cursor = (yield* adapter.listSessions())[0]?.resumeCursor; + yield* adapter.stopSession(session.threadId); + legacyHistory = true; + yield* adapter.startSession({ + threadId: session.threadId, + runtimeMode: "full-access", + resumeCursor: { + threadId: session.threadId, + resume: "550e8400-e29b-41d4-a716-446655440010", + turnCount: 1, + }, + }); + const legacyOptions = harness.getLastCreateQueryInput(); + const ambiguousLegacy = yield* adapter.rollbackThread(session.threadId, 1).pipe(Effect.flip); + assert.match(ambiguousLegacy.message, /exact Claude turn boundary is unavailable/); + assert.equal(forkCalls.length, 0); + assert.equal(harness.getLastCreateQueryInput(), legacyOptions); + assert.equal((yield* adapter.listSessions()).length, 1); + yield* adapter.stopSession(session.threadId); + legacyHistory = false; + yield* adapter.startSession({ + threadId: session.threadId, + runtimeMode: "full-access", + resumeCursor: cursor, + }); + missingBoundary = true; + const unavailable = yield* adapter.rollbackThread(session.threadId, 1).pipe(Effect.flip); + assert.match(unavailable.message, /exact Claude turn boundary is unavailable/); + assert.equal(forkCalls.length, 0); + missingBoundary = false; + + const recoveredQuery = harness.queries.at(-1)!; + assert.equal(recoveredQuery.closeCalls, 0); + yield* adapter.rollbackThread(session.threadId, 1); + assert.equal(recoveredQuery.closeCalls, 1); + const forkOptions = harness.getLastCreateQueryInput()?.options; + assert.deepEqual(forkCalls, [ + ["550e8400-e29b-41d4-a716-446655440010", { upToMessageId: "assistant-1-final" }], + ]); + assert.equal(forkOptions?.resume, "550e8400-e29b-41d4-a716-446655440020"); + assert.equal(forkOptions?.resumeSessionAt, undefined); + assert.equal(forkOptions?.forkSession, undefined); + assert.deepEqual((yield* adapter.listSessions())[0]?.resumeCursor, { + threadId: session.threadId, + resume: "550e8400-e29b-41d4-a716-446655440020", + turnCount: 1, + turnStartMessageIds: [`fork-${firstTurnId}`], + }); - const threadAfterRollback = yield* adapter.readThread(session.threadId); - assert.equal(threadAfterRollback.turns.length, 1); - assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }, - ); + yield* adapter.rollbackThread(session.threadId, 2); + const resetOptions = harness.getLastCreateQueryInput()?.options; + assert.equal(resetOptions?.resume, undefined); + assert.equal(resetOptions?.resumeSessionAt, undefined); + assert.equal(resetOptions?.forkSession, undefined); + assert.ok(resetOptions?.sessionId); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); it.effect("updates model on sendTurn when model override is provided", () => { const harness = makeHarness(); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 06fb20193153..c53f5f492d21 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -9,6 +9,8 @@ import { type CanUseTool, query, + getSessionMessages, + forkSession, type Options as ClaudeQueryOptions, type PermissionMode, type PermissionResult, @@ -78,6 +80,7 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -113,9 +116,26 @@ import { releaseThreadProcess, } from "../../resourceTelemetry/ThreadProcessRegistry.ts"; import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { spawnAndCollect } from "../providerSnapshot.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); +const encodeHistoryArgs = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeHistoryFork = Schema.decodeSync( + Schema.fromJsonString(Schema.Struct({ sessionId: Schema.String })), +); +const decodeSessionMessages = Schema.decodeSync( + Schema.fromJsonString( + Schema.Array( + Schema.Struct({ + type: Schema.Literals(["user", "assistant", "system"]), + uuid: Schema.String, + parent_tool_use_id: Schema.NullOr(Schema.String), + message: Schema.Unknown, + }), + ), + ), +); const PROVIDER = ProviderDriverKind.make("claudeAgent"); @@ -159,6 +179,7 @@ interface ClaudeResumeState { readonly resume?: string; readonly resumeSessionAt?: string; readonly turnCount?: number; + readonly turnStartMessageIds?: ReadonlyArray; } interface ClaudeTurnState { @@ -310,6 +331,8 @@ function rememberPendingTaskModel( interface ClaudeSessionContext { session: ProviderSession; + startInput: Parameters[0]; + readonly turnStartMessageIds: Array; readonly promptQueue: Queue.Queue; readonly query: ClaudeQueryRuntime; streamFiber: Fiber.Fiber | undefined; @@ -370,6 +393,8 @@ export interface ClaudeAdapterLiveOptions { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; }) => ClaudeQueryRuntime; + readonly getSessionMessages?: typeof getSessionMessages; + readonly forkSession?: typeof forkSession; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; readonly modelCatalog?: Effect.Effect; @@ -874,6 +899,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef sessionId?: unknown; resumeSessionAt?: unknown; turnCount?: unknown; + turnStartMessageIds?: unknown; }; const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined; @@ -891,11 +917,17 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef const resumeSessionAt = typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined; const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined; + const turnStartMessageIds = + Array.isArray(cursor.turnStartMessageIds) && + cursor.turnStartMessageIds.every((id: unknown) => id === null || typeof id === "string") + ? (cursor.turnStartMessageIds as Array) + : undefined; return { ...(threadId ? { threadId } : {}), ...(resume ? { resume } : {}), ...(resumeSessionAt ? { resumeSessionAt } : {}), + ...(turnStartMessageIds ? { turnStartMessageIds } : {}), ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0 ? { turnCount: turnCountValue } : {}), @@ -1947,6 +1979,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const path = yield* Path.Path; const serverConfig = yield* ServerConfig; const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( Effect.provideService(Path.Path, path), ); @@ -2063,7 +2096,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( threadId, ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}), ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}), - turnCount: context.turns.length, + turnCount: context.turnStartMessageIds.length, + turnStartMessageIds: [...context.turnStartMessageIds], }; context.session = { @@ -3178,6 +3212,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (!context.turnState) { const turnId = TurnId.make(yield* randomUUIDv4); const startedAt = yield* nowIso; + context.turnStartMessageIds.push(message.uuid); context.turnState = { turnId, startedAt, @@ -3200,6 +3235,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( activeTurnId: turnId, updatedAt: startedAt, }; + yield* updateResumeCursor(context); const turnStartedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "turn.started", @@ -4803,6 +4839,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(sessionId ? { resume: sessionId } : {}), ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), turnCount: resumeState?.turnCount ?? 0, + ...(resumeState?.turnStartMessageIds + ? { turnStartMessageIds: resumeState.turnStartMessageIds } + : {}), }, createdAt: startedAt, updatedAt: startedAt, @@ -4810,6 +4849,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const context: ClaudeSessionContext = { session, + startInput: input, + turnStartMessageIds: resumeState?.turnStartMessageIds + ? [...resumeState.turnStartMessageIds] + : Array.from({ length: resumeState?.turnCount ?? 0 }, () => null), promptQueue, query: queryRuntime, streamFiber: undefined, @@ -4924,6 +4967,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const modelSelection = selectedModel ? { ...selectedModel, model: resolveClaudeModelSlug(modelCatalog, selectedModel.model) } : undefined; + if (modelSelection) { + context.startInput = { ...context.startInput, modelSelection }; + } // A sendTurn while a real turn is running is a steer: the message is // queued into the live SDK agent loop and the work continues as the same @@ -5040,9 +5086,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ), }); + if (steeringTurnState === null) context.turnStartMessageIds.push(turnId); + yield* updateResumeCursor(context); yield* Queue.offer(context.promptQueue, { type: "message", - message, + message: + steeringTurnState === null + ? { ...message, uuid: turnId as NonNullable } + : message, }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause))); return { @@ -5074,10 +5125,190 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const rollbackThread: ClaudeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( function* (threadId, numTurns) { const context = yield* requireSession(threadId); - const nextLength = Math.max(0, context.turns.length - numTurns); - context.turns.splice(nextLength); - yield* updateResumeCursor(context); - return yield* snapshotThread(context); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + if ( + context.turnStartMessageIds.length > 0 && + context.turnStartMessageIds.every((id) => id !== null) && + numTurns >= context.turnStartMessageIds.length + ) { + yield* stopSessionInternal(context, { emitExitEvent: false }); + yield* startSession({ + ...context.startInput, + runtimeMode: context.session.runtimeMode, + resumeCursor: undefined, + }); + return yield* snapshotThread(yield* requireSession(threadId)); + } + const sessionId = context.resumeSessionId; + if (!sessionId) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Claude session id is unavailable.", + }); + } + const historyWorkerPath = yield* path + .fromFileUrl( + new URL( + import.meta.url.endsWith(".ts") + ? "../../claudeHistoryWorker.ts" + : "./claudeHistoryWorker.mjs", + import.meta.url, + ), + ) + .pipe(Effect.mapError((cause) => toRequestError(threadId, "thread/rollback", cause))); + const runScopedHistoryCommand = async ( + method: "getSessionMessages" | "forkSession", + args: object, + historySessionId = sessionId, + ) => { + // SDK history helpers read process.env. Isolate the provider's home instead + // of changing the server's environment while other providers are running. + const result = await Effect.runPromise( + spawnAndCollect( + process.execPath, + ChildProcess.make( + process.execPath, + [historyWorkerPath, method, historySessionId, encodeHistoryArgs(args)], + { env: { ...claudeEnvironment, ELECTRON_RUN_AS_NODE: "1" } }, + ), + ).pipe( + Effect.timeout("30 seconds"), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ); + if (result.code !== 0) throw new Error(result.stderr || "Claude history command failed."); + return result.stdout; + }; + const readHistory = (historySessionId: string) => + Effect.tryPromise({ + try: async () => { + const readOptions = { + ...(context.session.cwd ? { dir: context.session.cwd } : {}), + includeSystemMessages: true, + }; + if (options?.getSessionMessages) + return options.getSessionMessages(historySessionId, readOptions); + if (claudeEnvironment.CLAUDE_CONFIG_DIR === process.env.CLAUDE_CONFIG_DIR) { + return getSessionMessages(historySessionId, readOptions); + } + return decodeSessionMessages( + await runScopedHistoryCommand("getSessionMessages", readOptions, historySessionId), + ); + }, + catch: (cause) => toRequestError(threadId, "thread/rollback", cause), + }); + const messages = yield* readHistory(sessionId); + // Tool results are user-role messages too. Only human prompts begin a turn. + const turnStarts = messages.flatMap((message, index) => { + if (message.type !== "user" || message.parent_tool_use_id !== null) return []; + const body = message.message; + if (typeof body !== "object" || body === null || !("content" in body)) return []; + const content = body.content; + return typeof content === "string" || + (Array.isArray(content) && + content.some( + (part: unknown) => + typeof part === "object" && + part !== null && + "type" in part && + part.type !== "tool_result", + )) + ? [index] + : []; + }); + if (messages.length === 0) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Claude session history is unavailable.", + }); + } + const boundaries = [...context.turnStartMessageIds]; + // Older cursors did not record native boundaries. Infer them only when + // their T3 turn count agrees; steers must never be treated as extra turns. + if ( + boundaries.every((id): boolean => id === null) && + boundaries.length === turnStarts.length + ) { + boundaries.splice( + 0, + boundaries.length, + ...turnStarts.map((index) => messages[index]!.uuid), + ); + } + const retainedCount = Math.max(0, boundaries.length - numTurns); + const firstRemovedId = boundaries[retainedCount]; + const firstRemoved = messages.findIndex((message) => message.uuid === firstRemovedId); + if ( + boundaries.length === 0 || + boundaries.some((id) => id === null) || + (retainedCount > 0 && firstRemoved < 1) + ) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: + "The exact Claude turn boundary is unavailable, possibly after compaction or recovery of older history. Start a new thread instead.", + }); + } + const rollbackAt = retainedCount > 0 ? messages[firstRemoved - 1]?.uuid : undefined; + const retainedTurns = context.turns.slice(0, Math.max(0, context.turns.length - numTurns)); + const fork = rollbackAt + ? yield* Effect.tryPromise({ + try: async () => { + const forkOptions = { + ...(context.session.cwd ? { dir: context.session.cwd } : {}), + upToMessageId: rollbackAt, + }; + if (options?.forkSession) return options.forkSession(sessionId, forkOptions); + if (claudeEnvironment.CLAUDE_CONFIG_DIR === process.env.CLAUDE_CONFIG_DIR) { + return forkSession(sessionId, forkOptions); + } + return decodeHistoryFork(await runScopedHistoryCommand("forkSession", forkOptions)); + }, + catch: (cause) => toRequestError(threadId, "thread/rollback", cause), + }) + : undefined; + const retainedBoundaries = boundaries.slice(0, retainedCount); + if (fork) { + const forkMessages = yield* readHistory(fork.sessionId); + if (forkMessages.length !== firstRemoved) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Claude fork history did not preserve the retained turn boundaries.", + }); + } + // Native forks replace every UUID while preserving transcript order. + for (let index = 0; index < retainedBoundaries.length; index++) { + const messageIndex = messages.findIndex( + (message) => message.uuid === retainedBoundaries[index], + ); + retainedBoundaries[index] = forkMessages[messageIndex]?.uuid ?? null; + } + } + yield* stopSessionInternal(context, { emitExitEvent: false }); + yield* startSession({ + ...context.startInput, + runtimeMode: context.session.runtimeMode, + resumeCursor: fork + ? { + resume: fork.sessionId, + turnCount: retainedCount, + turnStartMessageIds: retainedBoundaries, + } + : undefined, + }); + const restarted = yield* requireSession(threadId); + restarted.turns.push(...retainedTurns); + return yield* snapshotThread(restarted); }, ); diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 2aeebdb2ccd8..0c7a40d9bd9e 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -127,6 +127,23 @@ it("prefers sol over terra when both are available", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol"); }); +it("ranks qualified Codex models while preserving their wire ids", () => { + const models = applyPreferredCodexDefaultModel([ + { + slug: "openai.gpt-5.6-luna", + name: "Luna", + isCustom: false, + isDefault: true, + capabilities: null, + }, + { slug: "openai.gpt-5.6-sol", name: "Sol", isCustom: false, capabilities: null }, + ]); + assert.deepStrictEqual( + models.filter((model) => model.isDefault).map((model) => model.slug), + ["openai.gpt-5.6-sol"], + ); +}); + it("keeps Codex's own default when no preferred model is available", () => { const models = applyPreferredCodexDefaultModel([ { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null }, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 1971913f1f98..a0e2b744c2a7 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -25,7 +25,11 @@ import type { } from "@t3tools/contracts"; import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; -import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model"; +import { + codexModelFamily, + createModelCapabilities, + readCustomModelEntries, +} from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; import { @@ -142,7 +146,8 @@ export function mapCodexModelCapabilities( model: CodexSchema.V2ModelListResponse__Model, ): ModelCapabilities { const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => - reasoningEffort === model.defaultReasoningEffort + reasoningEffort === + (codexModelFamily(model.model) === "gpt-6-astra" ? "medium" : model.defaultReasoningEffort) ? { id: reasoningEffort, label: reasoningEffortLabel(reasoningEffort), @@ -232,9 +237,9 @@ function parseCodexModelListResponse( export function applyPreferredCodexDefaultModel( models: ReadonlyArray, ): ReadonlyArray { - const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.find((slug) => - models.some((model) => model.slug === slug && !model.isCustom), - ); + const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.flatMap((slug) => + models.filter((model) => !model.isCustom && codexModelFamily(model.slug) === slug), + )[0]?.slug; if (!preferredSlug) { return models; } diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 3385137a2dae..c6257221c737 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -18,10 +18,101 @@ import { isRecoverableThreadResumeError, makeMemoryConsolidationNotificationFilter, openCodexThread, + readCodexThread, + rollbackCodexThread, toMcpElicitationResponse, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); +describe("Codex thread history", () => { + for (const numTurns of [1, 2, 3, 5]) { + it.effect(`reverts ${numTurns} paginated turns at the durable boundary`, () => + Effect.gen(function* () { + let retained = ["turn-1", "turn-2", "turn-3"]; + const client: Parameters[0] = { + request: () => Effect.die("Legacy history API must not be used for paginated threads"), + raw: { + request: (method, params) => + Effect.sync(() => { + if (method === "thread/read") return { thread: { historyMode: "paginated" } }; + if (method === "thread/turns/list") { + const { cursor } = params as { cursor: string | null }; + const start = cursor === null ? 0 : Number(cursor); + const ids = retained.slice(start, start + 2); + return { + data: ids.map((id) => ({ id, items: [], status: "completed" })), + nextCursor: start + 2 < retained.length ? String(start + 2) : null, + }; + } + NodeAssert.equal(method, "thread/revert"); + const { beforeTurnId } = params as { beforeTurnId: string }; + retained = retained.slice(0, retained.indexOf(beforeTurnId)); + return { thread: { id: "thread-1", turns: [] } }; + }), + }, + }; + const result = yield* rollbackCodexThread(client, "thread-1", numTurns); + const expected = ["turn-1", "turn-2", "turn-3"].slice(0, Math.max(0, 3 - numTurns)); + NodeAssert.deepEqual( + result.turns.map((turn) => turn.id), + expected, + ); + NodeAssert.deepEqual( + (yield* readCodexThread(client, "thread-1")).turns.map((turn) => turn.id), + expected, + ); + }), + ); + } + + for (const cursors of [ + ["next", "next"], + ["first", "second", "first"], + ]) { + it.effect(`rejects a pagination cursor cycle: ${cursors.join(", ")}`, () => + Effect.gen(function* () { + let pageCount = 0; + const client: Parameters[0] = { + request: () => Effect.die("Unexpected legacy request"), + raw: { + request: (method) => + Effect.sync(() => { + if (method === "thread/read") return { thread: { historyMode: "paginated" } }; + NodeAssert.ok(pageCount < cursors.length, "Repeated cursor was requested"); + return { data: [], nextCursor: cursors[pageCount++] }; + }), + }, + }; + const error = yield* Effect.flip(readCodexThread(client, "thread-1")); + NodeAssert.ok(isCodexAppServerRequestError(error)); + NodeAssert.equal(pageCount, cursors.length); + }), + ); + } + + it.effect("keeps the count-based rollback API for older threads", () => + Effect.gen(function* () { + const client: Parameters[0] = { + raw: { request: () => Effect.succeed({ thread: {} }) }, + request: ( + method: M, + params: CodexRpc.ClientRequestParamsByMethod[M], + ) => { + NodeAssert.equal(method, "thread/rollback"); + NodeAssert.deepEqual(params, { threadId: "legacy-thread", numTurns: 2 }); + return Effect.succeed({ + thread: { id: "legacy-thread", turns: [] }, + } as unknown as CodexRpc.ClientRequestResponsesByMethod[M]); + }, + }; + NodeAssert.deepEqual(yield* rollbackCodexThread(client, "legacy-thread", 2), { + threadId: "legacy-thread", + turns: [], + }); + }), + ); +}); + describe("CodexSessionRuntimeIdentifierGenerationError", () => { it("retains identifier purpose and the random source failure", () => { const cause = new Error("random source unavailable"); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 71db7a2fa0c7..4cab4c7608dd 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -1193,6 +1193,97 @@ function parseThreadSnapshot( }; } +const CodexThreadHistoryMetadata = Schema.Struct({ + thread: Schema.Struct({ + historyMode: Schema.optionalKey(Schema.Literals(["legacy", "paginated"])), + }), +}); +const CodexTurnsPage = Schema.Struct({ + data: Schema.Array(EffectCodexSchema.V2ThreadReadResponse__Turn), + nextCursor: Schema.NullOr(Schema.String), +}); +const decodeCodexHistoryMetadata = Schema.decodeUnknownEffect(CodexThreadHistoryMetadata); +const decodeCodexTurnsPage = Schema.decodeUnknownEffect(CodexTurnsPage); +type CodexHistoryClient = { + readonly raw: Pick; + readonly request: CodexClient.CodexAppServerClient["Service"]["request"]; +}; + +const readCodexHistoryMode = Effect.fn("readCodexHistoryMode")(function* ( + client: CodexHistoryClient, + threadId: string, +) { + const response = yield* client.raw.request("thread/read", { threadId, includeTurns: false }); + const metadata = yield* decodeCodexHistoryMetadata(response).pipe( + Effect.mapError((error) => + CodexErrors.CodexAppServerRequestError.invalidPayload("thread/read", "decode-payload", error), + ), + ); + return metadata.thread.historyMode; +}); + +export const readCodexThread = Effect.fn("readCodexThread")(function* ( + client: CodexHistoryClient, + threadId: string, +): Effect.fn.Return { + if ((yield* readCodexHistoryMode(client, threadId)) !== "paginated") { + return parseThreadSnapshot( + yield* client.request("thread/read", { threadId, includeTurns: true }), + ); + } + const turns: Array = []; + const requestedCursors = new Set(); + let cursor: string | null = null; + do { + if (requestedCursors.has(cursor)) { + return yield* CodexErrors.CodexAppServerRequestError.internalError( + "Thread history pagination repeated a cursor.", + undefined, + { method: "thread/turns/list", operation: "decode-payload" }, + ); + } + requestedCursors.add(cursor); + const response: unknown = yield* client.raw.request("thread/turns/list", { + threadId, + cursor, + limit: 100, + sortDirection: "asc", + itemsView: "full", + }); + const page = yield* decodeCodexTurnsPage(response).pipe( + Effect.mapError((error) => + CodexErrors.CodexAppServerRequestError.invalidPayload( + "thread/turns/list", + "decode-payload", + error, + ), + ), + ); + turns.push(...page.data.map((turn) => ({ id: TurnId.make(turn.id), items: turn.items }))); + cursor = page.nextCursor; + } while (cursor !== null); + return { threadId, turns }; +}); + +export const rollbackCodexThread = Effect.fn("rollbackCodexThread")(function* ( + client: CodexHistoryClient, + threadId: string, + numTurns: number, +): Effect.fn.Return { + if ((yield* readCodexHistoryMode(client, threadId)) !== "paginated") { + return parseThreadSnapshot(yield* client.request("thread/rollback", { threadId, numTurns })); + } + // Paginated threads replace history at a turn boundary instead of supporting + // the legacy count-based rollback endpoint. + const snapshot = yield* readCodexThread(client, threadId); + const retainedCount = Math.max(0, snapshot.turns.length - numTurns); + const firstRemoved = snapshot.turns[retainedCount]; + if (firstRemoved) { + yield* client.raw.request("thread/revert", { threadId, beforeTurnId: firstRemoved.id }); + } + return { threadId, turns: snapshot.turns.slice(0, retainedCount) }; +}); + export const makeCodexSessionRuntime = ( options: CodexSessionRuntimeOptions, ): Effect.Effect< @@ -2444,24 +2535,17 @@ export const makeCodexSessionRuntime = ( }), readThread: Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; - const response = yield* client.request("thread/read", { - threadId: providerThreadId, - includeTurns: true, - }); - return parseThreadSnapshot(response); + return yield* readCodexThread(client, providerThreadId); }), rollbackThread: (numTurns) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; - const response = yield* client.request("thread/rollback", { - threadId: providerThreadId, - numTurns, - }); + const snapshot = yield* rollbackCodexThread(client, providerThreadId, numTurns); yield* updateSession(sessionRef, { status: "ready", activeTurnId: undefined, }); - return parseThreadSnapshot(response); + return snapshot; }), uploadFeedback: (reason) => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 0c192ce7e114..bdc818994a9a 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -162,6 +162,28 @@ const cursorAdapterTestLayer = it.layer( ); cursorAdapterTestLayer("CursorAdapterLive", (it) => { + it.effect("rejects rollback without discarding the provider conversation", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-unsupported-rollback"); + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "Remember this turn", attachments: [] }); + const originalTurns = [...(yield* adapter.readThread(threadId)).turns]; + assert.isFalse(adapter.capabilities.supportsConversationRollback); + const error = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterRequestError"); + assert.deepStrictEqual((yield* adapter.readThread(threadId)).turns, originalTurns); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("rejects a Cursor transport error returned as a successful assistant answer", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index adf619166ff9..db952d8e150a 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1188,7 +1188,7 @@ export function makeCursorAdapter( const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) => Effect.gen(function* () { - const ctx = yield* requireSession(threadId); + yield* requireSession(threadId); if (!Number.isInteger(numTurns) || numTurns < 1) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, @@ -1196,9 +1196,11 @@ export function makeCursorAdapter( issue: "numTurns must be an integer >= 1.", }); } - const nextLength = Math.max(0, ctx.turns.length - numTurns); - ctx.turns.splice(nextLength); - return { threadId, turns: ctx.turns }; + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Cursor ACP sessions do not support provider-side rollback.", + }); }); const stopSession: CursorAdapterShape["stopSession"] = (threadId) => @@ -1236,7 +1238,7 @@ export function makeCursorAdapter( return { provider: PROVIDER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, compaction: { type: "slash-command", command: "/compress" }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index adda9f44d465..5b71c64244b8 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -492,6 +492,7 @@ describe("buildCursorProviderSnapshot", () => { status: "warning", message: "Cursor ACP model discovery timed out after 15000ms.", models: [], + supportsConversationRollback: false, }); }); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index cf4f00ac967a..7a18eef55e6b 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -55,6 +55,7 @@ const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect( ); const CURSOR_PRESENTATION = { displayName: "Cursor", + supportsConversationRollback: false, badgeLabel: "Early Access", showInteractionModeToggle: true, } as const; diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 41fa6ed0f60a..9efb28d80628 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -212,6 +212,26 @@ it("requires a settlement to match the live Grok turn", () => { }); it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { + it.effect("rejects rollback without discarding the provider conversation", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-unsupported-rollback"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "Remember this turn" }); + const originalTurns = [...(yield* adapter.readThread(threadId)).turns]; + assert.isFalse(adapter.capabilities.supportsConversationRollback); + const error = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterRequestError"); + assert.deepStrictEqual((yield* adapter.readThread(threadId)).turns, originalTurns); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("sends runtime context with the current model without changing saved prompts", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-runtime-context"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 5b8d91b859e4..0704fafd6355 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -2135,7 +2135,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return { provider: PROVIDER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, compaction: { type: "slash-command", command: "/compact" }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index c751919b189a..d0e5010bc2bf 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -282,6 +282,7 @@ describe("buildInitialGrokProviderSnapshot", () => { expect(snapshot.version).toBeNull(); expect(snapshot.message).toContain("Checking Grok"); expect(snapshot.requiresNewThreadForModelChange).toBeUndefined(); + expect(snapshot.supportsConversationRollback).toBe(false); }), ); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 493e46d44352..18a77334b9ac 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -44,6 +44,7 @@ import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; const GROK_PRESENTATION = { displayName: "Grok", + supportsConversationRollback: false, badgeLabel: "Early Access", showInteractionModeToggle: false, } as const; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index ee5767f9d356..a6636fcd69d0 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -97,6 +97,8 @@ const runtimeMock = { promptEchoEvents: [] as Array, closeError: null as Error | null, messages: [] as MessageEntry[], + forkMessagesBySession: new Map(), + forkPreservesBoundary: true, subscribedEvents: [] as Array>, eventSubscribeObserved: null as (() => void) | null, eventStreamError: null as ((cause: unknown) => void) | null, @@ -128,7 +130,7 @@ const runtimeMock = { permissionListImplementation: null as (() => Promise>) | null, questionListImplementation: null as (() => Promise>) | null, sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, - forkCalls: [] as Array<{ sessionID: string; directory?: string }>, + forkCalls: [] as Array<{ sessionID: string; directory?: string; messageID?: string }>, }, reset() { this.state.startCalls.length = 0; @@ -157,6 +159,8 @@ const runtimeMock = { this.state.promptEchoEvents.length = 0; this.state.closeError = null; this.state.messages = []; + this.state.forkMessagesBySession.clear(); + this.state.forkPreservesBoundary = true; this.state.subscribedEvents = []; this.state.eventSubscribeObserved = null; this.state.eventStreamError = null; @@ -264,7 +268,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { return { data: { id: sessionID, - ...(runtimeMock.state.revertMessageID + ...(runtimeMock.state.revertMessageID && + !runtimeMock.state.forkMessagesBySession.has(sessionID) ? { revert: { messageID: runtimeMock.state.revertMessageID } } : {}), ...(directory ? { directory } : {}), @@ -276,10 +281,37 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission }); return { data: { id: sessionID } }; }, - fork: async ({ sessionID, directory }: { sessionID: string; directory?: string }) => { + fork: async ({ + sessionID, + directory, + messageID, + }: { + sessionID: string; + directory?: string; + messageID?: string; + }) => { // Fork clones history into a new session bound to the directory. const forkedId = `${sessionID}_fork`; - runtimeMock.state.forkCalls.push({ sessionID, ...(directory ? { directory } : {}) }); + runtimeMock.state.forkCalls.push({ + sessionID, + ...(directory ? { directory } : {}), + ...(messageID ? { messageID } : {}), + }); + if (messageID) { + const messages = + runtimeMock.state.forkMessagesBySession.get(sessionID) ?? runtimeMock.state.messages; + const boundary = messages.findIndex((entry) => entry.info.id === messageID); + NodeAssert.notEqual(boundary, -1); + runtimeMock.state.forkMessagesBySession.set( + forkedId, + messages + .slice(0, runtimeMock.state.forkPreservesBoundary ? boundary : messages.length) + .map((entry) => ({ + ...entry, + info: { ...entry.info, id: `${entry.info.id}_fork` }, + })), + ); + } if (directory) { runtimeMock.state.sessionDirectoryById.set(forkedId, directory); } @@ -337,7 +369,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { typeof input.sessionID === "string" && typeof input.messageID === "string" ) { - runtimeMock.state.messages.push({ + const messages = + runtimeMock.state.forkMessagesBySession.get(input.sessionID) ?? + runtimeMock.state.messages; + messages.push({ info: { id: input.messageID, role: "user" }, parts: [], }); @@ -355,14 +390,19 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { runtimeMock.state.summarizeCalls.push(input); return { data: true }; }, - messages: async () => ({ data: runtimeMock.state.messages }), + messages: async ({ sessionID }: { sessionID: string }) => ({ + data: + runtimeMock.state.forkMessagesBySession.get(sessionID) ?? runtimeMock.state.messages, + }), message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => { runtimeMock.state.messageCalls.push({ sessionID, messageID }); if (runtimeMock.state.messageFailures > 0) { runtimeMock.state.messageFailures -= 1; throw new Error("message lookup failed", { cause: { status: 500 } }); } - const message = runtimeMock.state.messages.find((entry) => entry.info.id === messageID); + const messages = + runtimeMock.state.forkMessagesBySession.get(sessionID) ?? runtimeMock.state.messages; + const message = messages.find((entry) => entry.info.id === messageID); if (!message) { throw new Error(`Message not found: ${messageID}`, { cause: { status: 404, body: { name: "NotFoundError" } }, @@ -6333,7 +6373,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }).pipe(Effect.provide(adapterLayer)); }); - it.effect("reverts the first removed assistant message and returns only retained turns", () => + it.effect("forks before the removed user prompt and resumes only retained history", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; const threadId = asThreadId("thread-rollback-all"); @@ -6356,49 +6396,116 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }, ]; + const originalCursor = (yield* adapter.listSessions()).find( + (session) => session.threadId === threadId, + )?.resumeCursor; + runtimeMock.state.forkPreservesBoundary = false; + const boundaryError = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.flip); + NodeAssert.match(boundaryError.message, /did not preserve the requested rewind boundary/); + NodeAssert.deepEqual( + (yield* adapter.listSessions()).find((session) => session.threadId === threadId) + ?.resumeCursor, + originalCursor, + ); + runtimeMock.state.forkPreservesBoundary = true; + for (const numTurns of [0, 1, 2, 3]) { - runtimeMock.state.revertMessageID = undefined; - runtimeMock.state.revertCalls.length = 0; + yield* adapter.stopSession(threadId); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + runtimeMock.state.forkCalls.length = 0; const snapshot = yield* adapter.rollbackThread(threadId, numTurns); NodeAssert.deepEqual( - runtimeMock.state.revertCalls, + runtimeMock.state.forkCalls.map(({ sessionID, messageID }) => ({ sessionID, messageID })), numTurns === 0 ? [] : [ { sessionID: "http://127.0.0.1:9999/session", - messageID: numTurns === 1 ? "assistant-2" : "assistant-1", + messageID: numTurns === 1 ? "user-2" : "user-1", }, ], ); NodeAssert.deepEqual( snapshot.turns.map((turn) => turn.id), - ["assistant-1", "assistant-2"].slice(0, Math.max(0, 2 - numTurns)), + numTurns === 0 + ? ["assistant-1", "assistant-2"] + : ["assistant-1_fork"].slice(0, Math.max(0, 2 - numTurns)), ); + NodeAssert.deepEqual(runtimeMock.state.revertCalls, []); } - runtimeMock.state.revertMessageID = undefined; + yield* adapter.stopSession(threadId); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); for (const remaining of [1, 0]) { const snapshot = yield* adapter.rollbackThread(threadId, 1); NodeAssert.equal(snapshot.turns.length, remaining); NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, snapshot.turns); + const cursor = (yield* adapter.listSessions()).find( + (session) => session.threadId === threadId, + )?.resumeCursor; + NodeAssert.deepEqual(cursor, { + schemaVersion: 1, + sessionId: + remaining === 1 + ? "http://127.0.0.1:9999/session_fork" + : "http://127.0.0.1:9999/session_fork_fork", + }); + yield* adapter.stopSession(threadId); + yield* adapter.startSession({ threadId, runtimeMode: "full-access", resumeCursor: cursor }); + NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, snapshot.turns); } NodeAssert.deepEqual( - runtimeMock.state.revertCalls.slice(-2).map((call) => call.messageID), - ["assistant-2", "assistant-1"], + runtimeMock.state.forkCalls.slice(-2).map((call) => call.messageID), + ["user-2", "user-1_fork"], ); - runtimeMock.state.revertMessageID = undefined; + yield* adapter.sendTurn({ + threadId, + input: "continue the retained conversation", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "anthropic/claude-sonnet-4-5", + ), + }); + NodeAssert.equal( + (runtimeMock.state.promptCalls.at(-1) as { sessionID: string }).sessionID, + "http://127.0.0.1:9999/session_fork_fork", + ); + const continuation = runtimeMock.state.promptCalls.at(-1) as { + sessionID: string; + messageID: string; + }; + runtimeMock.state.forkMessagesBySession.get(continuation.sessionID)!.push({ + info: { id: "continuation-answer", role: "assistant" }, + parts: [{ id: "continuation-part", type: "text", text: "continued answer" }], + }); + const continuationCursor = (yield* adapter.listSessions()).find( + (session) => session.threadId === threadId, + )?.resumeCursor; + yield* adapter.stopSession(threadId); + yield* adapter.startSession({ + threadId, + runtimeMode: "full-access", + resumeCursor: continuationCursor, + }); + NodeAssert.deepEqual( + (yield* adapter.readThread(threadId)).turns.map((turn) => turn.id), + ["continuation-answer"], + ); + NodeAssert.deepEqual((yield* adapter.rollbackThread(threadId, 1)).turns, []); + NodeAssert.equal(runtimeMock.state.forkCalls.at(-1)?.messageID, continuation.messageID); + yield* adapter.stopSession(threadId); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); runtimeMock.state.messages = runtimeMock.state.messages.filter( (entry) => entry.info.id !== "user-2", ); const sharedUserSnapshot = yield* adapter.rollbackThread(threadId, 1); - NodeAssert.equal(runtimeMock.state.revertMessageID, "user-1"); + NodeAssert.equal(runtimeMock.state.forkCalls.at(-1)?.messageID, "user-1"); NodeAssert.deepEqual(sharedUserSnapshot.turns, []); NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, []); runtimeMock.state.messages = []; - runtimeMock.state.revertCalls.length = 0; + runtimeMock.state.forkCalls.length = 0; const emptySnapshot = yield* adapter.rollbackThread(threadId, 1); - NodeAssert.deepEqual(runtimeMock.state.revertCalls, []); + NodeAssert.deepEqual(runtimeMock.state.forkCalls, []); NodeAssert.deepEqual(emptySnapshot.turns, []); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 9d099313cfa2..c6c09272fff8 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -339,7 +339,7 @@ interface OpenCodeSessionContext { readonly client: OpencodeClient; readonly server: OpenCodeServerConnection; readonly directory: string; - readonly openCodeSessionId: string; + openCodeSessionId: string; readonly relatedSessionIds: Set; readonly resolvedRequestIds: Set; readonly autoRepliedRequestIds: Set; @@ -3821,14 +3821,89 @@ export function makeOpenCodeAdapter( const targetIndex = Math.max(0, snapshot.turns.length - numTurns); const target = snapshot.turns[targetIndex]; if (target) { - yield* runOpenCodeSdk("session.revert", () => - context.client.session.revert({ + const messages = yield* runOpenCodeSdk("session.messages", () => + context.client.session.messages({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.mapError(toRequestError)); + const entries = messages.data ?? []; + const targetMessageIndex = entries.findIndex((entry) => entry.info.id === target.id); + if (targetMessageIndex < 0) { + return yield* toRequestError( + new OpenCodeRuntimeError({ + operation: "session.fork", + detail: "The OpenCode rewind boundary is no longer available.", + }), + ); + } + const firstRemovedMessage = + entries + .slice(0, targetMessageIndex + 1) + .findLast((entry) => entry.info.role === "user") ?? entries[targetMessageIndex]!; + // Native revert also rewrites workspace files. Fork only the retained + // conversation so T3 alone decides whether filesystem changes survive. + const fork = yield* runOpenCodeSdk("session.fork", () => + context.client.session.fork({ sessionID: context.openCodeSessionId, - messageID: target.id, + messageID: firstRemovedMessage.info.id, + directory: context.directory, + }), + ).pipe(Effect.mapError(toRequestError)); + if (!fork.data) { + return yield* toRequestError( + new OpenCodeRuntimeError({ + operation: "session.fork", + detail: "OpenCode session.fork returned no session payload.", + }), + ); + } + const forkedSessionId = fork.data.id; + const forkMessages = yield* runOpenCodeSdk("session.messages", () => + context.client.session.messages({ sessionID: forkedSessionId }), + ).pipe(Effect.mapError(toRequestError)); + if (forkMessages.data?.length !== entries.indexOf(firstRemovedMessage)) { + return yield* toRequestError( + new OpenCodeRuntimeError({ + operation: "session.fork", + detail: "OpenCode did not preserve the requested rewind boundary.", + }), + ); + } + yield* runOpenCodeSdk("session.update", () => + context.client.session.update({ + sessionID: forkedSessionId, + permission: buildOpenCodePermissionRules(context.session.runtimeMode), }), ).pipe(Effect.mapError(toRequestError)); - // Native revert can move the boundary to the preceding user message. - return yield* readThread(threadId); + yield* clearPendingOpenCodeRequests(context, { type: "session.fork" }); + context.openCodeSessionId = forkedSessionId; + context.relatedSessionIds.clear(); + context.relatedSessionIds.add(forkedSessionId); + context.messageRoleById.clear(); + context.textPartsByMessageId.clear(); + context.turnTokenUsage = undefined; + context.activeTurnId = undefined; + context.interruptedTurnId = undefined; + context.reconcileIdleStatus = false; + context.awaitingBusyAfterInterruption = false; + context.pendingIdleReconciliation = undefined; + context.session = { + ...context.session, + resumeCursor: { schemaVersion: OPENCODE_RESUME_VERSION, sessionId: forkedSessionId }, + updatedAt: yield* nowIso, + }; + yield* emit({ + ...(yield* buildEventBase({ threadId })), + type: "thread.started", + payload: { providerThreadId: forkedSessionId }, + }); + return { + threadId, + turns: forkMessages.data + .filter((entry) => entry.info.role === "assistant") + .map((entry) => ({ + id: TurnId.make(entry.info.id), + items: [entry.info, ...entry.parts], + })), + }; } return snapshot; diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 17f49355a904..1d86e6d943a8 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -1654,6 +1654,9 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("routes provider operations and rollback conversation", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; + const modelSelection = createModelSelection(codexInstanceId, "gpt-5.6-sol", [ + { id: "reasoningEffort", value: "high" }, + ]); const session = yield* provider.startSession(asThreadId("thread-1"), { provider: ProviderDriverKind.make("codex"), @@ -1671,6 +1674,7 @@ routing.layer("ProviderServiceLive routing", (it) => { threadId: session.threadId, input: "hello", attachments: [], + modelSelection, }); assert.equal(routing.codex.sendTurn.mock.calls.length, 1); @@ -1708,6 +1712,21 @@ routing.layer("ProviderServiceLive routing", (it) => { numTurns: 0, }); + const rewindCursor = { threadId: "rewound-provider-thread" }; + routing.codex.updateSession(session.threadId, (session) => ({ + ...session, + resumeCursor: rewindCursor, + })); + yield* provider.rollbackConversation({ threadId: session.threadId, numTurns: 1 }); + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const rewoundBinding = yield* directory.getBinding(session.threadId); + assert(Option.isSome(rewoundBinding)); + assert.deepEqual(rewoundBinding.value.resumeCursor, rewindCursor); + assert.deepEqual( + (rewoundBinding.value.runtimePayload as { modelSelection?: unknown }).modelSelection, + modelSelection, + ); + yield* provider.stopSession({ threadId: session.threadId }); routing.codex.startSession.mockClear(); routing.codex.sendTurn.mockClear(); @@ -1727,16 +1746,93 @@ routing.layer("ProviderServiceLive routing", (it) => { cwd?: string; resumeCursor?: unknown; threadId?: string; + modelSelection?: unknown; }; assert.equal(startPayload.provider, "codex"); assert.equal(startPayload.cwd, fixtureCwd("project")); - assert.deepEqual(startPayload.resumeCursor, session.resumeCursor); + assert.deepEqual(startPayload.resumeCursor, rewindCursor); + assert.deepEqual(startPayload.modelSelection, modelSelection); assert.equal(startPayload.threadId, session.threadId); } assert.equal(routing.codex.sendTurn.mock.calls.length, 1); }), ); + it.effect("preserves background turn boundaries when stopping before rollback recovery", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-background-rewind"); + const initial = yield* provider.startSession(threadId, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId, + runtimeMode: "full-access", + }); + const cursor = { + resume: "550e8400-e29b-41d4-a716-446655440010", + turnCount: 2, + turnStartMessageIds: ["user-prompt", "background-assistant"], + }; + routing.claude.updateSession(threadId, (session) => ({ ...session, resumeCursor: cursor })); + const completed = yield* provider.streamEvents.pipe( + Stream.filter((event) => event.eventId === "evt-background-rewind"), + Stream.take(1), + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + routing.claude.emit({ + type: "turn.completed", + eventId: asEventId("evt-background-rewind"), + provider: CLAUDE_AGENT_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: asTurnId("background-turn"), + payload: { state: "completed" }, + }); + yield* Fiber.join(completed); + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const binding = yield* directory.getBinding(threadId); + assert(Option.isSome(binding)); + assert.deepEqual(binding.value.resumeCursor, cursor); + yield* provider.stopSession({ threadId }); + routing.claude.startSession.mockClear(); + yield* provider.rollbackConversation({ threadId, numTurns: 1 }); + assert.deepEqual(routing.claude.startSession.mock.calls[0]?.[0].resumeCursor, cursor); + + const replacement = yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + routing.claude.listSessions.mockReturnValueOnce( + Effect.succeed([{ ...initial, resumeCursor: cursor }]), + ); + const staleCompleted = yield* provider.streamEvents.pipe( + Stream.filter((event) => event.eventId === "evt-stale-background-rewind"), + Stream.take(1), + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + routing.claude.emit({ + type: "turn.completed", + eventId: asEventId("evt-stale-background-rewind"), + provider: CLAUDE_AGENT_DRIVER, + createdAt: "2026-01-01T00:00:01.000Z", + threadId, + turnId: asTurnId("old-background-turn"), + payload: { state: "completed" }, + }); + yield* Fiber.join(staleCompleted); + const replacementBinding = yield* directory.getBinding(threadId); + assert(Option.isSome(replacementBinding)); + assert.equal(replacementBinding.value.providerInstanceId, codexInstanceId); + assert.deepEqual(replacementBinding.value.resumeCursor, replacement.resumeCursor); + }), + ); + it.effect("marks a successful fallback compaction as compacted", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 7ffbb113d549..17ece3767792 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1100,6 +1100,35 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( canonicalEvent.type === "turn.aborted" ) { yield* recordTurnCompletedAnalytics(source, canonicalEvent); + if (source.provider === "claudeAgent") { + // Background Claude turns have no sendTurn response to persist their + // new native boundary. Save it before clients can checkpoint the turn. + yield* Effect.gen(function* () { + const adapter = yield* registry.getByInstance(source.instanceId); + const session = (yield* adapter.listSessions()).find( + (session) => session.threadId === canonicalEvent.threadId, + ); + if (session?.resumeCursor !== undefined) { + const binding = yield* directory.getBinding(session.threadId); + if ( + Option.isNone(binding) || + binding.value.providerInstanceId !== source.instanceId + ) { + return; + } + yield* directory.upsert({ + threadId: session.threadId, + provider: source.provider, + providerInstanceId: source.instanceId, + resumeCursor: session.resumeCursor, + }); + } + }).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to persist Claude turn resume state", { cause }), + ), + ); + } } else if (canonicalEvent.type === "session.exited") { yield* clearTurnAnalyticsSession(source.instanceId, canonicalEvent.threadId); } @@ -2006,6 +2035,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.thread_id": input.threadId, }); if (routed.isActive) { + const session = (yield* routed.adapter.listSessions()).find( + (session) => session.threadId === routed.threadId, + ); + if (session) { + yield* upsertSessionBinding( + { ...session, providerInstanceId: routed.instanceId }, + input.threadId, + ); + } yield* routed.adapter.stopSession(routed.threadId); } const pendingCompaction = pendingCompactions.get(input.threadId); @@ -2176,6 +2214,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.rollback_turns": input.numTurns, }); yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns); + const session = (yield* routed.adapter.listSessions()).find( + (session) => session.threadId === routed.threadId, + ); + if (session) { + yield* upsertSessionBinding( + { ...session, providerInstanceId: routed.instanceId }, + input.threadId, + ); + } yield* analytics.record("provider.conversation.rolled_back", { provider: routed.adapter.provider, turns: input.numTurns, diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index bb592a0a6fc8..0a2ca28feb73 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -38,6 +38,20 @@ const model = (overrides: Partial): ServerProviderModel => }); describe("classifyModels", () => { + it("classifies qualified Codex families without changing their wire ids", () => { + const manifest: ModelManifestData = { version: 1, currentModels: { codex: ["gpt-test"] } }; + const models = [ + model({ slug: "openai.gpt-test", isLegacy: true }), + model({ slug: "openai.gpt-old" }), + ]; + assert.deepStrictEqual( + classifyModels(models, manifest, CODEX).map((entry) => [entry.slug, entry.isLegacy ?? false]), + [ + ["openai.gpt-test", false], + ["openai.gpt-old", true], + ], + ); + }); it("flags non-current models, clears stale flags, and skips custom models", () => { const manifest: ModelManifestData = { version: 1, @@ -64,6 +78,21 @@ describe("classifyModels", () => { }); describe("applyManifestDefault", () => { + it("resolves the manifest default to the qualified live model", () => { + const manifest: ModelManifestData = { + version: 1, + currentModels: {}, + providers: { codex: { models: [], profiles: {}, defaults: { chat: "gpt-test" } } }, + }; + const models = [ + model({ slug: "openai.gpt-old", isDefault: true }), + model({ slug: "openai.gpt-test" }), + ]; + assert.strictEqual( + applyManifestDefault(models, manifest, CODEX).find((entry) => entry.isDefault)?.slug, + "openai.gpt-test", + ); + }); it("moves the default flag and its aliases to the manifest's chat default", () => { const driver = ProviderDriverKind.make("antigravity"); const manifest: ModelManifestData = { diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 1138eb6e08a6..d387c6a128e6 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -20,6 +20,7 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import { T3CODE_DISTRIBUTION_URLS } from "@t3tools/shared/distribution"; +import { codexModelFamily } from "@t3tools/shared/model"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -212,13 +213,15 @@ function isLegacyModel( driverKind: ProviderDriverKind, slug: string, ): boolean { - const catalogModel = manifest.providers?.[driverKind]?.models.find( - (model) => model.slug === slug, - ); + const family = driverKind === "codex" ? codexModelFamily(slug) : slug; + const catalog = manifest.providers?.[driverKind]?.models; + const catalogModel = + catalog?.find((model) => model.slug === slug) ?? + catalog?.find((model) => model.slug === family); if (catalogModel) return catalogModel.status === "legacy"; const currentModels = manifest.currentModels[driverKind]; if (!currentModels) return false; - return !currentModels.includes(slug); + return !currentModels.includes(slug) && !currentModels.includes(family); } /** @@ -260,8 +263,17 @@ export function applyManifestDefault( manifest: ModelManifestData, driverKind: ProviderDriverKind, ): ReadonlyArray { - const slug = manifestDefaultModel(manifest, driverKind); - if (slug === undefined || !models.some((model) => model.slug === slug)) return models; + const requestedSlug = manifestDefaultModel(manifest, driverKind); + if (requestedSlug === undefined) return models; + const slug = + models.find((model) => model.slug === requestedSlug)?.slug ?? + (driverKind === "codex" + ? models.find( + (model) => + !model.isCustom && codexModelFamily(model.slug) === codexModelFamily(requestedSlug), + )?.slug + : undefined); + if (slug === undefined) return models; const previous = models.find((model) => model.isDefault && model.slug !== slug); if (!previous) return models; const movedAliases = previous.aliases ?? []; diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index bca9f09eee8c..9ea0c0063eb5 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-09-04T19:10:48Z", + "updatedAt": "2026-09-12T00:41:55Z", "currentModels": { "codex": [ "gpt-6-astra", @@ -16,7 +16,7 @@ "providers": { "claudeAgent": { "defaults": { - "chat": "claude-sonnet-5" + "chat": "claude-fable-5-1" }, "profiles": { "fable-5": { @@ -33,12 +33,12 @@ }, { "id": "medium", - "label": "Medium" + "label": "Medium", + "isDefault": true }, { "id": "high", - "label": "High", - "isDefault": true + "label": "High" }, { "id": "xhigh", diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 26fc5b2d4031..afe0c36b6b01 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -66,6 +66,7 @@ export interface ServerProviderPresentation { readonly showInteractionModeToggle?: boolean; readonly reportsContextWindow?: boolean; readonly requiresNewThreadForModelChange?: boolean; + readonly supportsConversationRollback?: boolean; } export type ServerProviderDraft = Omit; @@ -210,6 +211,9 @@ export function buildServerProvider(input: { : undefined; return { displayName: input.presentation.displayName, + ...(typeof input.presentation.supportsConversationRollback === "boolean" + ? { supportsConversationRollback: input.presentation.supportsConversationRollback } + : {}), ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), ...(typeof input.presentation.showInteractionModeToggle === "boolean" ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 37dbd7394a00..60c93c2426cb 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -225,12 +225,37 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa }); it.effect.each([ - { existing: false, machineModel: null, projectModel: null }, - { existing: false, machineModel: "claude-sonnet-4-6", projectModel: null }, - { existing: true, machineModel: "claude-sonnet-4-6", projectModel: null }, - { existing: true, machineModel: "claude-sonnet-4-6", projectModel: "gpt-5.4" }, -])("auto-bootstrap model precedence: %j", ({ existing, machineModel, projectModel }) => + { + existing: false, + machineModel: null, + projectModel: null, + machineMode: "full-access", + projectMode: null, + }, + { + existing: false, + machineModel: "claude-sonnet-4-6", + projectModel: null, + machineMode: "approval-required", + projectMode: null, + }, + { + existing: true, + machineModel: "claude-sonnet-4-6", + projectModel: null, + machineMode: "auto", + projectMode: null, + }, + { + existing: true, + machineModel: "claude-sonnet-4-6", + projectModel: "gpt-5.4", + machineMode: "full-access", + projectMode: "auto-accept-edits", + }, +] as const)("auto-bootstrap model and permissions precedence: %j", (options) => Effect.gen(function* () { + const { existing, machineModel, projectModel, machineMode, projectMode } = options; const machineSelection = machineModel ? { instanceId: ProviderInstanceId.make("claude-code"), model: machineModel } : null; @@ -242,16 +267,21 @@ it.effect.each([ readonly type: string; readonly defaultModelSelection?: unknown; readonly modelSelection?: unknown; + readonly runtimeMode?: unknown; }> >([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( Effect.provide( ServerSettings.layerTest({ defaultModelSelection: machineSelection, + defaultRuntimeMode: machineMode, projectSettingsOverrides: existing && projectSelection ? { - [ProjectId.make("existing-project")]: { defaultModelSelection: projectSelection }, + [ProjectId.make("existing-project")]: { + defaultModelSelection: projectSelection, + ...(projectMode ? { defaultRuntimeMode: projectMode } : {}), + }, } : {}, }), @@ -321,6 +351,7 @@ it.effect.each([ existing ? ["thread.create"] : ["project.create", "thread.create"], ); if (!existing) assert.equal("defaultModelSelection" in commands[0]!, false); + assert.equal(commands.at(-1)?.runtimeMode, projectMode ?? machineMode); assert.deepStrictEqual( commands.at(-1)?.modelSelection, projectSelection ?? diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 90d6c3c576a0..ec820e2f0e6f 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -248,7 +248,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { title: "New thread", modelSelection: nextThreadModelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "full-access", + runtimeMode: resolveProjectSettings(settings, nextProjectId).settings + .defaultRuntimeMode, branch: null, worktreePath: null, createdAt, diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 0129136d5e8d..12a327d34452 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -134,6 +134,7 @@ function withFakeCodexEnv( input: FakeCodexInput & { launchArgs?: string; environment?: NodeJS.ProcessEnv; + models?: ReadonlyArray; }, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { @@ -142,12 +143,44 @@ function withFakeCodexEnv( const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); const codexPath = yield* makeFakeCodexBinary(tempDir, input); const config = decodeCodexSettings({ binaryPath: codexPath, launchArgs: input.launchArgs }); - const textGeneration = yield* makeCodexTextGeneration(config, input.environment); + const textGeneration = yield* makeCodexTextGeneration( + config, + input.environment, + Effect.succeed( + (input.models ?? []).map((slug) => ({ + slug, + name: slug, + isCustom: false, + capabilities: null, + })), + ), + ); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { + for (const selectedModel of ["gpt-5.6-luna", "openai.gpt-5.6-luna"]) { + it.effect(`dispatches the qualified live model for ${selectedModel}`, () => + withFakeCodexEnv( + { + output: JSON.stringify({ title: "Bedrock title" }), + models: ["openai.gpt-5.6-luna"], + requireArg: "--model openai.gpt-5.6-luna", + forbidArg: "--model gpt-5.6-luna", + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Describe this change", + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), selectedModel), + }); + expect(result.title).toBe("Bedrock title"); + }), + ), + ); + } it.effect("generates and sanitizes commit messages without branch by default", () => withFakeCodexEnv( { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 0b870ac1d679..10c16fc9cee5 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -11,6 +11,7 @@ import { type CodexSettings, DEFAULT_TEXT_GENERATION_REASONING_EFFORT, type ModelSelection, + type ServerProviderModel, TextGenerationError, } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; @@ -34,7 +35,7 @@ import { sanitizeThreadTitle, toJsonSchemaObject, } from "./TextGenerationUtils.ts"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { codexModelFamily, getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; const CODEX_TIMEOUT_MS = 180_000; @@ -46,6 +47,7 @@ const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknow export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( codexConfig: CodexSettings, environment?: NodeJS.ProcessEnv, + getModels: Effect.Effect> = Effect.succeed([]), ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -178,6 +180,14 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const outputPath = yield* writeTempFile(operation, "codex-output", ""); const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { + const models = yield* getModels; + const requestedModel = modelSelection.model; + const model = + models.find((candidate) => candidate.slug === requestedModel)?.slug ?? + models.find( + (candidate) => !candidate.isCustom && codexModelFamily(candidate.slug) === requestedModel, + )?.slug ?? + requestedModel; const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment); const reasoningEffort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? @@ -193,7 +203,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func "-s", "read-only", "--model", - modelSelection.model, + model, "--config", `model_reasoning_effort="${reasoningEffort}"`, ...(serviceTier ? ["--config", `service_tier="${serviceTier}"`] : []), diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 031055a3b6cb..5c7e2e1360af 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -7,7 +7,7 @@ import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import { assert, it } from "@effect/vitest"; -import { GitCommandError } from "@t3tools/contracts"; +import { CheckpointRef, GitCommandError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -65,6 +65,70 @@ runVcsDriverContractSuite({ }, }); +it.effect("restores empty checkpoints without changing paths outside the workspace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + for (const nested of [false, true]) { + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-empty-checkpoint-" }); + yield* runGit(root, ["init"]); + yield* runGit(root, ["config", "user.email", "test@test.com"]); + yield* runGit(root, ["config", "user.name", "Test"]); + if (nested) { + yield* fileSystem.writeFileString(path.join(root, "outside.txt"), "original\n"); + yield* runGit(root, ["add", "."]); + } + yield* runGit(root, ["commit", "--allow-empty", "-m", "initial"]); + const cwd = nested ? path.join(root, "nested") : root; + yield* fileSystem.makeDirectory(cwd, { recursive: true }); + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/empty"); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + if (nested) { + yield* fileSystem.writeFileString(path.join(root, "outside.txt"), "changed\n"); + yield* runGit(root, ["add", "outside.txt"]); + } + for (const staged of [false, true]) { + const addedPath = path.join(cwd, "added.txt"); + yield* fileSystem.writeFileString(addedPath, "new\n"); + if (staged) yield* runGit(cwd, ["add", "added.txt"]); + assert.isTrue( + yield* driver.checkpoints.restoreCheckpoint({ + cwd, + checkpointRef, + fallbackToHead: false, + }), + ); + assert.isFalse(yield* fileSystem.exists(addedPath)); + } + yield* fileSystem.writeFileString( + path.join(root, ".git", "info", "exclude"), + "ignored.txt\n", + ); + yield* fileSystem.writeFileString(path.join(cwd, "ignored.txt"), "keep\n"); + yield* fileSystem.makeDirectory(path.join(cwd, "untracked")); + yield* fileSystem.writeFileString(path.join(cwd, "untracked", "file.txt"), "remove\n"); + assert.isTrue( + yield* driver.checkpoints.restoreCheckpoint({ cwd, checkpointRef, fallbackToHead: false }), + ); + assert.strictEqual(yield* fileSystem.readFileString(path.join(cwd, "ignored.txt")), "keep\n"); + assert.isFalse(yield* fileSystem.exists(path.join(cwd, "untracked"))); + if (nested) { + assert.strictEqual( + yield* fileSystem.readFileString(path.join(root, "outside.txt")), + "changed\n", + ); + const staged = yield* driver.execute({ + operation: "test", + cwd: root, + args: ["diff", "--cached", "--name-only"], + }); + assert.strictEqual(staged.stdout.trim(), "outside.txt"); + } + } + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + it.effect("GitVcsDriver forwards execute env to the VCS process", () => { let observedEnv: NodeJS.ProcessEnv | undefined; let observedAppendTruncationMarker: boolean | undefined; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index f1e48a24d6fe..9b25e915973c 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -811,16 +811,56 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return false; } - yield* execute({ + const tracked = yield* execute({ operation, cwd: input.cwd, - args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], + args: ["ls-files", "--cached", `--with-tree=${commitOid}`, "-z", "--", "."], }); - yield* execute({ + // An empty index and checkpoint have nothing for git restore's pathspec to match. + if (tracked.stdout.length > 0) { + yield* execute({ + operation, + cwd: input.cwd, + args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], + }); + } + // Restoring away the last tracked file can remove a nested workspace directory. + yield* fileSystem.makeDirectory(input.cwd, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new VcsProcessExitError({ + operation, + command: "git restore", + cwd: input.cwd, + exitCode: 0, + detail: `Could not recreate the checkpoint workspace: ${cause.message}`, + }), + ), + ); + const cleaned = yield* execute({ operation, cwd: input.cwd, args: ["clean", "-fd", "--", "."], + allowNonZeroExit: true, }); + if (cleaned.exitCode !== 0) { + // Git can remove every child, then fail trying to remove './' itself. + const emptiedWorkspace = + cleaned.exitCode === 1 && + /^warning: failed to remove \.\/: [^\n]+$/.test(cleaned.stderr.trim()) && + (yield* fileSystem.readDirectory(input.cwd).pipe( + Effect.map((entries) => entries.length === 0), + Effect.catch(() => Effect.succeed(false)), + )); + if (!emptiedWorkspace) + return yield* new VcsProcessExitError({ + operation, + command: "git clean", + cwd: input.cwd, + exitCode: cleaned.exitCode, + detail: cleaned.stderr.trim() || "Could not clean the checkpoint workspace.", + }); + } const headExists = yield* hasHeadCommit(input.cwd); if (headExists) { diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 88c8c2f4d37f..621a1f7bf66f 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -36,7 +36,7 @@ export default mergeConfig( }, }, pack: { - entry: ["src/bin.ts"], + entry: ["src/bin.ts", "src/claudeHistoryWorker.ts"], outDir: "dist", sourcemap: true, clean: true, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index ae0cdc8581ff..713f1c462a47 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,6 +1,8 @@ import { ANTIGRAVITY_DEFAULT_MODEL, + CheckpointRef, EnvironmentId, + EventId, MessageId, ProjectId, ProviderDriverKind, @@ -10,6 +12,9 @@ import { TurnId, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { Atom, AsyncResult } from "effect/unstable/reactivity"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentThreadDetails } from "../state/threads"; import type { Thread, ThreadShell, TurnDiffSummary } from "../types"; import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; @@ -80,6 +85,8 @@ import { shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, toolGroupConsumesUpwardNavigation, + waitForRevertedMessage, + prepareRevertedMessageAttachments, } from "./ChatView.logic"; describe("agent browser close confirmation", () => { @@ -2242,3 +2249,134 @@ describe("threadShellHasStarted", () => { expect(threadShellHasStarted(null)).toBe(false); }); }); + +describe("rewind draft recovery", () => { + const message = { + id: MessageId.make("rewound-message"), + role: "user" as const, + text: "edit this question", + turnId: TurnId.make("rewound-turn"), + createdAt: now, + updatedAt: now, + streaming: false, + }; + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("waits past command acceptance until the exact message disappears", async () => { + const atom = Atom.make(makeThread({ messages: [message] })); + vi.spyOn(environmentThreadDetails, "detailAtom").mockReturnValue(atom); + let accepted = false; + const result = waitForRevertedMessage({ environmentId, threadId }, message.id, 0, async () => { + accepted = true; + }); + let completed = false; + void result.then(() => { + completed = true; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(accepted).toBe(true); + expect(completed).toBe(false); + appAtomRegistry.set( + atom, + makeThread({ + messages: [], + latestTurn: completedTurn, + checkpoints: [ + { + turnId: completedTurn.turnId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/1"), + status: "ready", + files: [], + assistantMessageId: null, + completedAt: now, + }, + ], + }), + ); + await Promise.resolve(); + expect(completed).toBe(false); + appAtomRegistry.set(atom, makeThread({ messages: [] })); + await result; + }); + + it("rejects a new provider rewind failure without restoring a draft", async () => { + const atom = Atom.make(makeThread({ messages: [message] })); + vi.spyOn(environmentThreadDetails, "detailAtom").mockReturnValue(atom); + const result = waitForRevertedMessage({ environmentId, threadId }, message.id, 0, async () => { + appAtomRegistry.set( + atom, + makeThread({ + messages: [message], + activities: [ + { + id: EventId.make("rewind-failed"), + kind: "checkpoint.revert.failed", + tone: "error", + summary: "Checkpoint revert failed", + payload: { detail: "Native history unavailable", turnCount: 0 }, + turnId: null, + createdAt: now, + }, + ], + }), + ); + }); + await expect(result).rejects.toThrow("Native history unavailable"); + }); + + it("bounds waits when a provider never finishes", async () => { + vi.useFakeTimers(); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); + const atom = Atom.make(makeThread({ messages: [message] })); + vi.spyOn(environmentThreadDetails, "detailAtom").mockReturnValue(atom); + const result = waitForRevertedMessage( + { environmentId, threadId }, + message.id, + 0, + async () => {}, + 20, + ); + const timeoutIndex = setTimeoutSpy.mock.calls.findIndex(([, delay]) => delay === 20); + const rewindTimeout = setTimeoutSpy.mock.results[timeoutIndex]?.value; + expect(rewindTimeout).toBeDefined(); + const rejection = expect(result).rejects.toThrow("Timed out waiting"); + await vi.advanceTimersByTimeAsync(20); + await rejection; + expect(clearTimeoutSpy).toHaveBeenCalledWith(rewindTimeout); + }); + + it("copies attachment bytes before rewind into a fresh file", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response("original bytes")); + vi.stubGlobal("fetch", fetchMock); + const files = await prepareRevertedMessageAttachments({ + message: { + ...message, + attachments: [ + { + type: "file", + id: "old-attachment", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 14, + }, + ], + }, + environmentId, + httpBaseUrl: "https://server.test", + createAssetUrl: async () => + AsyncResult.success({ relativeUrl: "/asset/signed", expiresAt: Date.now() + 60_000 }), + }); + expect(files[0]).toBeInstanceOf(File); + expect(files[0]?.name).toBe("notes.txt"); + expect(await files[0]?.text()).toBe("original bytes"); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://server.test/asset/signed"); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b6bb98b4c50a..b916862ef59c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -737,6 +737,38 @@ export async function resolveFileAttachmentUrl(input: { return url; } +export async function prepareRevertedMessageAttachments(input: { + message: ChatMessage; + environmentId: EnvironmentId; + httpBaseUrl: string; + createAssetUrl: Parameters[0]["createAssetUrl"]; +}): Promise { + return Promise.all( + (input.message.attachments ?? []).map(async (attachment) => { + if (attachment.type !== "image" && attachment.type !== "file") { + throw new Error("This message has an attachment that cannot be restored."); + } + const result = await input.createAssetUrl({ + environmentId: input.environmentId, + input: { + resource: { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: attachment.mimeType, + }, + }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const url = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl); + if (url === null) throw new Error("The environment returned an invalid attachment URL."); + const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + if (!response.ok) throw new Error(`Could not restore attachment: ${attachment.name}`); + return new File([await response.blob()], attachment.name, { type: attachment.mimeType }); + }), + ); +} + export function revokeUserMessagePreviewUrls(message: ChatMessage): void { if (message.role !== "user" || !message.attachments) { return; @@ -1105,6 +1137,81 @@ export async function waitForStartedServerThread( }); } +export async function waitForRevertedMessage( + threadRef: ScopedThreadRef, + messageId: MessageId, + turnCount: number, + revert: () => Promise, + timeoutMs = 120_000, +): Promise { + const threadAtom = environmentThreadDetails.detailAtom(threadRef); + const initial = appAtomRegistry.get(threadAtom); + if (!initial?.messages.some((message) => message.id === messageId)) { + throw new Error("The message to rewind is no longer available."); + } + const previousFailures = new Set( + initial.activities + .filter((activity) => activity.kind === "checkpoint.revert.failed") + .map((activity) => activity.id), + ); + return new Promise((resolve, reject) => { + let settled = false; + let accepted = false; + let unsubscribe = () => {}; + let timeout: ReturnType | undefined; + const finish = (error?: unknown) => { + if (settled) return; + settled = true; + if (timeout !== undefined) globalThis.clearTimeout(timeout); + unsubscribe(); + if (error !== undefined) reject(error); + else resolve(); + }; + const inspect = () => { + const thread = appAtomRegistry.get(threadAtom); + if (!thread) return; + const failure = thread.activities.findLast( + (activity) => + activity.kind === "checkpoint.revert.failed" && !previousFailures.has(activity.id), + ); + if (failure) { + const payload = failure.payload; + finish( + new Error( + typeof payload === "object" && + payload !== null && + "detail" in payload && + typeof payload.detail === "string" + ? payload.detail + : failure.summary, + ), + ); + } else if ( + accepted && + !thread.messages.some((message) => message.id === messageId) && + thread.checkpoints.every((checkpoint) => checkpoint.checkpointTurnCount <= turnCount) && + (turnCount === 0 + ? thread.latestTurn === null + : thread.checkpoints.some( + (checkpoint) => checkpoint.turnId === thread.latestTurn?.turnId, + )) + ) { + finish(); + } + }; + unsubscribe = appAtomRegistry.subscribe(threadAtom, inspect); + timeout = globalThis.setTimeout(() => { + finish(new Error("Timed out waiting for the thread to rewind.")); + }, timeoutMs); + Promise.resolve() + .then(revert) + .then(() => { + accepted = true; + inspect(); + }, finish); + }); +} + export interface LocalDispatchSnapshot { startedAt: string; preparingWorktree: boolean; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dd8a29335a73..49e4220b2b92 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,4 +1,5 @@ import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; +import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; import { collectProviderUsageLimits, @@ -157,7 +158,6 @@ import { } from "../proposedPlan"; import { DEFAULT_INTERACTION_MODE, - DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, @@ -232,7 +232,7 @@ import { PaperclipIcon, WifiOffIcon, } from "lucide-react"; -import { cn, randomHex } from "~/lib/utils"; +import { cn, randomHex, randomUUID } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -414,6 +414,8 @@ import { deriveLockedProvider, readFileAsDataUrl, resolveFileAttachmentUrl, + prepareRevertedMessageAttachments, + waitForRevertedMessage, reconcileMountedTerminalThreadIds, recallCheckoutIsRepo, rememberCheckoutIsRepo, @@ -489,7 +491,10 @@ import { supportsServerUpdateThreadContinuation, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; -import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory"; +import { + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + recallableComposerPrompt, +} from "./chat/composerPromptHistory"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; @@ -1437,6 +1442,13 @@ export default function ChatView(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); + const currentRouteThreadKeyRef = useRef(routeThreadKey); + useLayoutEffect(() => { + currentRouteThreadKeyRef.current = routeThreadKey; + return () => { + currentRouteThreadKeyRef.current = null; + }; + }, [routeThreadKey]); const updateProjectScriptSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false, }); @@ -1651,7 +1663,9 @@ export default function ChatView(props: ChatViewProps) { Record >({}); const [isConnecting, _setIsConnecting] = useState(false); - const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); + const isRevertingCheckpoint = useComposerDraftStore((store) => + store.rewindingThreadKeys.has(routeThreadKey), + ); const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( null, ); @@ -1848,7 +1862,11 @@ export default function ChatView(props: ChatViewProps) { // session.lastError. Bump a tick so the banner hides immediately. Mirrors // the branch mismatch banner. const [, setThreadErrorBannerDismissTick] = useState(0); - const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const defaultRuntimeMode = resolveProjectSettings(settings, activeThread?.projectId ?? null) + .settings.defaultRuntimeMode; + // Implicit drafts follow their current project/environment, including retargets. + // Explicit composer choices and existing server threads retain their permissions. + const runtimeMode = composerRuntimeMode ?? activeServerThread?.runtimeMode ?? defaultRuntimeMode; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; @@ -2302,7 +2320,8 @@ export default function ChatView(props: ChatViewProps) { setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { threadId: nextThreadId, createdAt: new Date().toISOString(), - runtimeMode: DEFAULT_RUNTIME_MODE, + runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings + .defaultRuntimeMode, interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); @@ -2321,6 +2340,7 @@ export default function ChatView(props: ChatViewProps) { navigate, projectGroupingSettings, routeKind, + settings, setDraftThreadContext, setLogicalProjectDraftThreadId, ], @@ -4213,10 +4233,14 @@ export default function ChatView(props: ChatViewProps) { }, [activeThreadRef]); const supportsThreadPullRequests = serverConfig?.environment.capabilities.threadPullRequests === true; + const pullRequestsSurfaceAvailable = + isServerThread && + supportsThreadPullRequests && + visibleThreadPullRequests((activeThreadShell ?? activeThread)?.pullRequests ?? []).length > 0; const addPullRequestsSurface = useCallback(() => { - if (!activeThreadRef || !supportsThreadPullRequests) return; + if (!activeThreadRef || !pullRequestsSurfaceAvailable) return; useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); - }, [activeThreadRef, supportsThreadPullRequests]); + }, [activeThreadRef, pullRequestsSurfaceAvailable]); const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( activeThreadRef?.environmentId ?? null, ); @@ -5352,10 +5376,6 @@ export default function ChatView(props: ChatViewProps) { // activeThreadRef resets transitively with the active thread. }, [activeThread?.id]); - useEffect(() => { - setIsRevertingCheckpoint(false); - }, [activeThread?.id]); - useEffect(() => { if (!activeThread?.id || terminalUiState.terminalOpen) return; const frame = window.requestAnimationFrame(() => { @@ -6523,10 +6543,22 @@ export default function ChatView(props: ChatViewProps) { return () => window.removeEventListener("paste", handler, true); }, [activeThreadId, composerRef]); + const [pendingRevert, setPendingRevert] = useState<{ + turnCount: number; + messageId: MessageId; + routeThreadKey: string; + } | null>(null); + + if (pendingRevert && pendingRevert.routeThreadKey !== routeThreadKey) { + setPendingRevert(null); + } + const onRevertToTurnCount = useCallback( - async (turnCount: number) => { + async (turnCount: number, messageId: MessageId, restoreFiles?: boolean) => { const localApi = readLocalApi(); if (!localApi || !activeThread || isRevertingCheckpoint) return; + const message = activeThread.messages.find((message) => message.id === messageId); + if (!message || message.role !== "user") return; if (!supportsConversationRollback) { setThreadError( @@ -6546,46 +6578,107 @@ export default function ChatView(props: ChatViewProps) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; } - const confirmed = await localApi.dialogs.confirm( - [ - `Revert this thread to checkpoint ${turnCount}?`, - "This will discard newer messages and turn diffs in this thread.", - "This action cannot be undone.", - ].join("\n"), - { variant: "destructive" }, - ); - if (!confirmed) { + if (restoreFiles === undefined) { + setPendingRevert({ turnCount, messageId, routeThreadKey }); return; } - setIsRevertingCheckpoint(true); + useComposerDraftStore.setState((store) => ({ + rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey), + })); setThreadError(activeThread.id, null); - const result = await revertThreadCheckpoint({ - environmentId, - input: { - threadId: activeThread.id, - turnCount, - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); + try { + if (composerRef.current?.hasPendingAttachments()) { + throw new Error("Wait for attachments to finish preparing before rewinding."); + } + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error("The environment is not connected."); + const files = await prepareRevertedMessageAttachments({ + message, + environmentId, + httpBaseUrl: connection.httpBaseUrl, + createAssetUrl: createAttachmentAssetUrl, + }); + const store = useComposerDraftStore.getState(); + const draft = store.getComposerDraft(composerDraftTarget); + if ( + (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + files.length > + PROVIDER_SEND_TURN_MAX_ATTACHMENTS + ) { + throw new Error( + "Make room for this message's attachments in the composer before rewinding.", + ); + } + await waitForRevertedMessage(routeThreadRef, messageId, turnCount, async () => { + const result = await revertThreadCheckpoint({ + environmentId, + input: { threadId: activeThread.id, turnCount, restoreFiles }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + }); + const currentPrompt = store.getComposerDraft(composerDraftTarget)?.prompt ?? ""; + const restoredPrompt = recallableComposerPrompt(message.text); + const nextPrompt = + restoredPrompt.length === 0 + ? currentPrompt + : currentPrompt.length > 0 + ? `${currentPrompt}\n\n${restoredPrompt}` + : restoredPrompt; + store.setPrompt(composerDraftTarget, nextPrompt); + const images: ComposerImageAttachment[] = []; + const restoredFiles: ComposerFileAttachment[] = []; + files.forEach((file, index) => { + const attachment = { + id: randomUUID(), + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + file, + }; + if (message.attachments?.[index]?.type === "image") { + images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) }); + } else { + restoredFiles.push({ ...attachment, type: "file" }); + } + }); + store.addImages(composerDraftTarget, images, { allowDuplicates: true }); + store.addFiles(composerDraftTarget, restoredFiles, { allowDuplicates: true }); + if (currentRouteThreadKeyRef.current === routeThreadKey) { + promptRef.current = nextPrompt; + composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length }); + requestAnimationFrame(() => { + if (currentRouteThreadKeyRef.current === routeThreadKey) + composerRef.current?.focusAtEnd(); + }); + } + } catch (error) { setThreadError( activeThread.id, error instanceof Error ? error.message : "Failed to revert thread state.", ); + } finally { + useComposerDraftStore.setState((store) => { + const remaining = new Set(store.rewindingThreadKeys); + remaining.delete(routeThreadKey); + return { rewindingThreadKeys: remaining }; + }); } - setIsRevertingCheckpoint(false); }, [ activeThread, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel, + composerDraftTarget, + composerRef, + createAttachmentAssetUrl, environmentId, isConnecting, isRevertingCheckpoint, isSendBusy, phase, revertThreadCheckpoint, + routeThreadKey, + routeThreadRef, setThreadError, supportsConversationRollback, ], @@ -6703,6 +6796,7 @@ export default function ChatView(props: ChatViewProps) { !activeThread || isSendBusy || isConnecting || + isRevertingCheckpoint || !clientSettingsHydrated || threadDetailLoading || sendInFlightRef.current || @@ -7902,7 +7996,7 @@ export default function ChatView(props: ChatViewProps) { projectId: activeProject.id, title: nextThreadTitle, modelSelection: nextThreadModelSelection, - runtimeMode, + runtimeMode: defaultRuntimeMode, interactionMode: "default", branch: activeThreadBranch, worktreePath: activeThread.worktreePath, @@ -7925,7 +8019,7 @@ export default function ChatView(props: ChatViewProps) { }, modelSelection: ctxSelectedModelSelection, titleSeed: nextThreadTitle, - runtimeMode, + runtimeMode: defaultRuntimeMode, interactionMode: "default", sourceProposedPlan: { threadId: activeThread.id, @@ -7999,7 +8093,7 @@ export default function ChatView(props: ChatViewProps) { isServerThread, navigate, resetLocalDispatch, - runtimeMode, + defaultRuntimeMode, startThreadTurn, environmentId, composerRef, @@ -8162,8 +8256,8 @@ export default function ChatView(props: ChatViewProps) { // reference is fully stable and never busts TimelineRowCtx identity. const onRevertToTurnCountRef = useRef(onRevertToTurnCount); onRevertToTurnCountRef.current = onRevertToTurnCount; - const onRevertTimelineTurn = useCallback((targetTurnCount: number) => { - void onRevertToTurnCountRef.current(targetTurnCount); + const onRevertTimelineTurn = useCallback((targetTurnCount: number, messageId: MessageId) => { + void onRevertToTurnCountRef.current(targetTurnCount, messageId); }, []); // Files dropped on a sidebar row land here once the dropped-on thread is @@ -8357,7 +8451,7 @@ export default function ChatView(props: ChatViewProps) { } composerDraftTarget={composerDraftTarget} onBack={ - activeThreadRef !== null && supportsThreadPullRequests + activeThreadRef !== null && pullRequestsSurfaceAvailable ? addPullRequestsSurface : undefined } @@ -8657,6 +8751,7 @@ export default function ChatView(props: ChatViewProps) { {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */}
) : null} + { + if (!open) setPendingRevert(null); + }} + > + + + Edit from here? + + Rewind chat to before this message. Your prompt and attachments return to the + composer. + + + + }>Cancel + + + + + {expandedImage && ( , run: async () => { useRightPanelStore.getState().open(threadRef, "pull-requests"); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index aa3767a3155a..746a1bbe0247 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -303,7 +303,7 @@ function getMenuActionDisabledReason({ if (item.id === "push") { if (!hasBranch) { - return "Detached HEAD: checkout a refName before pushing."; + return "Detached HEAD: check out a branch before pushing."; } if (hasChanges) { return "Commit or stash local changes before pushing."; @@ -324,7 +324,7 @@ function getMenuActionDisabledReason({ return `View ${terminology.singular} is currently unavailable.`; } if (!hasBranch) { - return `Detached HEAD: checkout a refName before creating a ${terminology.singular}.`; + return `Detached HEAD: check out a branch before creating a ${terminology.singular}.`; } if (hasChanges) { return `Commit local changes before creating a ${terminology.singular}.`; @@ -1754,7 +1754,7 @@ export default function GitActionsControl({ ) : null} {gitStatusForActions?.refName === null && (

- Detached HEAD: create and checkout a refName to enable push and pull request + Detached HEAD: create and check out a branch to enable push and pull request actions.

)} @@ -1799,9 +1799,7 @@ export default function GitActionsControl({ {gitStatusForActions?.refName ?? "(detached HEAD)"} - {isDefaultRef && ( - Warning: default refName - )} + {isDefaultRef && Default branch}
@@ -1932,7 +1930,7 @@ export default function GitActionsControl({ disabled={noneSelected} onClick={runDialogActionOnNewBranch} > - Commit on new refName + Commit on new branch diff --git a/apps/web/src/components/ProjectEnvironmentBadge.tsx b/apps/web/src/components/ProjectEnvironmentBadge.tsx new file mode 100644 index 000000000000..22be8298a4f8 --- /dev/null +++ b/apps/web/src/components/ProjectEnvironmentBadge.tsx @@ -0,0 +1,54 @@ +import type { EnvironmentId, EnvironmentMachineKind } from "@t3tools/contracts"; + +import type { SidebarProjectSnapshot } from "~/sidebarProjectGrouping"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +/** + * Machine icon for a project picker row whose group has a member on another + * environment, with the environment names in a tooltip. Projects that only + * live on this device render nothing, the rule thread rows use for their + * machine icon. Callers + * render it only while the catalog spans environments (see + * projectGroupsSpanEnvironments), so single-machine users see no change. + */ +export function ProjectEnvironmentBadge(props: { + readonly group: Pick; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly machineByEnvironmentId: ReadonlyMap; +}) { + // Member order follows registration order and can differ between sessions, + // so sort by label to keep the icon and tooltip stable. + const remoteMembers = props.group.memberProjects + .filter((member) => member.environmentId !== props.primaryEnvironmentId) + .map((member) => ({ ...member, environmentLabel: member.environmentLabel ?? "Remote" })) + .sort((a, b) => a.environmentLabel.localeCompare(b.environmentLabel)); + const first = remoteMembers[0]; + if (!first) return null; + const labels = remoteMembers + .map((member) => member.environmentLabel) + .filter((label, index, all) => all.indexOf(label) === index) + .join(", "); + const alsoHere = remoteMembers.length < props.group.memberProjects.length; + const description = `${alsoHere ? "Also on" : "On"} ${labels}`; + return ( + + + } + > + + + {description} + + ); +} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index c791a632b79e..e0fb70b8080d 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -154,7 +154,7 @@ const SURFACE_DISABLED_REASONS = { files: "Files are only available when a project is open.", diff: "Diff is only available for server threads in Git repositories.", pullRequest: "This thread's branch has no pull request yet.", - pullRequests: "Linked pull requests are only available for server threads.", + pullRequests: "No linked pull requests are available for this thread.", agents: "Agents are only available from a thread.", device: "Devices are only available from a thread.", } as const; @@ -178,7 +178,7 @@ const SURFACE_UNAVAILABLE_HINTS = { files: "Available when a project is open.", diff: "Available for Git repositories.", pullRequest: "No pull request on this branch yet.", - pullRequests: "Available for server threads.", + pullRequests: "No linked pull requests available.", agents: "Available from a thread.", device: "Available from a thread.", } as const; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1642ec3af2b7..3154fe5d6937 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -104,6 +104,7 @@ import { import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots, + projectGroupsSpanEnvironments, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -141,6 +142,7 @@ import type { SidebarThreadSummary } from "../types"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { animateSidebarLayoutChanges, @@ -2342,6 +2344,13 @@ export default function Sidebar() { ], [projectGroups], ); + // Same-named projects on two machines are only told apart by where they + // live, so rows on another machine carry its icon once the catalog spans + // more than one environment; a single-machine catalog stays as it was. + const showProjectEnvironments = useMemo( + () => projectGroupsSpanEnvironments(projectGroups), + [projectGroups], + ); const projectGroupByScopeKey = useMemo( () => new Map(projectGroups.map((project) => [project.projectKey, project] as const)), [projectGroups], @@ -4450,6 +4459,13 @@ export default function Sidebar() { {scopedProjectGroup?.displayName ?? "All projects"} + {scopedProjectGroup && showProjectEnvironments ? ( + + ) : null} )} {item.label} + {project && showProjectEnvironments ? ( + + ) : null} {project ? ( + )} +
+ ))} + + ); +} + +export function PermissionContinueButton({ + ready, + busy = false, + children = "Continue", + ...props +}: Omit, "disabled"> & { ready: boolean; busy?: boolean }) { + return ( + + ); +} diff --git a/apps/web/src/components/permissions/usePermissionStatus.test.ts b/apps/web/src/components/permissions/usePermissionStatus.test.ts new file mode 100644 index 000000000000..8b882f9df675 --- /dev/null +++ b/apps/web/src/components/permissions/usePermissionStatus.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; +import { usePermissionStatus } from "./usePermissionStatus"; + +const effects = vi.hoisted(() => [] as Array<() => (() => void) | undefined>); +vi.mock("react", async (original) => { + const actual = await original(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useState: reactHookHarness.useState, + useEffect: (effect: () => (() => void) | undefined) => effects.push(effect), + useEffectEvent: (callback: T) => callback, + }; +}); +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); +const check = vi.fn<() => Promise<{ screen: boolean; accessibility: boolean }>>(); +let cleanup: (() => void) | undefined; +let page: EventTarget & { visibilityState: string }; +const render = () => { + hooks.beginRender(); + return usePermissionStatus(check, { screen: false, accessibility: false }); +}; +beforeEach(() => { + hooks.reset(); + effects.length = 0; + vi.useFakeTimers(); + page = Object.assign(new EventTarget(), { visibilityState: "visible" }); + vi.stubGlobal("document", page); + vi.stubGlobal("window", Object.assign(new EventTarget(), { setInterval, clearInterval })); + check.mockReset().mockResolvedValue({ screen: false, accessibility: false }); +}); +afterEach(() => { + cleanup?.(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); +const start = async () => { + render(); + cleanup = effects[0]!(); + await Promise.resolve(); +}; + +it("unlocks Continue only for required grants and relocks on revocation", async () => { + await start(); + expect(render().isReady(["screen"])).toBe(false); + check.mockResolvedValue({ screen: true, accessibility: false }); + await vi.advanceTimersByTimeAsync(1500); + expect(render().isReady(["screen"])).toBe(true); + expect(render().isReady(["screen", "accessibility"])).toBe(false); + check.mockResolvedValue({ screen: true, accessibility: true }); + await vi.advanceTimersByTimeAsync(1500); + expect(render().isReady(["screen", "accessibility"])).toBe(true); + check.mockResolvedValue({ screen: false, accessibility: true }); + window.dispatchEvent(new Event("focus")); + await Promise.resolve(); + expect(render().isReady(["screen"])).toBe(false); +}); + +it("does not overlap checks and discards completion after closing", async () => { + let resolve!: (status: { screen: boolean; accessibility: boolean }) => void; + const promise = new Promise<{ screen: boolean; accessibility: boolean }>((done) => { + resolve = done; + }); + const pending = { promise, resolve }; + check.mockReturnValue(pending.promise); + await start(); + await vi.advanceTimersByTimeAsync(4500); + window.dispatchEvent(new Event("focus")); + expect(check).toHaveBeenCalledTimes(1); + cleanup?.(); + pending.resolve({ screen: true, accessibility: true }); + await pending.promise; + expect(render().isReady(["screen"])).toBe(false); + expect(vi.getTimerCount()).toBe(0); +}); + +it("pauses in the background and blocks stale grants after a check failure", async () => { + check.mockResolvedValue({ screen: true, accessibility: true }); + await start(); + expect(render().isReady(["screen"])).toBe(true); + page.visibilityState = "hidden"; + await vi.advanceTimersByTimeAsync(3000); + expect(check).toHaveBeenCalledTimes(1); + check.mockRejectedValue(new Error("IPC unavailable")); + page.visibilityState = "visible"; + page.dispatchEvent(new Event("visibilitychange")); + await Promise.resolve(); + expect(render().isReady(["screen"])).toBe(false); + check.mockResolvedValue({ screen: true, accessibility: true }); + await vi.advanceTimersByTimeAsync(1500); + expect(render().isReady(["screen"])).toBe(true); +}); diff --git a/apps/web/src/components/permissions/usePermissionStatus.ts b/apps/web/src/components/permissions/usePermissionStatus.ts new file mode 100644 index 000000000000..f28c9c755da8 --- /dev/null +++ b/apps/web/src/components/permissions/usePermissionStatus.ts @@ -0,0 +1,45 @@ +import { useEffect, useEffectEvent, useState } from "react"; + +export function usePermissionStatus( + check: () => Promise>, + initialStatus: Record, + enabled = true, +) { + const [status, setStatus] = useState(initialStatus); + const [error, setError] = useState(null); + const checkLatest = useEffectEvent(check); + useEffect(() => { + if (!enabled) return; + let disposed = false; + let checking = false; + const refresh = async () => { + if (disposed || checking || document.visibilityState === "hidden") return; + checking = true; + try { + const next = await checkLatest(); + if (!disposed) { + setStatus(next); + setError(null); + } + } catch { + if (!disposed) setError("Could not check permissions. We'll try again automatically."); + } + checking = false; + }; + void refresh(); + const timer = window.setInterval(() => void refresh(), 1500); + window.addEventListener("focus", refresh); + document.addEventListener("visibilitychange", refresh); + return () => { + disposed = true; + window.clearInterval(timer); + window.removeEventListener("focus", refresh); + document.removeEventListener("visibilitychange", refresh); + }; + }, [enabled]); + return { + status, + error, + isReady: (required: readonly Id[]) => error === null && required.every((id) => status[id]), + }; +} diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 69017444ce80..b9b000829945 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -104,7 +104,7 @@ function PullRequestRowImpl({ aria-current={selected ? "true" : undefined} onClick={() => onSelect(entry)} className={cn( - "@container/pr-row grid w-full grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + "@container/pr-row grid w-full cursor-pointer grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", // Offscreen rows are skipped for style, layout and paint: a long list costs what the // viewport shows, not what the pages have loaded. The intrinsic size keeps the // scrollbar honest while a row is skipped. diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 239ebeb7950e..2c8bb76aafd3 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -363,7 +363,7 @@ function CommentComposer({ }; return ( -
+