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 61de1361a311..5e7c41514a67 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 { @@ -305,7 +308,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; }), }); @@ -379,3 +391,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/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 ac055a0f5bac..89c11fe6e18c 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -93,6 +93,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/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 77036c212517..d57904b7e7a2 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -41,14 +41,8 @@ import { DEFAULT_SERVER_SETTINGS, MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, - type ServerSettingsPatch, } from "@t3tools/contracts"; -import { - filterSharedServerPatch, - findSharedSettingsMismatches, - pickSharedServerSettings, - supportsSharedSettingsSync, -} from "@t3tools/client-runtime/state/shared-settings"; +import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, @@ -61,6 +55,7 @@ import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; +import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -588,10 +583,9 @@ function GeneralSettingsSection() { const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; /** - * Auto-settlement is a user preference that every server has to hold. Mobile - * has no primary environment, so the first eligible sync target provides the - * reference value. Edits fan out to every eligible target, and a mismatch row - * lets the user push the reference out. + * Mobile edits auto-settle defaults across connected, capable environments. + * The first target supplies the displayed values. Applying them leaves each + * environment's other defaults and overrides intact. */ function AutoSettleSettingsRows() { const { environments } = useEnvironments(); @@ -610,24 +604,20 @@ function AutoSettleSettingsRows() { return null; } - const writeToAll = (patch: ServerSettingsPatch) => { + const writeToAll = (patch: Partial) => { for (const environment of syncTargets) { void updateSettings({ environmentId: environment.environmentId, input: { patch } }); } }; - const mismatches = findSharedSettingsMismatches({ - primaryEnvironmentId: reference.environmentId, - primarySettings: referenceSettings, - primaryCapabilities: reference.serverConfig?.environment.capabilities, - environments: environments.map((environment) => ({ + const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( + { environmentId: reference.environmentId, settings: referenceSettings }, + syncTargets.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, - syncEligible: supportsSharedSettingsSync(environment), settings: environment.serverConfig?.settings ?? null, - capabilities: environment.serverConfig?.environment.capabilities, })), - }); + ); const afterDays = referenceSettings.sidebarAutoSettleAfterDays; const commitDays = () => { @@ -681,7 +671,7 @@ function AutoSettleSettingsRows() { {mismatches.length > 0 ? ( - Settings differ + Auto-settle defaults differ {mismatches.map((mismatch) => mismatch.label).join(", ")} @@ -689,30 +679,18 @@ function AutoSettleSettingsRows() { { - const patch = pickSharedServerSettings( - referenceSettings, - reference.serverConfig?.environment.capabilities, - ); for (const mismatch of mismatches) { - const target = environments.find( - (candidate) => candidate.environmentId === mismatch.environmentId, - ); void updateSettings({ environmentId: mismatch.environmentId, - input: { - patch: filterSharedServerPatch( - patch, - target?.serverConfig?.environment.capabilities, - target?.serverConfig?.settings, - referenceSettings, - ), - }, + input: { patch: autoSettlePatch }, }); } }} className="rounded-full bg-subtle px-4 py-2 active:opacity-70" > - Apply to all + + Apply auto-settle defaults + ) : null} diff --git a/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts b/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts new file mode 100644 index 000000000000..ec550725adcf --- /dev/null +++ b/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts @@ -0,0 +1,78 @@ +import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { planAutoSettleSettingsSync } from "./autoSettleSettingsSync"; + +const reference = { + environmentId: EnvironmentId.make("reference"), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: 7, + sidebarAutoSettleOnMerge: true, + newWorktreesStartFromOrigin: false, + continueThreadsAfterServerUpdate: false, + }, +}; + +describe("auto-settle settings sync", () => { + it("ignores differences in independently configured environment settings", () => { + const target = { + environmentId: EnvironmentId.make("remote"), + label: "Remote", + settings: { + ...reference.settings, + newWorktreesStartFromOrigin: true, + continueThreadsAfterServerUpdate: true, + sourceControlWritingStyle: { + ...reference.settings.sourceControlWritingStyle, + customInstructions: "Keep this environment's writing instructions.", + }, + }, + }; + + const plan = planAutoSettleSettingsSync(reference, [target]); + + expect(plan.mismatches).toEqual([]); + expect(plan.patch).toEqual({ + sidebarAutoSettleAfterDays: 7, + sidebarAutoSettleOnMerge: true, + }); + }); + + it("applies only auto-settle defaults when another environment differs", () => { + const target = { + environmentId: EnvironmentId.make("remote"), + label: "Remote", + settings: { + ...reference.settings, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + newWorktreesStartFromOrigin: true, + continueThreadsAfterServerUpdate: true, + sourceControlWritingStyle: { + ...reference.settings.sourceControlWritingStyle, + customInstructions: "Preserve these instructions.", + }, + }, + }; + + const plan = planAutoSettleSettingsSync(reference, [target]); + const updated = { ...target.settings, ...plan.patch }; + + expect(plan.mismatches).toEqual([target]); + expect(updated.sidebarAutoSettleAfterDays).toBe(7); + expect(updated.sidebarAutoSettleOnMerge).toBe(true); + expect(updated.newWorktreesStartFromOrigin).toBe(true); + expect(updated.continueThreadsAfterServerUpdate).toBe(true); + expect(updated.sourceControlWritingStyle).toEqual(target.settings.sourceControlWritingStyle); + }); + + it("does not compare the reference or a target without loaded settings", () => { + const plan = planAutoSettleSettingsSync(reference, [ + { ...reference, label: "Reference" }, + { environmentId: EnvironmentId.make("loading"), label: "Loading", settings: null }, + ]); + + expect(plan.mismatches).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/settings/autoSettleSettingsSync.ts b/apps/mobile/src/features/settings/autoSettleSettingsSync.ts new file mode 100644 index 000000000000..6addfa381fde --- /dev/null +++ b/apps/mobile/src/features/settings/autoSettleSettingsSync.ts @@ -0,0 +1,31 @@ +import type { EnvironmentId, ServerSettings } from "@t3tools/contracts"; + +export type AutoSettleSettings = Pick< + ServerSettings, + "sidebarAutoSettleAfterDays" | "sidebarAutoSettleOnMerge" +>; + +interface AutoSettleSyncTarget { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly settings: AutoSettleSettings | null; +} + +/** Receives connected, capable targets. Applying these defaults must preserve other settings. */ +export function planAutoSettleSettingsSync( + reference: { readonly environmentId: EnvironmentId; readonly settings: AutoSettleSettings }, + targets: readonly AutoSettleSyncTarget[], +) { + const patch: AutoSettleSettings = { + sidebarAutoSettleAfterDays: reference.settings.sidebarAutoSettleAfterDays, + sidebarAutoSettleOnMerge: reference.settings.sidebarAutoSettleOnMerge, + }; + const mismatches = targets.filter( + (target) => + target.environmentId !== reference.environmentId && + target.settings !== null && + (target.settings.sidebarAutoSettleAfterDays !== patch.sidebarAutoSettleAfterDays || + target.settings.sidebarAutoSettleOnMerge !== patch.sidebarAutoSettleOnMerge), + ); + return { patch, mismatches }; +} 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 535581b58e75..58fc8f1e3fa3 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -13,10 +13,12 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, + DEFAULT_SERVER_SETTINGS, MessageId, T3_PROJECT_FILE_NAME, ThreadId, } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { isDefaultThreadEnvModeSettled, @@ -428,17 +430,32 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; }, [t3ProjectFileData]); + // Environment settings with the project's overrides applied; the + // aggregate's own legacy fields still count until the server folds them. + const projectSettings = useMemo( + () => + resolveProjectSettings( + selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + selectedProject?.id ?? null, + selectedProject, + ), + [selectedEnvironmentServerConfig?.settings, selectedProject], + ); + const projectThreadEnvMode = + projectSettings.sources.defaultThreadEnvMode === "project" + ? projectSettings.settings.defaultThreadEnvMode + : undefined; const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ - projectSetting: selectedProject?.defaultThreadEnvMode, + projectSetting: projectThreadEnvMode, projectFile: t3ProjectFileDefaultMode, - globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local", + globalDefault: projectSettings.settings.defaultThreadEnvMode, }); // While unsettled the resolved default is provisional. Nothing may write // it into the draft during that window (the auto-branch effect does), or // the frozen interim value beats the t3.json default once it loads. const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ explicitMode: selectedProjectDraft.workspaceSelection?.mode, - projectSetting: selectedProject?.defaultThreadEnvMode, + projectSetting: projectThreadEnvMode, projectFilePending: t3ProjectFileQuery.isPending, }); const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; @@ -449,10 +466,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // value keeps tracking the server setting when the config loads late. const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; const startFromOrigin = - draftStartFromOrigin ?? - selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? - true; - const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; + draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin; + 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 @@ -463,9 +481,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, - selectedProject?.defaultModelSelection ?? - selectedEnvironmentServerConfig?.settings.defaultModelSelection ?? - null, + projectSettings.settings.defaultModelSelection, ); const storedStickyModelSelection = useStickyComposerModelSelection(); const stickyModelSelection = resolveDefaultableModelSelection( @@ -965,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, @@ -1001,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/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 2aab17b27a76..7bf081abc91d 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -222,6 +222,7 @@ export const make = Effect.gen(function* () { threadSettlement: true, threadAutoSettlement: true, threadRestartContinuation: true, + projectSettingsOverrides: true, threadSnooze: true, environmentThemes: true, usageLimitSources: true, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f60eb2781872..1a14fb5be5bc 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -29,9 +29,16 @@ import { type VcsStatusRemoteResult, VcsStatusResult, ModelSelection, + type ProjectId, SourceControlProviderError, type SourceControlWritingStyleSettings, + type ThreadId, } from "@t3tools/contracts"; +import { + hasProjectSettingsOverrides, + resolveProjectSettings, +} from "@t3tools/shared/projectSettings"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, @@ -661,6 +668,28 @@ export const make = Effect.gen(function* () { const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; + // Optional: git actions also run from the CLI and tests without orchestration. + const projectionQuery = yield* Effect.serviceOption( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + ); + /** Environment settings with the acting project's overrides applied. */ + const projectSettingsFor = Effect.fnUntraced(function* (input: { + readonly cwd: string; + readonly threadId?: ThreadId | undefined; + }) { + const settings = yield* serverSettingsService.getSettings; + if (!hasProjectSettingsOverrides(settings) || Option.isNone(projectionQuery)) return settings; + const projectId = yield* ( + input.threadId !== undefined + ? projectionQuery.value + .getThreadShellById(input.threadId) + .pipe(Effect.map(Option.map((thread) => thread.projectId))) + : projectionQuery.value + .getActiveProjectByWorkspaceRoot(input.cwd) + .pipe(Effect.map(Option.map((project) => project.id))) + ).pipe(Effect.orElseSucceed(() => Option.none())); + return resolveProjectSettings(settings, Option.getOrNull(projectId)).settings; + }); const readRepositoryInstructions = (cwd: string, fileName: string) => Effect.gen(function* () { const root = yield* fileSystem.realPath(cwd); @@ -2600,7 +2629,7 @@ export const make = Effect.gen(function* () { let commitMessageForStep = input.commitMessage; let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; - const textGenerationSettings = yield* serverSettingsService.getSettings.pipe( + const textGenerationSettings = yield* projectSettingsFor(input).pipe( Effect.flatMap((settings) => settings.sourceControlWriterModelSelection === null ? Effect.succeed({ diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index b88f7f012d46..01430a201289 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -1916,27 +1916,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..fc1a3f740349 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -699,21 +699,17 @@ 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))) { + const checkpointCwd = yield* resolveCheckpointCwd({ + threadId: event.payload.threadId, + thread, + projects: yield* resolveThreadProjects(thread.projectId), + preferSessionRuntime: true, + }); + if (!checkpointCwd) { yield* appendRevertFailureActivity({ threadId: event.payload.threadId, turnCount: event.payload.turnCount, - detail: "Checkpoints are unavailable because this project is not a git repository.", + detail: "Checkpoint workspace is unavailable or is not a git repository.", createdAt: now, }).pipe(Effect.catch(() => Effect.void)); return; @@ -754,7 +750,7 @@ const make = Effect.gen(function* () { yield* providerService.assertConversationRollbackSupported(event.payload.threadId); const restored = yield* checkpointStore.restoreCheckpoint({ - cwd: sessionRuntime.value.cwd, + cwd: checkpointCwd, checkpointRef: targetCheckpointRef, fallbackToHead: event.payload.turnCount === 0, }); @@ -770,12 +766,12 @@ const make = Effect.gen(function* () { // Refresh the workspace entry index so the @-mention file picker // reflects the reverted filesystem state. - yield* workspaceEntries.refresh(sessionRuntime.value.cwd); + 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, }); } @@ -789,7 +785,7 @@ const make = Effect.gen(function* () { if (staleCheckpointRefs.length > 0) { yield* checkpointStore.deleteCheckpointRefs({ - cwd: sessionRuntime.value.cwd, + cwd: checkpointCwd, checkpointRefs: staleCheckpointRefs, }); } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 5849123c55d6..be66f3cf4b43 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -682,6 +682,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { if (context._tag === "Some") { assert.deepEqual(context.value, { id: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), title: "Thread 1", session: snapshot.threads[0]?.session, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 066c60760ca5..efb7bba8f15b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -142,6 +142,7 @@ const ProjectionThreadActivityIdRowSchema = Schema.Struct({ const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({ id: ThreadId, + projectId: ProjectId, title: Schema.String, session: Schema.NullOr(ProjectionThreadSessionDbRowSchema), }); @@ -1231,6 +1232,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sql` SELECT threads.thread_id AS id, + threads.project_id AS "projectId", threads.title, sessions.thread_id AS "threadId", sessions.status, @@ -1251,6 +1253,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { Effect.map((rows) => rows.map((row) => ({ id: row.id, + projectId: row.projectId, title: row.title, session: row.threadId === null ? null : row, })), @@ -3164,6 +3167,7 @@ pending_approval_requests AS ( ); return Option.map(context, (row) => ({ id: row.id, + projectId: row.projectId, title: row.title, session: row.session === null ? null : mapSessionRow(row.session), })); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9b125922137c..c5d120106a19 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -54,6 +54,7 @@ import { resolveSourceControlWriterModelSelection, ServerSettingsService, } from "../../serverSettings.ts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); @@ -329,6 +330,16 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + /** Environment settings with the thread's project overrides applied. */ + const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) { + const settings = yield* serverSettingsService.getSettings; + if (Object.keys(settings.projectSettingsOverrides).length === 0) return settings; + const thread = yield* projectionSnapshotQuery + .getThreadShellById(threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + return resolveProjectSettings(settings, Option.isSome(thread) ? thread.value.projectId : null) + .settings; + }); const serverCommandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); @@ -996,7 +1007,7 @@ const make = Effect.gen(function* () { const cwd = input.worktreePath; const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const settings = yield* serverSettingsService.getSettings; + const settings = yield* projectSettingsForThread(input.threadId); const modelSelection = settings.sourceControlWriterModelSelection === null ? settings.textGenerationModelSelection @@ -1047,8 +1058,9 @@ const make = Effect.gen(function* () { }) { const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = - yield* serverSettingsService.getSettings; + const { textGenerationModelSelection: modelSelection } = yield* projectSettingsForThread( + input.threadId, + ); const generated = yield* textGeneration .generateThreadTitle({ @@ -1117,8 +1129,10 @@ const make = Effect.gen(function* () { thread, projects: project ? [project] : [], }) ?? process.cwd(); - const { textGenerationModelSelection: modelSelection } = - yield* serverSettingsService.getSettings; + const { textGenerationModelSelection: modelSelection } = resolveProjectSettings( + yield* serverSettingsService.getSettings, + thread.projectId, + ).settings; const generated = yield* textGeneration.generateThreadTitle({ cwd, message, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 8d34fee4f981..964f60d3a306 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -51,6 +51,7 @@ import { import { projectActivityPayload } from "../ActivityPayloadProjection.ts"; import { forkParked } from "../../serverActivation.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { canReplaceThreadTitle } from "../threadTitles.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; @@ -1668,7 +1669,10 @@ const make = Effect.gen(function* () { const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, - (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), + (settings) => + resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming + ? "streaming" + : "buffered", ); if (assistantDeliveryMode === "buffered") { const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); @@ -1709,7 +1713,10 @@ const make = Effect.gen(function* () { }); const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, - (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), + (settings) => + resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming + ? "streaming" + : "buffered", ); const flushedMessageIds = assistantDeliveryMode === "buffered" diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 35d5bacc239c..fda0ac04556e 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -209,7 +209,7 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadRuntimeContext: ( threadId: ThreadId, ) => Effect.Effect< - Option.Option>, + Option.Option>, ProjectionRepositoryError >; diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 0690eea2d50e..c443ec75e0d5 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -302,6 +302,35 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( }); describe("ThreadSettlementReactor", () => { + it("distinguishes a project that inherits the threshold from one that disables it", () => { + const inherits = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: true } }, + }); + const never = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [PROJECT_ID]: { sidebarAutoSettleOnMerge: true, sidebarAutoSettleAfterDays: null }, + }, + }); + assert.notStrictEqual(inherits, never); + }); + + it("ignores project overrides that do not touch settlement", () => { + const base = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: false } }, + }); + const unrelated = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [LINKED_PROJECT_ID]: { defaultThreadEnvMode: "worktree" }, + [PROJECT_ID]: { sidebarAutoSettleOnMerge: false, defaultAutoPull: true }, + }, + }); + assert.strictEqual(base, unrelated); + }); + it.effect( "settles all-terminal links from snapshots and keeps open or unsynced links active", () => @@ -486,6 +515,59 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("a project override settles only that project's inactive threads", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const overriddenProject = ProjectId.make("overridden-project"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("inherits-thread"), + makeThread("overridden-thread", { projectId: overriddenProject }), + ], + [makeProject(), makeProject(overriddenProject, "/workspace/overridden")], + ), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + projectSettingsOverrides: { + [overriddenProject]: { sidebarAutoSettleAfterDays: 1 }, + }, + }, + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + yield* Queue.take(fixture.settingsReads); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("overridden-thread")], + ); + + // Clearing the override is a settlement change, so the sweep re-arms. + yield* fixture.updateSettings({ + projectSettingsOverrides: { [overriddenProject]: null }, + sidebarAutoSettleAfterDays: 1, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + // The static snapshot never records the first settlement, so the + // second sweep dispatches for both; the inheriting thread is new. + assert.include( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + ThreadId.make("inherits-thread"), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("starts without clients and skips protected threads before pull request lookup", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 61fc5d4ab863..b9041d2976ca 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -1,4 +1,5 @@ -import { CommandId } from "@t3tools/contracts"; +import { CommandId, type ServerSettings as ServerSettingsValue } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -32,6 +33,45 @@ export class ThreadSettlementReactor extends Context.Service< } >()("t3/orchestration/ThreadSettlementReactor") {} +/** @public Service construction is part of the canonical Effect module API. */ +/** Whether any environment default or project override can settle a thread. */ +function autoSettlementConfigured(settings: ServerSettingsValue): boolean { + if (settings.sidebarAutoSettleOnMerge || settings.sidebarAutoSettleAfterDays !== null) { + return true; + } + return Object.values(settings.projectSettingsOverrides).some( + (entry) => + entry.sidebarAutoSettleOnMerge === true || + (entry.sidebarAutoSettleAfterDays !== undefined && entry.sidebarAutoSettleAfterDays !== null), + ); +} + +/** Identity of every settlement input, so unrelated settings edits do not trigger a sweep. */ +/** @internal Exported for tests. */ +export function autoSettlementSettingsKey(settings: ServerSettingsValue): string { + return JSON.stringify([ + settings.sidebarAutoSettleOnMerge, + settings.sidebarAutoSettleAfterDays, + // Only entries that touch settlement, in a stable order, so a project + // override on an unrelated key does not queue a sweep. JSON drops + // undefined, so inherit (absent) and never (null) need distinct marks. + Object.entries(settings.projectSettingsOverrides) + .filter( + ([, entry]) => + entry.sidebarAutoSettleOnMerge !== undefined || + entry.sidebarAutoSettleAfterDays !== undefined, + ) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([projectId, entry]) => [ + projectId, + entry.sidebarAutoSettleOnMerge ?? "inherit", + entry.sidebarAutoSettleAfterDays === undefined + ? "inherit" + : entry.sidebarAutoSettleAfterDays, + ]), + ]); +} + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; @@ -46,7 +86,7 @@ export const make = Effect.gen(function* () { mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, ) { const settings = yield* settingsService.getSettings; - if (!settings.sidebarAutoSettleOnMerge && settings.sidebarAutoSettleAfterDays === null) { + if (!autoSettlementConfigured(settings)) { return; } const snapshot = yield* snapshots.getShellSnapshot(); @@ -60,7 +100,10 @@ export const make = Effect.gen(function* () { // dispatch skips it for this snapshot instead of retrying through a lookup. const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")( function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { - const settings = yield* settingsService.getSettings; + const settings = resolveProjectSettings( + yield* settingsService.getSettings, + thread.projectId, + ).settings; const decisionNow = DateTime.formatIso(yield* DateTime.now); const settledAt = resolveAutoSettlementAt({ thread, @@ -254,8 +297,7 @@ export const make = Effect.gen(function* () { const settingsChanges = yield* settingsService.subscribeChanges; const mergedPullRequests = yield* pullRequests.subscribeMerges; const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); - let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays; - let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge; + let lastSettlementSettings = autoSettlementSettingsKey(initialSettings); yield* forkParked( Effect.gen(function* () { yield* worker.enqueue(undefined); @@ -264,14 +306,11 @@ export const make = Effect.gen(function* () { ); yield* forkParked( Stream.runForEach(settingsChanges, (settings) => { - if ( - settings.sidebarAutoSettleAfterDays === lastAfterDays && - settings.sidebarAutoSettleOnMerge === lastOnMerge - ) { + const key = autoSettlementSettingsKey(settings); + if (key === lastSettlementSettings) { return Effect.void; } - lastAfterDays = settings.sidebarAutoSettleAfterDays; - lastOnMerge = settings.sidebarAutoSettleOnMerge; + lastSettlementSettings = key; return worker.enqueue(undefined); }), ); 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 981e183414f1..fc52fe38dc68 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"; @@ -108,9 +111,26 @@ import { type ProviderAdapterError, } from "../Errors.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"); type ClaudeTextStreamKind = Extract; @@ -139,6 +159,7 @@ interface ClaudeResumeState { readonly resume?: string; readonly resumeSessionAt?: string; readonly turnCount?: number; + readonly turnStartMessageIds?: ReadonlyArray; } interface ClaudeTurnState { @@ -290,6 +311,8 @@ function rememberPendingTaskModel( interface ClaudeSessionContext { session: ProviderSession; + startInput: Parameters[0]; + readonly turnStartMessageIds: Array; readonly promptQueue: Queue.Queue; readonly query: ClaudeQueryRuntime; streamFiber: Fiber.Fiber | undefined; @@ -350,6 +373,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; @@ -854,6 +879,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef sessionId?: unknown; resumeSessionAt?: unknown; turnCount?: unknown; + turnStartMessageIds?: unknown; }; const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined; @@ -871,11 +897,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 } : {}), @@ -1927,6 +1959,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), ); @@ -2043,7 +2076,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 = { @@ -3155,6 +3189,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, @@ -3177,6 +3212,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( activeTurnId: turnId, updatedAt: startedAt, }; + yield* updateResumeCursor(context); const turnStartedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "turn.started", @@ -4779,6 +4815,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, @@ -4786,6 +4825,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, @@ -4899,6 +4942,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 @@ -5015,9 +5061,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 { @@ -5049,10 +5100,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 bd9a6b6f34a8..a04db1405912 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -1192,6 +1192,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< @@ -2435,24 +2526,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 b19d46112fde..925d585e5838 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1187,7 +1187,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, @@ -1195,9 +1195,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) => @@ -1235,7 +1237,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 a2f78a0d72d1..395d7e546f9a 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -2134,7 +2134,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/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 9a3fcb8d65f4..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; @@ -4802,7 +4898,8 @@ describe("agent browser access", () => { const startSessionWith = ( access: boolean | { readonly browser: boolean; readonly device: boolean }, threadId: ThreadId, - projectOverride?: boolean, + projectOverride?: boolean | { readonly browser?: boolean; readonly device?: boolean }, + options?: { readonly withoutOrchestration?: boolean }, ) => Effect.gen(function* () { const enableAgentBrowserAccess = typeof access === "boolean" ? access : access.browser; @@ -4875,13 +4972,26 @@ describe("agent browser access", () => { }).pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), - Layer.provide(projectionLayer), + Layer.provide(options?.withoutOrchestration ? Layer.empty : projectionLayer), Layer.provide( ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess, enableAgentDeviceAccess, - projectAgentBrowserAccessOverrides: - projectOverride === undefined ? {} : { [projectId]: projectOverride }, + projectSettingsOverrides: + projectOverride === undefined + ? {} + : typeof projectOverride === "boolean" + ? { [projectId]: { enableAgentBrowserAccess: projectOverride } } + : { + [projectId]: { + ...(projectOverride.browser !== undefined + ? { enableAgentBrowserAccess: projectOverride.browser } + : {}), + ...(projectOverride.device !== undefined + ? { enableAgentDeviceAccess: projectOverride.device } + : {}), + }, + }, }), ), Layer.provide(serverConfigTestLayer), @@ -4965,4 +5075,29 @@ describe("agent browser access", () => { assert.deepEqual(issued, [{ threadId, capabilities: ["preview", "pull-requests"] }]); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("a project device override grants device access when the environment denies it", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-project-device-on"); + const issued = yield* startSessionWith({ browser: false, device: false }, threadId, { + device: true, + }); + assert.deepEqual(issued, [{ threadId, capabilities: ["device", "pull-requests"] }]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + // Without orchestration the project cannot be resolved, so an overridden + // capability is withheld; one no project overrides keeps its environment value. + it.effect("withholds only the overridden capability when the project cannot be resolved", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-no-orchestration-device-override"); + const issued = yield* startSessionWith( + { browser: true, device: true }, + threadId, + { device: false }, + { withoutOrchestration: true }, + ); + assert.deepEqual(issued, [{ threadId, capabilities: ["preview", "pull-requests"] }]); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index d04dcae7f126..17ece3767792 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -28,16 +28,18 @@ import { ProviderUploadFeedbackInput, ThreadId, TurnId, + type ProjectId, type ProviderInstanceId, type ProviderDriverKind, type ProviderRuntimeEvent, type ProviderSession, + type ServerSettings as ServerSettingsValue, } from "@t3tools/contracts"; import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { causeErrorTag } from "@t3tools/shared/observability"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -864,34 +866,40 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( * "off" silently becoming "on" would violate the user's stated choice, * whereas the reverse costs an agent one toolset and is visible immediately. */ - const agentBrowserAccessEnabled = Effect.fn("ProviderService.agentBrowserAccessEnabled")( + const agentAccessSettings = Effect.fn("ProviderService.agentAccessSettings")( function* (threadId: ThreadId) { const settings = yield* serverSettings.getSettings; - if (Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0) { - return settings.enableAgentBrowserAccess; - } + const entries = Object.values(settings.projectSettingsOverrides); + const browserOverridden = entries.some( + (entry) => entry.enableAgentBrowserAccess !== undefined, + ); + const deviceOverridden = entries.some((entry) => entry.enableAgentDeviceAccess !== undefined); + const environment = { + browser: settings.enableAgentBrowserAccess, + device: settings.enableAgentDeviceAccess, + }; + if (!browserOverridden && !deviceOverridden) return environment; // Provider-only runtimes may omit orchestration. An unresolved project - // must not bypass an explicit browser override. - if (Option.isNone(projectionQuery)) return false; + // must not bypass an explicit project override, but a capability no + // project overrides keeps its environment value. + const denied = { + browser: browserOverridden ? false : environment.browser, + device: deviceOverridden ? false : environment.device, + }; + if (Option.isNone(projectionQuery)) return denied; const thread = yield* projectionQuery.value.getThreadShellById(threadId); - if (Option.isNone(thread)) return false; - return resolveProjectAgentBrowserAccess(settings, thread.value.projectId); + if (Option.isNone(thread)) return denied; + const resolved = resolveProjectSettings(settings, thread.value.projectId).settings; + return { + browser: resolved.enableAgentBrowserAccess, + device: resolved.enableAgentDeviceAccess, + }; }, Effect.catch((cause) => Effect.logWarning( - "Could not read server settings; withholding agent browser access for this session.", - { cause }, - ).pipe(Effect.as(false)), - ), - ); - - const agentDeviceAccessEnabled = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.enableAgentDeviceAccess), - Effect.catch((cause) => - Effect.logWarning( - "Could not read server settings; withholding agent device access for this session.", + "Could not read server settings; withholding agent browser and device access for this session.", { cause }, - ).pipe(Effect.as(false)), + ).pipe(Effect.as({ browser: false, device: false })), ), ); @@ -899,8 +907,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( threadId: ThreadId, ) { const capabilities = new Set(["pull-requests"]); - if (yield* agentBrowserAccessEnabled(threadId)) capabilities.add("preview"); - if (yield* agentDeviceAccessEnabled) capabilities.add("device"); + const access = yield* agentAccessSettings(threadId); + if (access.browser) capabilities.add("preview"); + if (access.device) capabilities.add("device"); return capabilities; }); @@ -1091,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); } @@ -1997,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); @@ -2167,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, @@ -2224,10 +2280,30 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); const runStopAll = Effect.fn("runStopAll")(function* () { - const continueAfterRestart = yield* serverSettings.getSettings.pipe( - Effect.map((settings) => settings.continueThreadsAfterServerUpdate), - Effect.orElseSucceed(() => false), + // Continuation is project-scopable, so decide it per session's project; + // without orchestration the environment value is all there is. + const stopSettings = yield* serverSettings.getSettings.pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), ); + const continueAfterRestartFor = Effect.fn("continueAfterRestartFor")(function* ( + threadId: ThreadId, + ) { + if (Option.isNone(stopSettings)) return false; + const settings = stopSettings.value; + const overridden = Object.values(settings.projectSettingsOverrides).some( + (entry) => entry.continueThreadsAfterServerUpdate !== undefined, + ); + if (!overridden || Option.isNone(projectionQuery)) { + return settings.continueThreadsAfterServerUpdate; + } + const thread = yield* projectionQuery.value + .getThreadShellById(threadId) + .pipe(Effect.orElseSucceed(() => Option.none<{ projectId: ProjectId }>())); + if (Option.isNone(thread)) return settings.continueThreadsAfterServerUpdate; + return resolveProjectSettings(settings, thread.value.projectId).settings + .continueThreadsAfterServerUpdate; + }); const properties = yield* Ref.modify(turnAnalytics, (state) => { const completed: Array>> = []; for (const [sessionKey, session] of state.sessions) { @@ -2253,15 +2329,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions))); yield* Effect.forEach(activeSessions, (session) => - Effect.flatMap(nowIso, (lastRuntimeEventAt) => - upsertSessionBinding(session, session.threadId, { - ...(continueAfterRestart && session.status === "running" && session.activeTurnId + Effect.gen(function* () { + const continueAfterRestart = + session.status === "running" && session.activeTurnId + ? yield* continueAfterRestartFor(session.threadId) + : false; + const lastRuntimeEventAt = yield* nowIso; + yield* upsertSessionBinding(session, session.threadId, { + ...(continueAfterRestart && session.activeTurnId ? { continueAfterServerUpdate: session.activeTurnId } : {}), lastRuntimeEvent: "provider.stopAll", lastRuntimeEventAt, - }), - ), + }); + }), ).pipe(Effect.asVoid); yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); 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 15000630b359..f5ad15dd8586 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -19,6 +19,7 @@ import { type ProviderDriverKind, type ServerProviderModel, } from "@t3tools/contracts"; +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 d53367a90640..40ae0eeefde0 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; @@ -209,6 +210,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 0426df44bcea..60c93c2426cb 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,5 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_MODEL, + DEFAULT_SERVER_SETTINGS, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -40,27 +46,43 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che }; }), } as unknown as GitVcsDriver.GitVcsDriver["Service"]; - const project = (workspaceRoot: string, autoPull = true) => - ({ id: ProjectId.make(workspaceRoot), workspaceRoot, autoPull }) as never; - - yield* ServerRuntimeStartup.autoPullProjects([ - project("/clean"), - project("/current"), - project("/dirty"), - project("/ahead"), - project("/feature"), - project("/disabled", false), - ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); + const project = (workspaceRoot: string) => + ({ id: ProjectId.make(workspaceRoot), workspaceRoot }) as never; + const overrides = (entries: Record) => ({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: Object.fromEntries( + Object.entries(entries).map(([root, defaultAutoPull]) => [ + ProjectId.make(root), + { defaultAutoPull }, + ]), + ), + }); + + yield* ServerRuntimeStartup.autoPullProjects( + [ + project("/clean"), + project("/current"), + project("/dirty"), + project("/ahead"), + project("/feature"), + project("/disabled"), + ], + overrides({ + "/clean": true, + "/current": true, + "/dirty": true, + "/ahead": true, + "/feature": true, + "/disabled": false, + }), + ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); assert.deepStrictEqual(pulled, ["/clean"]); pulled.length = 0; yield* ServerRuntimeStartup.autoPullProjects( - [project("/inherited", false), project("/opted-out"), project("/dirty", false)], - { - defaultAutoPull: true, - projectAutoPullOverrides: { [ProjectId.make("/opted-out")]: false }, - }, + [project("/inherited"), project("/opted-out"), project("/dirty")], + { ...overrides({ "/opted-out": false }), defaultAutoPull: true }, ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); assert.deepStrictEqual(pulled, ["/inherited"]); }), @@ -203,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; @@ -220,10 +267,25 @@ 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 })), + Effect.provide( + ServerSettings.layerTest({ + defaultModelSelection: machineSelection, + defaultRuntimeMode: machineMode, + projectSettingsOverrides: + existing && projectSelection + ? { + [ProjectId.make("existing-project")]: { + defaultModelSelection: projectSelection, + ...(projectMode ? { defaultRuntimeMode: projectMode } : {}), + }, + } + : {}, + }), + ), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -244,7 +306,7 @@ it.effect.each([ id: ProjectId.make("existing-project"), title: "Startup Project", workspaceRoot: "/tmp/startup-project", - defaultModelSelection: projectSelection, + defaultModelSelection: null, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -289,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 3d04abaa1914..ec820e2f0e6f 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -3,6 +3,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_SERVER_SETTINGS, + type ServerSettings as ServerSettingsValue, type ModelSelection, type OrchestrationProjectShell, ProjectId, @@ -10,7 +11,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; @@ -229,7 +230,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { nextProjectId = existingProject.value.id; bootstrapProjectId = nextProjectId; nextThreadModelSelection = - existingProject.value.defaultModelSelection ?? defaultModelSelection; + resolveProjectSettings(settings, nextProjectId, existingProject.value).settings + .defaultModelSelection ?? defaultModelSelection; } yield* Effect.gen(function* () { @@ -246,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, @@ -479,14 +482,19 @@ export const reconcileProviderSessions = Effect.gen(function* () { const providerService = yield* ProviderService.ProviderService; const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const settings = yield* ServerSettings.ServerSettingsService; - const continueAfterRestart = yield* settings.getSettings.pipe( - Effect.map((value) => value.continueThreadsAfterServerUpdate), + const restartSettings = yield* settings.getSettings.pipe( + Effect.map(Option.some), Effect.catch((cause) => Effect.logWarning("could not read restart continuation preference", { cause }).pipe( - Effect.as(false), + Effect.as(Option.none()), ), ), ); + const continueAfterRestartFor = (projectId: ProjectId) => + Option.isSome(restartSettings) + ? resolveProjectSettings(restartSettings.value, projectId).settings + .continueThreadsAfterServerUpdate + : false; const liveThreadIds = new Set( (yield* providerService.listSessions()).map((session) => session.threadId), @@ -568,7 +576,7 @@ export const reconcileProviderSessions = Effect.gen(function* () { // Runtime events advance the projection's turn, but not the directory's // last admitted turn. Use the projection to identify interrupted work. const interruptedByRestart = - continueAfterRestart && + continueAfterRestartFor(thread.projectId) && session.status === "running" && session.activeTurnId !== null && Option.isSome(binding) && @@ -742,16 +750,13 @@ interface StartupOptions { export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( projects: ReadonlyArray, - settings: Pick< - typeof DEFAULT_SERVER_SETTINGS, - "defaultAutoPull" | "projectAutoPullOverrides" - > = DEFAULT_SERVER_SETTINGS, + settings: ServerSettingsValue = DEFAULT_SERVER_SETTINGS, ) { const git = yield* GitVcsDriver.GitVcsDriver; const workspaceRoots = [ ...new Set( projects - .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull)) + .filter((project) => resolveProjectSettings(settings, project.id).settings.defaultAutoPull) .map((project) => project.workspaceRoot), ), ]; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 5d2571e72dc3..208d75fb6517 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,6 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, + ModelSelection, + ProjectId, + ProjectScript, ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, @@ -1279,4 +1282,113 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.include(persisted, '"valueRedacted": true'); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("folds legacy project overrides into projectSettingsOverrides once", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const sql = yield* SqlClient.SqlClient; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const legacyProject = ProjectId.make("project-legacy"); + const scriptedProject = ProjectId.make("project-scripted"); + const script: ProjectScript = { + id: "check", + name: "Check", + command: "npm test", + icon: "play", + runOnWorktreeCreate: false, + }; + const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5"); + const modelJson = yield* Schema.encodeEffect(Schema.fromJsonString(ModelSelection))(model); + const scriptsJson = yield* Schema.encodeEffect( + Schema.fromJsonString(Schema.Array(ProjectScript)), + )([script]); + for (const [projectId, modelColumn, envMode, autoPull, scripts] of [ + // The legacy project also carries aggregate scripts, but its stored + // null override reset them; the fold must not bring them back. + [legacyProject, modelJson, "worktree", 1, scriptsJson], + [scriptedProject, null, null, 0, scriptsJson], + ] as const) { + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, default_model_selection_json, + default_thread_env_mode, auto_pull, scripts_json, created_at, updated_at + ) + VALUES ( + ${projectId}, ${"Project"}, ${`/tmp/${projectId}`}, ${modelColumn}, + ${envMode}, ${autoPull}, ${scripts}, + ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"} + ) + `; + } + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + `{"projectAgentBrowserAccessOverrides":{"${legacyProject}":false},"projectAutoPullOverrides":{"${scriptedProject}":true},"projectScriptOverrides":{"${legacyProject}":null}}`, + ); + + const settings = yield* serverSettings.getSettings; + assert.isTrue(settings.projectSettingsFolded); + assert.deepEqual( + settings.projectSettingsOverrides, + { + [legacyProject]: { + enableAgentBrowserAccess: false, + defaultModelSelection: model, + defaultThreadEnvMode: "worktree", + defaultAutoPull: true, + }, + [scriptedProject]: { defaultAutoPull: true, defaultProjectScripts: [script] }, + }, + ); + // Derived legacy views keep older clients reading the same values. + assert.deepEqual( + settings.projectAutoPullOverrides, + { + [legacyProject]: true, + [scriptedProject]: true, + }, + ); + assert.deepEqual(settings.projectScriptOverrides, { + [scriptedProject]: [script], + }); + + // A reset survives the next load: the fold does not run again. + yield* serverSettings.updateSettings({ + projectSettingsOverrides: { [legacyProject]: null }, + }); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + const persisted = yield* decodeServerSettings( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.parse(raw), + ); + assert.isTrue(persisted.projectSettingsFolded); + assert.isUndefined(persisted.projectSettingsOverrides[legacyProject]); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("leaves an unreadable settings.json untouched instead of folding over it", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const sql = yield* SqlClient.SqlClient; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, auto_pull, scripts_json, created_at, updated_at + ) + VALUES ( + ${"project-broken"}, ${"Project"}, ${"/tmp/project-broken"}, ${1}, ${"[]"}, + ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"} + ) + `; + const broken = '{"defaultAutoPull": tru'; + yield* fileSystem.writeFileString(serverConfig.settingsPath, broken); + + const settings = yield* serverSettings.getSettings; + assert.isFalse(settings.projectSettingsFolded); + assert.deepEqual(settings.projectSettingsOverrides, {}); + // The user's file is still there to repair; nothing was written over it. + assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), broken); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 0b64d445adf8..50f8649eaacb 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -15,7 +15,9 @@ import { DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, - type ModelSelection, + ModelSelection, + ProjectScript, + type ProjectSettingsOverrides, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, type UsageLimitSourceConfig, @@ -51,6 +53,7 @@ import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { applyServerSettingsPatch, + deriveLegacyProjectOverrides, isModelSelectionProviderEnabled, } from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -118,6 +121,7 @@ const normalizeServerSettings = ( encodeServerSettings(settings).pipe( Effect.flatMap(decodeServerSettings), Effect.map(foldProviderInstanceEnabledFlags), + Effect.map((next) => ({ ...next, ...deriveLegacyProjectOverrides(next) })), Effect.mapError( (cause) => new ServerSettingsError({ @@ -353,6 +357,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "providerHealthRefreshInterval", "sourceControlWriterModelSelection", "textGenerationModelSelection", + "pullRequestMergeMethod", ]); // Preserve both enabled states because provider history cannot recover a new opt-in. @@ -400,6 +405,93 @@ function stripDefaultServerSettings(current: unknown, defaults: unknown): unknow return Object.is(current, defaults) ? undefined : current; } +const decodeProjectScriptsJson = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Array(ProjectScript)), +); +const decodeModelSelectionJson = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.NullOr(ModelSelection)), +); + +interface LegacyProjectSettingsRow { + readonly projectId: string; + readonly defaultModelSelection: string | null; + readonly defaultThreadEnvMode: string | null; + readonly autoPull: number; + readonly scripts: string; +} + +/** + * One-time fold of the legacy per-project fields into `projectSettingsOverrides`: + * the three `project*Overrides` maps and the settings columns on the project + * aggregate. Keys already present in the generic record win. Marked with + * `projectSettingsFolded` so a later reset in the UI survives restarts. + */ +function foldLegacyProjectSettings( + settings: ServerSettings, + rows: ReadonlyArray, +): ServerSettings { + if (settings.projectSettingsFolded) return settings; + // Nothing to fold yet (fresh install): leave the marker off so the file + // stays sparse, and check again on the next load. + if ( + rows.length === 0 && + Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0 && + Object.keys(settings.projectAutoPullOverrides).length === 0 && + Object.keys(settings.projectScriptOverrides).length === 0 + ) { + return settings; + } + const entries: Record = { + ...settings.projectSettingsOverrides, + }; + const set = ( + projectId: string, + key: K, + value: ProjectSettingsOverrides[K] | undefined, + ) => { + if (value === undefined) return; + const entry = entries[projectId] ?? {}; + if (Object.hasOwn(entry, key)) return; + entries[projectId] = { ...entry, [key]: value }; + }; + for (const [projectId, value] of Object.entries(settings.projectAgentBrowserAccessOverrides)) { + set(projectId, "enableAgentBrowserAccess", value); + } + for (const [projectId, value] of Object.entries(settings.projectAutoPullOverrides)) { + set(projectId, "defaultAutoPull", value); + } + // A stored null meant "reset to machine defaults", which is now plain + // inheritance; the project's own aggregate scripts must not resurface. + const resetScripts = new Set(); + for (const [projectId, value] of Object.entries(settings.projectScriptOverrides)) { + if (value === null) resetScripts.add(projectId); + else set(projectId, "defaultProjectScripts", value); + } + for (const row of rows) { + const model = decodeModelSelectionJson(row.defaultModelSelection ?? "null"); + if (Option.isSome(model) && model.value !== null) { + set(row.projectId, "defaultModelSelection", model.value); + } + if (row.defaultThreadEnvMode === "local" || row.defaultThreadEnvMode === "worktree") { + set(row.projectId, "defaultThreadEnvMode", row.defaultThreadEnvMode); + } + if (row.autoPull === 1) set(row.projectId, "defaultAutoPull", true); + const scripts = decodeProjectScriptsJson(row.scripts); + if (Option.isSome(scripts) && scripts.value.length > 0 && !resetScripts.has(row.projectId)) { + set(row.projectId, "defaultProjectScripts", scripts.value); + } + } + const projectSettingsOverrides = Object.fromEntries( + Object.entries(entries).filter(([, entry]) => Object.keys(entry).length > 0), + ); + return { + ...settings, + projectSettingsOverrides, + projectSettingsFolded: true, + ...deriveLegacyProjectOverrides({ projectSettingsOverrides }), + }; +} + const make = Effect.gen(function* () { const { settingsPath } = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; @@ -439,9 +531,36 @@ const make = Effect.gen(function* () { ), ); + const writeSettingsAtomically = Effect.fnUntraced( + function* (settings: ServerSettings) { + const sparseSettingsJson = yield* encodeServerSettingsJson( + stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, + ); + + return yield* writeFileStringAtomically({ + filePath: settingsPath, + contents: `${sparseSettingsJson}\n`, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, pathService), + ); + }, + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "write-file", + cause, + }), + ), + ); + const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; let persisted: typeof PersistedOptionalProviderSettings.Type = {}; + // A file that failed to decode must stay on disk for the user to repair; + // the fold below only writes when it started from the file's real contents. + let settingsFileTrusted = true; if (yield* readConfigExists) { const raw = yield* readRawConfig; @@ -452,6 +571,7 @@ const make = Effect.gen(function* () { } if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") { const failure = decoded._tag === "Failure" ? decoded : persistedSettings; + settingsFileTrusted = false; if (failure._tag === "Failure") { yield* Effect.logWarning("failed to parse settings.json, using defaults", { path: settingsPath, @@ -490,9 +610,39 @@ const make = Effect.gen(function* () { ), ); - return foldProviderInstanceEnabledFlags( + const legacyProjectRows = + settings.projectSettingsFolded || !settingsFileTrusted + ? [] + : yield* sql` + SELECT + project_id AS "projectId", + default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + auto_pull AS "autoPull", + scripts_json AS "scripts" + FROM projection_projects + WHERE deleted_at IS NULL + `.pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-project-settings", + cause, + }), + ), + ); + + const loaded = foldProviderInstanceEnabledFlags( restoreUsedProviders(settings, persisted, providerHistory), ); + const folded = settingsFileTrusted + ? foldLegacyProjectSettings(loaded, legacyProjectRows) + : loaded; + if (folded !== loaded) { + yield* writeSettingsAtomically(folded); + } + return folded; }); const settingsCache = yield* Cache.make({ @@ -738,30 +888,6 @@ const make = Effect.gen(function* () { }; }); - const writeSettingsAtomically = Effect.fnUntraced( - function* (settings: ServerSettings) { - const sparseSettingsJson = yield* encodeServerSettingsJson( - stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, - ); - - return yield* writeFileStringAtomically({ - filePath: settingsPath, - contents: `${sparseSettingsJson}\n`, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, pathService), - ); - }, - Effect.mapError( - (cause) => - new ServerSettingsError({ - settingsPath, - operation: "write-file", - cause, - }), - ), - ); - const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { yield* Cache.invalidate(settingsCache, cacheKey); 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/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index b9fc9e7ee3ab..6668cc6a0ff5 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -22,7 +22,7 @@ import type { VcsStatusStreamEvent, } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; -import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; @@ -160,7 +160,7 @@ export const autoPullPolicyLayer = Layer.effect( const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd); if (project._tag === "None") return false; const settings = yield* serverSettings.getSettings; - return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull); + return resolveProjectSettings(settings, project.value.id).settings.defaultAutoPull; }, Effect.orElseSucceed(() => false), ), 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 973dd5749027..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", () => { @@ -126,7 +133,7 @@ describe("floating browser preview", () => { const ref = scopeThreadRef(EnvironmentId.make("env-1"), ThreadId.make("thread-1")); const panels = useRightPanelStore.getState(); const revision = panels.getUserActionRevision(ref); - usePreviewMiniPlayerStore.getState().open(ref, "agent-tab"); + usePreviewMiniPlayerStore.getState().open(ref, { kind: "browser", tabId: "agent-tab" }); panels.reconcileBrowserSurfaces(ref, ["agent-tab"]); const intent = selectThreadPreviewMiniPlayer( usePreviewMiniPlayerStore.getState().byThreadKey, @@ -135,7 +142,7 @@ describe("floating browser preview", () => { const isFloating = () => shouldRenderPreviewMiniPlayer( selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, ref) - ?.tabId ?? null, + ?.source ?? null, selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref), ); @@ -152,22 +159,61 @@ describe("floating browser preview", () => { }); it("only hides the duplicate while the same browser is rendered in the panel", () => { + const tab = { kind: "browser", tabId: "tab-1" } as const; expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false); expect( - shouldRenderPreviewMiniPlayer("tab-1", { + shouldRenderPreviewMiniPlayer(tab, { id: "browser:one", kind: "preview", resourceId: "tab-1", }), ).toBe(false); expect( - shouldRenderPreviewMiniPlayer("tab-1", { + shouldRenderPreviewMiniPlayer(tab, { id: "browser:two", kind: "preview", resourceId: "tab-2", }), ).toBe(true); - expect(shouldRenderPreviewMiniPlayer("tab-1", { id: "diff", kind: "diff" })).toBe(true); + expect(shouldRenderPreviewMiniPlayer(tab, { id: "diff", kind: "diff" })).toBe(true); + }); + + it("only hides a floating device while that device is rendered in the panel", () => { + const pixel = { + kind: "device", + hostId: "nucbox", + deviceId: "emulator-5580", + platform: "android", + name: "Pixel", + } as const; + const target = { + hostId: "nucbox", + deviceId: "emulator-5580", + platform: "android", + name: "Pixel", + } as const; + expect( + shouldRenderPreviewMiniPlayer(pixel, { + id: "device:nucbox:emulator-5580", + kind: "device", + target, + }), + ).toBe(false); + expect( + shouldRenderPreviewMiniPlayer(pixel, { + id: "device:nucbox:emulator-5554", + kind: "device", + target: { ...target, deviceId: "emulator-5554" }, + }), + ).toBe(true); + expect(shouldRenderPreviewMiniPlayer(pixel, { id: "device", kind: "device" })).toBe(true); + expect( + shouldRenderPreviewMiniPlayer(pixel, { + id: "browser:one", + kind: "preview", + resourceId: "emulator-5580", + }), + ).toBe(true); }); }); @@ -2203,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 772a0f3cf2fa..b916862ef59c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -50,6 +50,7 @@ import { import type { DraftThreadEnvMode } from "../composerDraftStore"; import type { ComposerSubmissionIntent } from "../composer-logic"; import type { TimelineEntry } from "../session-logic"; +import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore"; import type { DesktopPreviewOverlay } from "../previewStateStore"; import type { RightPanelSurface } from "../rightPanelStore"; import { @@ -88,16 +89,22 @@ export function agentControlledBrowserCloseConfirmation( ].join("\n"); } +/** The floating player hides only while the same source is rendered in the panel. */ export function shouldRenderPreviewMiniPlayer( - miniPlayerTabId: string | null, + source: PreviewMiniPlayerSource | null, renderedRightPanelSurface: RightPanelSurface | null, ): boolean { - return ( - miniPlayerTabId !== null && - !( + if (source === null) return false; + if (source.kind === "browser") { + return !( renderedRightPanelSurface?.kind === "preview" && - renderedRightPanelSurface.resourceId === miniPlayerTabId - ) + renderedRightPanelSurface.resourceId === source.tabId + ); + } + return !( + renderedRightPanelSurface?.kind === "device" && + renderedRightPanelSurface.target?.hostId === source.hostId && + renderedRightPanelSurface.target.deviceId === source.deviceId ); } @@ -730,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; @@ -1098,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 51b9c5eabc48..45207934592f 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, @@ -68,6 +69,7 @@ import { projectScriptRuntimeEnv, resolveProjectScripts, } from "@t3tools/shared/projectScripts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { @@ -152,7 +154,6 @@ import { } from "../proposedPlan"; import { DEFAULT_INTERACTION_MODE, - DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, @@ -194,6 +195,8 @@ import { useSidebarPendingFileDropStore, } from "../sidebarPendingFileDropStore"; import { + browserMiniPlayerSource, + previewMiniPlayerSourceKey, selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; @@ -225,7 +228,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"; @@ -304,7 +307,6 @@ import { environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, - primaryServerSettingsAtom, serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; @@ -408,6 +410,8 @@ import { deriveLockedProvider, readFileAsDataUrl, resolveFileAttachmentUrl, + prepareRevertedMessageAttachments, + waitForRevertedMessage, reconcileMountedTerminalThreadIds, recallCheckoutIsRepo, rememberCheckoutIsRepo, @@ -483,7 +487,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[] = []; @@ -566,6 +573,8 @@ const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), ); const DiffPanel = lazy(() => import("./DiffPanel")); +const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => + settings.browserAutoShowFloatingPreview; const DevicePanel = lazy(() => import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), ); @@ -1423,6 +1432,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, }); @@ -1516,7 +1532,6 @@ export default function ChatView(props: ChatViewProps) { }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); - const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -1638,7 +1653,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, ); @@ -1691,8 +1708,6 @@ export default function ChatView(props: ChatViewProps) { [], ); const [composerOverlayElement, setComposerOverlayElement] = useState(null); - const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); - const composerOverlayHeightRef = useRef(0); // Space the timeline keeps clear above its end. Tracks the overlay while the // composer is expanded and holds that height while it rests, so the resting // composer never exposes rows that its expansion will cover. @@ -1802,17 +1817,14 @@ export default function ChatView(props: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.defaultModelSelection ?? - settings.defaultModelSelection ?? - NO_PROVIDER_MODEL_SELECTION, + resolveProjectSettings( + settings, + fallbackDraftProject?.id ?? null, + fallbackDraftProject ?? undefined, + ).settings.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, ) : undefined, - [ - draftThread, - fallbackDraftProject?.defaultModelSelection, - settings.defaultModelSelection, - threadId, - ], + [draftThread, fallbackDraftProject, settings, threadId], ); // Promotion is data-driven: the draft route keeps rendering while the // server thread (same pre-allocated ref) starts, so live state must not @@ -1840,7 +1852,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; @@ -1962,7 +1978,7 @@ export default function ChatView(props: ChatViewProps) { const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( - activePreviewMiniPlayer?.tabId ?? null, + activePreviewMiniPlayer?.source ?? null, renderedRightPanelSurface, ); const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; @@ -1978,8 +1994,10 @@ export default function ChatView(props: ChatViewProps) { }, [activePreviewState.sessions, activeThreadRef]); useEffect(() => { - if (!activeThreadRef || !activePreviewMiniPlayer) return; - const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]); + if (!activeThreadRef || activePreviewMiniPlayer?.source.kind !== "browser") return; + const miniTabStillExists = Boolean( + activePreviewState.sessions[activePreviewMiniPlayer.source.tabId], + ); if (!miniTabStillExists) { usePreviewMiniPlayerStore.getState().close(activeThreadRef); } @@ -2033,12 +2051,16 @@ export default function ChatView(props: ChatViewProps) { [activeThread?.environmentId, activeThread?.projectId], ); const activeProject = useProject(activeProjectRef); + // Environment settings with the active project's overrides applied. + const activeProjectSettings = useMemo( + () => resolveProjectSettings(settings, activeProject?.id ?? null, activeProject ?? undefined), + [activeProject, settings], + ); const activeProjectScripts = useMemo( () => (activeProject ? resolveProjectScripts(settings, activeProject) : []), [activeProject, settings], ); - const activeProjectDefaultModelSelection = - activeProject?.defaultModelSelection ?? settings.defaultModelSelection; + const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); @@ -2288,7 +2310,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, }); @@ -2307,6 +2330,7 @@ export default function ChatView(props: ChatViewProps) { navigate, projectGroupingSettings, routeKind, + settings, setDraftThreadContext, setLogicalProjectDraftThreadId, ], @@ -3945,6 +3969,9 @@ export default function ChatView(props: ChatViewProps) { ], ); + const supportsProjectSettingsOverrides = + environmentById.get(environmentId)?.serverConfig?.environment.capabilities + .projectSettingsOverrides === true; const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; @@ -3958,11 +3985,22 @@ export default function ChatView(props: ChatViewProps) { await updateProjectScriptSettings({ environmentId, input: { - patch: { - projectScriptOverrides: { - [input.projectId]: input.nextScripts, - }, - }, + // The canonical key on servers that understand it; the legacy + // per-project map is still translated on older ones. + patch: supportsProjectSettingsOverrides + ? { + projectSettingsOverrides: { + [input.projectId]: { + ...settings.projectSettingsOverrides[input.projectId], + defaultProjectScripts: input.nextScripts, + }, + }, + } + : { + projectScriptOverrides: { + [input.projectId]: input.nextScripts, + }, + }, }, }), () => undefined, @@ -3987,7 +4025,13 @@ export default function ChatView(props: ChatViewProps) { } return updateResult; }, - [environmentId, updateProjectScriptSettings, upsertKeybinding], + [ + environmentId, + settings.projectSettingsOverrides, + supportsProjectSettingsOverrides, + updateProjectScriptSettings, + upsertKeybinding, + ], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput): Promise> => { @@ -4179,10 +4223,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, ); @@ -4195,9 +4243,13 @@ export default function ChatView(props: ChatViewProps) { } useRightPanelStore.getState().open(activeThreadRef, "device"); }, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]); - // Reconcile new server sessions into separate tabs, including sessions opened - // by an agent or another client. The first snapshot is a baseline: persisted - // tabs restore themselves, and existing sessions must not resurrect closed tabs. + // A device the agent opens floats over chat like an agent-driven browser, + // or becomes a panel tab when floating previews are off. Sessions opened by + // another client arrive the same way; sheet layouts get neither. The first + // snapshot is a baseline: persisted tabs restore themselves, and existing + // sessions must not resurrect closed tabs. A session whose device summary + // has not arrived yet stays out of the baseline so a later snapshot opens it. + const autoShowFloatingPreview = useClientSettings(selectAutoShowFloatingPreview); const previousDeviceSessions = useRef(new Map>()); useEffect(() => { if (!activeThreadRef || !deviceStateLoaded) return; @@ -4206,11 +4258,30 @@ export default function ChatView(props: ChatViewProps) { (session) => session.threadId === activeThreadRef.threadId, ); const key = (session: (typeof sessions)[number]) => `${session.hostId}:${session.deviceId}`; + const deviceFor = (session: (typeof sessions)[number]) => + deviceState.devices.find( + (entry) => entry.hostId === session.hostId && entry.id === session.deviceId, + ); const previous = previousDeviceSessions.current.get(threadKey); - previousDeviceSessions.current.set(threadKey, new Set(sessions.map(key))); + previousDeviceSessions.current.set( + threadKey, + new Set(sessions.filter((session) => deviceFor(session) !== undefined).map(key)), + ); if (!previous || shouldUseRightPanelSheet) return; for (const session of sessions) { - if (previous?.has(key(session))) continue; + if (previous.has(key(session))) continue; + const device = deviceFor(session); + if (!device) continue; + const target = { + hostId: session.hostId, + deviceId: session.deviceId, + platform: device.platform, + name: device.name, + }; + if (autoShowFloatingPreview) { + usePreviewMiniPlayerStore.getState().open(activeThreadRef, { kind: "device", ...target }); + continue; + } const existing = useRightPanelStore .getState() .byThreadKey[scopedThreadKey(activeThreadRef)]?.surfaces.some( @@ -4220,28 +4291,30 @@ export default function ChatView(props: ChatViewProps) { surface.target.deviceId === session.deviceId, ); if (existing) continue; - const device = deviceState.devices.find( - (entry) => entry.hostId === session.hostId && entry.id === session.deviceId, - ); - if (!device) continue; - useRightPanelStore.getState().openDevice( - activeThreadRef, - { - hostId: session.hostId, - deviceId: session.deviceId, - platform: device.platform, - name: device.name, - }, - true, - ); + useRightPanelStore.getState().openDevice(activeThreadRef, target, true); } }, [ activeThreadRef, + autoShowFloatingPreview, deviceStateLoaded, shouldUseRightPanelSheet, deviceState.sessions, deviceState.devices, ]); + // A floating device follows its session: once the agent or another client + // closes the device there is nothing left to stream. + useEffect(() => { + if (!activeThreadRef || !deviceStateLoaded) return; + const source = activePreviewMiniPlayer?.source; + if (source?.kind !== "device") return; + const sessionStillExists = deviceState.sessions.some( + (session) => + session.threadId === activeThreadRef.threadId && + session.hostId === source.hostId && + session.deviceId === source.deviceId, + ); + if (!sessionStillExists) usePreviewMiniPlayerStore.getState().close(activeThreadRef); + }, [activePreviewMiniPlayer, activeThreadRef, deviceState.sessions, deviceStateLoaded]); const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; @@ -4401,10 +4474,15 @@ export default function ChatView(props: ChatViewProps) { ]); const closePreviewPanel = useCallback(() => { if (activeThreadRef) { + // Closing the panel on a live browser or device floats it instead of dropping it. if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) { usePreviewMiniPlayerStore .getState() - .open(activeThreadRef, activeRightPanelSurface.resourceId); + .open(activeThreadRef, browserMiniPlayerSource(activeRightPanelSurface.resourceId)); + } else if (activeRightPanelSurface?.kind === "device" && activeRightPanelSurface.target) { + usePreviewMiniPlayerStore + .getState() + .open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target }); } setMaximizedRightPanelThreadKey(null); useRightPanelStore.getState().close(activeThreadRef); @@ -5288,10 +5366,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(() => { @@ -5396,7 +5470,7 @@ export default function ChatView(props: ChatViewProps) { ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? - primaryServerSettings.newWorktreesStartFromOrigin) + activeProjectSettings.settings.newWorktreesStartFromOrigin) : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, @@ -5437,11 +5511,6 @@ export default function ChatView(props: ChatViewProps) { (height: number) => { const nextHeight = Math.ceil(height); if (nextHeight <= 0) return; - const previousHeight = composerOverlayHeightRef.current; - if (previousHeight !== nextHeight) { - composerOverlayHeightRef.current = nextHeight; - setComposerOverlayHeight(nextHeight); - } const nextInset = resolveComposerTimelineInset({ currentInset: composerTimelineInsetRef.current, overlayHeight: nextHeight, @@ -6465,9 +6534,11 @@ export default function ChatView(props: ChatViewProps) { }, [activeThreadId, composerRef]); const onRevertToTurnCount = useCallback( - async (turnCount: number) => { + async (turnCount: number, messageId: MessageId) => { 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( @@ -6489,9 +6560,9 @@ export default function ChatView(props: ChatViewProps) { } 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.", + "Edit from here?", + "Rewind files and chat to before this message.", + "Your prompt and attachments return to the composer.", ].join("\n"), { variant: "destructive" }, ); @@ -6499,34 +6570,102 @@ export default function ChatView(props: ChatViewProps) { 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 }, + }); + 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, ], @@ -6644,6 +6783,7 @@ export default function ChatView(props: ChatViewProps) { !activeThread || isSendBusy || isConnecting || + isRevertingCheckpoint || !clientSettingsHydrated || threadDetailLoading || sendInFlightRef.current || @@ -7836,7 +7976,7 @@ export default function ChatView(props: ChatViewProps) { projectId: activeProject.id, title: nextThreadTitle, modelSelection: nextThreadModelSelection, - runtimeMode, + runtimeMode: defaultRuntimeMode, interactionMode: "default", branch: activeThreadBranch, worktreePath: activeThread.worktreePath, @@ -7859,7 +7999,7 @@ export default function ChatView(props: ChatViewProps) { }, modelSelection: ctxSelectedModelSelection, titleSeed: nextThreadTitle, - runtimeMode, + runtimeMode: defaultRuntimeMode, interactionMode: "default", sourceProposedPlan: { threadId: activeThread.id, @@ -7933,7 +8073,7 @@ export default function ChatView(props: ChatViewProps) { isServerThread, navigate, resetLocalDispatch, - runtimeMode, + defaultRuntimeMode, startThreadTurn, environmentId, composerRef, @@ -8045,7 +8185,7 @@ export default function ChatView(props: ChatViewProps) { envMode: mode, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: mode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin, }), ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); @@ -8057,7 +8197,7 @@ export default function ChatView(props: ChatViewProps) { composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, - primaryServerSettings.newWorktreesStartFromOrigin, + activeProjectSettings.settings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, @@ -8096,8 +8236,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 @@ -8291,7 +8431,7 @@ export default function ChatView(props: ChatViewProps) { } composerDraftTarget={composerDraftTarget} onBack={ - activeThreadRef !== null && supportsThreadPullRequests + activeThreadRef !== null && pullRequestsSurfaceAvailable ? addPullRequestsSurface : undefined } @@ -8591,6 +8731,7 @@ export default function ChatView(props: ChatViewProps) { {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */}
-
+
{isDraftHeroState ? (
) : null} @@ -8929,7 +9076,7 @@ export default function ChatView(props: ChatViewProps) { diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={isServerThread && supportsThreadPullRequests} + pullRequestsAvailable={pullRequestsSurfaceAvailable} agentsAvailable deviceAvailable={activeThreadRef !== null} liveAgentCount={agentPanelModel.liveCount} @@ -8987,7 +9134,7 @@ export default function ChatView(props: ChatViewProps) { diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={isServerThread && supportsThreadPullRequests} + pullRequestsAvailable={pullRequestsSurfaceAvailable} agentsAvailable deviceAvailable={activeThreadRef !== null} liveAgentCount={agentPanelModel.liveCount} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 4a9877f87a02..72842dd2a1dc 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,7 @@ "use client"; import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; +import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { @@ -1708,6 +1709,7 @@ function OpenCommandPaletteDialog(props: { value: "action:open-thread-pull-requests", searchTerms: ["pull requests", "linked", "stack", "prs"], title: "Show linked pull requests", + disabled: visibleThreadPullRequests(activeThread.pullRequests).length === 0, icon: , run: async () => { useRightPanelStore.getState().open(threadRef, "pull-requests"); @@ -1814,8 +1816,7 @@ function OpenCommandPaletteDialog(props: { }, }); - // There is no projects listing page; the action targets the contextual - // project (active thread/draft, falling back to the first sidebar group). + // Target the active thread or draft's project, falling back to the first sidebar group. const contextualProjectGroup = (contextualProjectRef ? projectGroupByTargetKey.get( @@ -1867,8 +1868,6 @@ function OpenCommandPaletteDialog(props: { run: async () => { await navigate({ to: item.to, - search: (previous) => - item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, hash: item.targetId ?? item.id, replace: pathname === item.to, hashScrollIntoView: false, 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 c269b73ee761..2bc0c6bab019 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, @@ -2341,6 +2343,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], @@ -4449,6 +4458,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/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index d1fc12821730..a793e8a2c8c0 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -29,7 +29,11 @@ import { reconcilePreviewServerSessions, updatePreviewServerSnapshot, } from "~/previewStateStore"; -import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { + browserMiniPlayerSource, + selectThreadPreviewMiniPlayerTabId, + usePreviewMiniPlayerStore, +} from "~/previewMiniPlayerStore"; import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; import { readActiveBrowserRecordingTargets, @@ -378,7 +382,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ?.has(runtimeTabId) ?? false, }) ) { - usePreviewMiniPlayerStore.getState().open(threadRef, readyTabId); + usePreviewMiniPlayerStore + .getState() + .open(threadRef, browserMiniPlayerSource(readyTabId)); } } browserActivity.release ??= acquireBrowserSurfaceActivity(runtimeTabId); @@ -493,11 +499,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) new Set([activeRuntimeTabId]), ); } - const miniPlayer = selectThreadPreviewMiniPlayer( + const miniPlayerTabId = selectThreadPreviewMiniPlayerTabId( usePreviewMiniPlayerStore.getState().byThreadKey, threadRef, ); - if (miniPlayer?.tabId === activeTabId) { + if (miniPlayerTabId === activeTabId) { usePreviewMiniPlayerStore.getState().close(threadRef); } } else if (shouldPresentPreview) { @@ -507,7 +513,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } } if (shouldPresentPreview) { - usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId); + usePreviewMiniPlayerStore + .getState() + .open(threadRef, browserMiniPlayerSource(activeTabId)); } if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) { await requireReadyTab(); diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 2a146834012e..ea28a93235ee 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -171,7 +171,7 @@ vi.mock("~/previewMiniPlayerStore", () => { byThreadKey: mocks.miniPlayerTabId ? { "environment-1:thread-1": { - tabId: mocks.miniPlayerTabId, + source: { kind: "browser", tabId: mocks.miniPlayerTabId }, position: null, }, } @@ -185,9 +185,10 @@ vi.mock("~/previewMiniPlayerStore", () => { }, ); return { - selectThreadPreviewMiniPlayer: ( - byThreadKey: Record, - ) => byThreadKey["environment-1:thread-1"] ?? null, + browserMiniPlayerSource: (tabId: string) => ({ kind: "browser", tabId }), + selectThreadPreviewMiniPlayerTabId: ( + byThreadKey: Record, + ) => byThreadKey["environment-1:thread-1"]?.source.tabId ?? null, usePreviewMiniPlayerStore, }; }); @@ -485,7 +486,10 @@ describe("PreviewView navigation", () => { renderToStaticMarkup(); expect(mocks.pictureInPicturePressed).toBe(false); mocks.togglePictureInPicture?.(); - expect(mocks.openMiniPlayer).toHaveBeenCalledWith(props.threadRef, "tab-1"); + expect(mocks.openMiniPlayer).toHaveBeenCalledWith(props.threadRef, { + kind: "browser", + tabId: "tab-1", + }); expect(mocks.closeRightPanel).toHaveBeenCalledWith(props.threadRef); mocks.miniPlayerTabId = "tab-1"; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index e6ad2758bc48..6086e049ab11 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -34,7 +34,11 @@ import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; import { useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useAtomCommand } from "~/state/use-atom-command"; -import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { + browserMiniPlayerSource, + selectThreadPreviewMiniPlayerTabId, + usePreviewMiniPlayerStore, +} from "~/previewMiniPlayerStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { previewBridge } from "./previewBridge"; @@ -114,8 +118,8 @@ export function PreviewView({ threadRef, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, ); - const miniPlayer = usePreviewMiniPlayerStore((state) => - selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), + const miniPlayerTabId = usePreviewMiniPlayerStore((state) => + selectThreadPreviewMiniPlayerTabId(state.byThreadKey, threadRef), ); const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); const addImage = useComposerDraftStore((store) => store.addImage); @@ -311,13 +315,13 @@ export function PreviewView({ const handlePictureInPicture = useCallback(() => { if (!tabId) return; - if (miniPlayer?.tabId === tabId) { + if (miniPlayerTabId === tabId) { usePreviewMiniPlayerStore.getState().close(threadRef); return; } - usePreviewMiniPlayerStore.getState().open(threadRef, tabId); + usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(tabId)); useRightPanelStore.getState().close(threadRef); - }, [miniPlayer?.tabId, tabId, threadRef]); + }, [miniPlayerTabId, tabId, threadRef]); const handleNativePictureInPicture = useCallback(() => { if (!previewBridge || !runtimeTabId) return; @@ -722,7 +726,7 @@ export function PreviewView({ captureDisabled={!desktopOverlay || isUnreachable} recording={recordingRuntimeTabId !== null} onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined} - pictureInPicture={miniPlayer?.tabId === tabId} + pictureInPicture={miniPlayerTabId === tabId} pictureInPictureDisabled={!desktopOverlay?.hasWebContents || isUnreachable} onPickElement={previewBridge && tabId ? handlePickElement : undefined} pickActive={pickActive} diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 4384019abbad..5249fe37401a 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -2,9 +2,20 @@ import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts"; import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; -import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef, useState } from "react"; +import { + type PointerEvent as ReactPointerEvent, + type ReactNode, + useCallback, + useLayoutEffect, + useRef, + useState, +} from "react"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; +import { + findActiveBrowserRecordingRuntimeTabId, + useActiveBrowserRecordingTabIds, +} from "~/browser/browserRecording"; import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; @@ -15,17 +26,27 @@ import { cn } from "~/lib/utils"; import { useThreadPreviewState } from "~/previewStateStore"; import { type PreviewMiniPlayerSize, - selectThreadPreviewMiniPlayer, + type PreviewMiniPlayerSource, + type PreviewMiniPlayerState, + previewMiniPlayerSourceKey, usePreviewMiniPlayerStore, } from "~/previewMiniPlayerStore"; import { useRightPanelStore } from "~/rightPanelStore"; +import { useDeviceState } from "~/state/device"; +import { DeviceStreamView } from "../device/DeviceStreamView"; +import type { DeviceScreenSize } from "../device/deviceStream"; import { previewBridge } from "./previewBridge"; import { clampPreviewMiniPlayerPosition, + NO_PREVIEW_MINI_PLAYER_OBSTACLES, + PREVIEW_MINI_PLAYER_CORNER_RADIUS, PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, type PreviewMiniPlayerFrame, + type PreviewMiniPlayerObstacles, resizePreviewMiniPlayer, + resolveDeviceMiniPlayerCornerRadius, + resolveDeviceMiniPlayerSourceSize, resolvePreviewMiniPlayerFrame, resolvePreviewMiniPlayerSourceSize, } from "./previewMiniPlayerLayout"; @@ -40,11 +61,53 @@ interface PointerGesture { interface Props { readonly threadRef: ScopedThreadRef; - readonly tabId: string; - readonly bottomInset: number; + readonly miniPlayer: PreviewMiniPlayerState; + /** The docked composer overlay; null while the composer floats mid-screen. */ + readonly composerOverlayElement: HTMLElement | null; +} + +interface Layout { + readonly container: PreviewMiniPlayerSize; + readonly obstacles: PreviewMiniPlayerObstacles; +} + +const sameLayout = (a: Layout, b: Layout) => + a.container.width === b.container.width && + a.container.height === b.container.height && + (a.obstacles.composer === b.obstacles.composer || + (a.obstacles.composer !== null && + b.obstacles.composer !== null && + a.obstacles.composer.left === b.obstacles.composer.left && + a.obstacles.composer.right === b.obstacles.composer.right && + a.obstacles.composer.height === b.obstacles.composer.height)); + +/** + * Measures the chat column and the composer in the column's coordinates. The + * composer's columns come from its centered stack, not the full-width overlay, + * so the margins beside it stay open to the player. + */ +function measureLayout(container: HTMLElement, composerOverlay: HTMLElement | null): Layout { + const containerRect = container.getBoundingClientRect(); + const stackRect = composerOverlay + ?.querySelector('[data-chat-composer-stack="true"]') + ?.getBoundingClientRect(); + const overlayRect = composerOverlay?.getBoundingClientRect(); + return { + container: { width: container.clientWidth, height: container.clientHeight }, + obstacles: { + composer: + overlayRect && stackRect && overlayRect.height > 0 + ? { + left: Math.floor(stackRect.left - containerRect.left), + right: Math.ceil(stackRect.right - containerRect.left), + height: Math.ceil(overlayRect.height), + } + : null, + }, + }; } -const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12; +const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS; // Invisible grab zones straddling each edge; the cursor is the only affordance. const RESIZE_HANDLES: ReadonlyArray<{ @@ -61,43 +124,50 @@ const RESIZE_HANDLES: ReadonlyArray<{ { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" }, ]; -/** - * Floats the thread's browser surface over chat. Native clipping and the DOM - * frame use the same radius so their separately composited edges stay aligned. - */ -export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props) { - const containerRef = useRef(null); - const gestureRef = useRef(null); - const [container, setContainer] = useState(null); - const miniPlayer = usePreviewMiniPlayerStore((state) => - selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), +/** Floats the thread's browser tab or device stream over chat. */ +export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, composerOverlayElement }: Props) { + const { source } = miniPlayer; + return source.kind === "browser" ? ( + + ) : ( + ); +} + +function BrowserMiniPlayer({ + threadRef, + tabId, + miniPlayer, + composerOverlayElement, +}: Props & { readonly tabId: string }) { const previewState = useThreadPreviewState(threadRef); const snapshot = previewState.sessions[tabId] ?? null; const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId); + const recordingTabIds = useActiveBrowserRecordingTabIds(); + const recording = + recordingTabIds.has(runtimeTabId) || + findActiveBrowserRecordingRuntimeTabId(threadRef, tabId) !== null; const desktopOverlay = previewState.desktopByTabId[tabId] ?? null; const fittedSourceContent = useBrowserSurfaceStore( (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null, ); - const source = resolvePreviewMiniPlayerSourceSize( + const sourceSize = resolvePreviewMiniPlayerSourceSize( snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT, fittedSourceContent, desktopOverlay?.zoomFactor ?? 1, ); - const frame = - container && miniPlayer?.tabId === tabId - ? resolvePreviewMiniPlayerFrame({ - width: miniPlayer.width, - position: miniPlayer.position, - source, - container, - bottomInset, - }) - : null; - - const close = () => { - usePreviewMiniPlayerStore.getState().close(threadRef); - }; const openInPanel = () => { usePreviewMiniPlayerStore.getState().close(threadRef); @@ -118,22 +188,196 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props }); }; + if (!snapshot) return null; + + return ( + + event.stopPropagation()} + onClick={toggleNativePictureInPicture} + /> + } + > + + + + {desktopOverlay?.pictureInPicture + ? "Close separate window" + : "Pop into separate window"} + + + } + > + {(frame) => ( + <> + + {!desktopOverlay?.hasWebContents ? ( +
+ Reconnecting preview… +
+ ) : null} + + )} +
+ ); +} + +function DeviceMiniPlayer({ + threadRef, + source, + miniPlayer, + composerOverlayElement, +}: Props & { readonly source: Extract }) { + const { state: deviceState } = useDeviceState(threadRef.environmentId); + const [screen, setScreen] = useState(null); + const sourceSize = resolveDeviceMiniPlayerSourceSize(source.platform, screen); + const device = deviceState.devices.find( + (entry) => entry.hostId === source.hostId && entry.id === source.deviceId, + ); + const hostLabel = + deviceState.hosts.find((host) => host.id === source.hostId)?.label ?? "Device host"; + const cornerRadius = useCallback( + (player: PreviewMiniPlayerSize) => resolveDeviceMiniPlayerCornerRadius(source.platform, player), + [source.platform], + ); + + const openInPanel = () => { + usePreviewMiniPlayerStore.getState().close(threadRef); + useRightPanelStore.getState().openDevice(threadRef, { + hostId: source.hostId, + deviceId: source.deviceId, + platform: source.platform, + name: source.name, + }); + }; + + return ( + + {() => ( + // The stream is DOM, so it takes the band the browser's native webview would. +
+ +
+ )} +
+ ); +} + +/** + * The frame, drag/resize gestures, and hover pill shared by every floating + * source. Native clipping and the DOM frame use the same radius so their + * separately composited edges stay aligned. + */ +function MiniPlayerShell({ + threadRef, + miniPlayer, + sourceSize, + composerOverlayElement, + label, + onOpenInPanel, + pillActions, + recording = false, + cornerRadius = frameCornerRadius, + children, +}: { + readonly threadRef: ScopedThreadRef; + readonly miniPlayer: PreviewMiniPlayerState; + readonly sourceSize: PreviewMiniPlayerSize; + readonly composerOverlayElement: HTMLElement | null; + readonly label: string; + readonly onOpenInPanel: () => void; + readonly pillActions?: ReactNode; + readonly recording?: boolean; + /** The clip radius for a given frame; the pill stays inside the curve. */ + readonly cornerRadius?: (frame: PreviewMiniPlayerSize) => number; + readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode; +}) { + const containerRef = useRef(null); + const gestureRef = useRef(null); + const [layout, setLayout] = useState(null); + const container = layout?.container ?? null; + const obstacles = layout?.obstacles ?? NO_PREVIEW_MINI_PLAYER_OBSTACLES; + const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); + const frame = container + ? resolvePreviewMiniPlayerFrame({ + width: miniPlayer.width, + position: miniPlayer.position, + source: sourceSize, + container, + obstacles, + }) + : null; + + const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS; + // Inside a wide curve the default 8px inset would land on the clipped-away corner. + const pillInset = Math.max(8, Math.round(radius * 0.55)); + + const close = () => { + usePreviewMiniPlayerStore.getState().close(threadRef); + }; + + // The composer grows on its own (drafts, banners), so it is observed alongside the column. useLayoutEffect(() => { const element = containerRef.current; if (!element) return; const measure = () => { - setContainer((current) => - current?.width === element.clientWidth && current.height === element.clientHeight - ? current - : { width: element.clientWidth, height: element.clientHeight }, - ); + const next = measureLayout(element, composerOverlayElement); + setLayout((current) => (current && sameLayout(current, next) ? current : next)); }; measure(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(measure); observer.observe(element); + if (composerOverlayElement) observer.observe(composerOverlayElement); return () => observer.disconnect(); - }, []); + }, [composerOverlayElement]); const beginGesture = ( event: ReactPointerEvent, @@ -160,12 +404,12 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props if (gesture.direction === null) { store.move( threadRef, - tabId, + sourceKey, clampPreviewMiniPlayerPosition( { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y }, container, gesture.frame, - bottomInset, + obstacles, ), ); return; @@ -174,12 +418,12 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props start: gesture.frame, direction: gesture.direction, delta, - source, + source: sourceSize, container, - bottomInset, + obstacles, }); - store.resize(threadRef, tabId, next.width); - store.move(threadRef, tabId, { x: next.x, y: next.y }); + store.resize(threadRef, sourceKey, next.width); + store.move(threadRef, sourceKey, { x: next.x, y: next.y }); }; const endGesture = (event: ReactPointerEvent) => { @@ -190,28 +434,38 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props } }; - if (!snapshot || miniPlayer?.tabId !== tabId) return null; - return (
{frame ? (
-
+
beginGesture(event, null)} @@ -219,6 +473,11 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props onPointerUp={endGesture} onPointerCancel={endGesture} > + {recording ? ( + + + + ) : null} event.stopPropagation()} - onClick={openInPanel} + onClick={onOpenInPanel} /> } > @@ -235,31 +494,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props Open in right panel - - event.stopPropagation()} - onClick={toggleNativePictureInPicture} - /> - } - > - - - - {desktopOverlay?.pictureInPicture - ? "Close separate window" - : "Pop into separate window"} - - + {pillActions}
- + {children(frame)}
- {!desktopOverlay?.hasWebContents ? ( -
- Reconnecting preview… -
- ) : null} {RESIZE_HANDLES.map(({ direction, className }) => (
{ it("uses the device viewport scaled by zoom", () => { @@ -30,6 +38,50 @@ describe("resolvePreviewMiniPlayerSourceSize", () => { }); }); +describe("resolveDeviceMiniPlayerSourceSize", () => { + it("stands in with the platform's phone shape until the stream reports a size", () => { + const ios = resolveDeviceMiniPlayerSourceSize("ios", null); + expect(ios.width / ios.height).toBeCloseTo(9 / 19.5); + const android = resolveDeviceMiniPlayerSourceSize("android", null); + expect(android.width / android.height).toBeCloseTo(9 / 20); + }); + + it("turns a rotated screen into a landscape box", () => { + const screen = { width: 1_179, height: 2_556, orientation: "landscape_left" } as const; + expect(resolveDeviceMiniPlayerSourceSize("ios", screen)).toEqual({ + width: 2_556, + height: 1_179, + }); + expect( + resolveDeviceMiniPlayerSourceSize("android", { ...screen, orientation: "portrait" }), + ).toEqual({ width: 1_179, height: 2_556 }); + }); + + it("floats a phone at the minimum width rather than the default box", () => { + expect( + resolvePreviewMiniPlayerFrame({ + width: null, + position: null, + source: resolveDeviceMiniPlayerSourceSize("ios", null), + container, + }), + ).toMatchObject({ width: 240, height: 520 }); + }); +}); + +describe("resolveDeviceMiniPlayerCornerRadius", () => { + it("rounds an Android player like a phone, scaled with its short side", () => { + expect(resolveDeviceMiniPlayerCornerRadius("android", { width: 240, height: 520 })).toBe(34); + expect(resolveDeviceMiniPlayerCornerRadius("android", { width: 520, height: 240 })).toBe(34); + expect(resolveDeviceMiniPlayerCornerRadius("android", { width: 60, height: 130 })).toBe(12); + }); + + it("keeps the frame radius for iOS, whose stream has square corners", () => { + expect(resolveDeviceMiniPlayerCornerRadius("ios", { width: 240, height: 520 })).toBe(12); + expect(resolveDeviceMiniPlayerCornerRadius("ios", { width: 720, height: 1_000 })).toBe(12); + }); +}); + describe("resolvePreviewMiniPlayerFrame", () => { it("opens at the source aspect ratio in the top-right corner", () => { expect( @@ -60,11 +112,35 @@ describe("resolvePreviewMiniPlayerFrame", () => { position: { x: 100, y: 80 }, source, container, - bottomInset: 300, + obstacles: tallComposer, }); expect(frame).toEqual({ x: 100, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 602, height: 376 }); }); + it("keeps a tall frame parked beside the composer across layout passes", () => { + // The frame an edge resize produced in the left margin, resolved again from + // the stored width and position on the next render. + const phone = { width: 390, height: 844 }; + const beside = { composer: { left: 300, right: 900, height: 300 } }; + const resized = resizePreviewMiniPlayer({ + start: { x: 12, y: 100, width: 240, height: 519 }, + direction: "east", + delta: { x: 10, y: 0 }, + source: phone, + container, + obstacles: beside, + }); + expect( + resolvePreviewMiniPlayerFrame({ + width: resized.width, + position: { x: resized.x, y: resized.y }, + source: phone, + container, + obstacles: beside, + }), + ).toEqual(resized); + }); + it("never grows past the source's own rendered size", () => { expect( resolvePreviewMiniPlayerFrame({ @@ -148,11 +224,52 @@ describe("resizePreviewMiniPlayer", () => { delta: { x: 300, y: 0 }, source, container, - bottomInset: 0, }), ).toEqual({ x: 12, y: 300, width: 620, height: 388 }); }); + it("lets a player beside a tall composer keep its height on an edge drag", () => { + // A portrait player parked in the margin left of the composer, already + // taller than the rows above the composer, nudged from its right edge. + const phone = { width: 390, height: 844 }; + const start = { x: 12, y: 100, width: 240, height: 519 }; + const beside = { composer: { left: 300, right: 900, height: 300 } }; + expect( + resizePreviewMiniPlayer({ + start, + direction: "east", + delta: { x: 10, y: 0 }, + source: phone, + container, + obstacles: beside, + }), + ).toEqual({ x: 12, y: 100, width: 250, height: 541 }); + // The same drag with the composer under the player is still held above it. + expect( + resizePreviewMiniPlayer({ + start: { ...start, x: 400 }, + direction: "east", + delta: { x: 10, y: 0 }, + source: phone, + container, + obstacles: beside, + }), + ).toMatchObject({ height: 376 }); + }); + + it("stops growing downward at the composer beneath the player's columns", () => { + expect( + resizePreviewMiniPlayer({ + start: { x: 300, y: 100, width: 320, height: 200 }, + direction: "south", + delta: { x: 0, y: 400 }, + source, + container, + obstacles: tallComposer, + }), + ).toEqual({ x: 300, y: 100, width: 461, height: 288 }); + }); + it("respects the minimum size", () => { expect( resizePreviewMiniPlayer({ @@ -167,20 +284,53 @@ describe("resizePreviewMiniPlayer", () => { }); describe("clampPreviewMiniPlayerPosition", () => { + const player = { width: 360, height: 240 }; + it("keeps a dragged player within the chat viewport", () => { + expect(clampPreviewMiniPlayerPosition({ x: 900, y: -40 }, container, player)).toEqual({ + x: 628, + y: gap, + }); + }); + + it("keeps the player above a growing composer", () => { expect( - clampPreviewMiniPlayerPosition({ x: 900, y: -40 }, container, { width: 360, height: 240 }), - ).toEqual({ x: 628, y: PREVIEW_MINI_PLAYER_EDGE_GAP }); + clampPreviewMiniPlayerPosition({ x: 500, y: 448 }, container, player, { + composer: { ...composer, height: 160 }, + }), + ).toEqual({ x: 500, y: 288 }); }); - it("keeps the player above a growing composer inset", () => { + it("lets the player drop into the margin beside the composer", () => { expect( clampPreviewMiniPlayerPosition( - { x: 500, y: 448 }, + { x: 20, y: 500 }, container, - { width: 360, height: 240 }, - 160, + { width: 60, height: 150 }, + obstacles, ), - ).toEqual({ x: 500, y: 288 }); + ).toEqual({ x: 20, y: 500 }); + }); + + it("slides sideways past the composer when that is the shorter move", () => { + expect( + clampPreviewMiniPlayerPosition( + { x: 850, y: 500 }, + container, + { width: 60, height: 150 }, + obstacles, + ), + ).toEqual({ x: composer.right + gap, y: 500 }); + }); + + it("sits above the composer when it is too wide for either margin", () => { + expect( + clampPreviewMiniPlayerPosition( + { x: 100, y: 100 }, + container, + { width: 976, height: 500 }, + obstacles, + ), + ).toEqual({ x: gap, y: 700 - 150 - gap - 500 }); }); }); diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.ts index 372ed71c6b0d..13ba26f06fca 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.ts @@ -1,4 +1,4 @@ -import type { PreviewViewportSetting } from "@t3tools/contracts"; +import type { DevicePlatform, PreviewViewportSetting } from "@t3tools/contracts"; import type { BrowserSurfaceContentPresentation } from "~/browser/browserSurfaceStore"; import { @@ -7,7 +7,10 @@ import { } from "~/browser/browserViewportLayout"; import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; +import type { DeviceScreenSize } from "../device/deviceStream"; + export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12; +export const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12; // The mini-player shell straddles this webview at 47 and 49; dialogs begin at 50. export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48; // A fresh player is the largest box at the source aspect ratio that fits here. @@ -34,12 +37,95 @@ export function resolvePreviewMiniPlayerSourceSize( }; } +/** + * The device screen as the user sees it, so a rotated phone floats as a + * landscape box. Before the stream reports its size the platform's usual phone + * shape stands in, matching the stream view's own placeholder aspect; the + * nominal width only keeps the source cap above any sensible player width. + */ +export function resolveDeviceMiniPlayerSourceSize( + platform: DevicePlatform, + screen: DeviceScreenSize | null, +): PreviewMiniPlayerSize { + if (!screen) { + const width = 1_000; + return { width, height: width / (platform === "ios" ? 9 / 19.5 : 9 / 20) }; + } + const landscape = + screen.orientation === "landscape_left" || screen.orientation === "landscape_right"; + const long = Math.max(screen.width, screen.height); + const short = Math.min(screen.width, screen.height); + return landscape ? { width: long, height: short } : { width: short, height: long }; +} + +/** + * The Android emulator composites the skin's rounded corners into its + * framebuffer as black wedges (measured at ~13% of the short side on a + * Pixel 9), so its player clips at a matching phone-like radius; the sliver + * lost under the curve is status-bar padding. iOS simulators stream an + * edge-to-edge rectangle and keep the frame radius, which matters for iPads + * whose real corners are far tighter than a phone's. + */ +export function resolveDeviceMiniPlayerCornerRadius( + platform: DevicePlatform, + player: PreviewMiniPlayerSize, +): number { + if (platform !== "android") return PREVIEW_MINI_PLAYER_CORNER_RADIUS; + return Math.max( + PREVIEW_MINI_PLAYER_CORNER_RADIUS, + Math.round(Math.min(player.width, player.height) * 0.14), + ); +} + +interface HorizontalSpan { + readonly left: number; + readonly right: number; +} + +/** + * The composer stack docked to the bottom edge, in container coordinates. It + * only reserves the columns it covers, so the margins beside it stay open all + * the way down. + */ +export interface PreviewMiniPlayerObstacles { + readonly composer: (HorizontalSpan & { readonly height: number }) | null; +} + +export const NO_PREVIEW_MINI_PLAYER_OBSTACLES: PreviewMiniPlayerObstacles = { composer: null }; + +const spanOf = (x: number, width: number): HorizontalSpan => ({ left: x, right: x + width }); + +const spansOverlap = (a: HorizontalSpan, b: HorizontalSpan) => a.left < b.right && a.right > b.left; + +/** The lowest row (before the edge gap) open to a player covering these columns. */ +function floorFor( + span: HorizontalSpan, + container: PreviewMiniPlayerSize, + obstacles: PreviewMiniPlayerObstacles, +): number { + const { composer } = obstacles; + return composer && spansOverlap(span, composer) + ? container.height - Math.max(0, composer.height) + : container.height; +} + +/** + * The box a stored size is fitted into. A player with a position keeps the + * rows its own columns have, so a tall frame parked beside the composer + * survives the next layout pass; without one it takes the rows above the + * composer, which every column has. + */ const availableArea = ( container: PreviewMiniPlayerSize, - bottomInset: number, + obstacles: PreviewMiniPlayerObstacles, + span: HorizontalSpan | null, ): PreviewMiniPlayerSize => ({ width: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, - height: container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, + height: + (span + ? floorFor(span, container, obstacles) + : container.height - Math.max(0, obstacles.composer?.height ?? 0)) - + PREVIEW_MINI_PLAYER_EDGE_GAP * 2, }); /** @@ -74,25 +160,68 @@ function defaultPreviewMiniPlayerWidth(source: PreviewMiniPlayerSize): number { ); } +const clampToContainer = ( + position: PreviewMiniPlayerPosition, + container: PreviewMiniPlayerSize, + player: PreviewMiniPlayerSize, + bottom = container.height, +): PreviewMiniPlayerPosition => ({ + x: Math.min( + Math.max(position.x, PREVIEW_MINI_PLAYER_EDGE_GAP), + Math.max( + PREVIEW_MINI_PLAYER_EDGE_GAP, + container.width - player.width - PREVIEW_MINI_PLAYER_EDGE_GAP, + ), + ), + y: Math.min( + Math.max(position.y, PREVIEW_MINI_PLAYER_EDGE_GAP), + Math.max(PREVIEW_MINI_PLAYER_EDGE_GAP, bottom - player.height - PREVIEW_MINI_PLAYER_EDGE_GAP), + ), +}); + +const overlapsObstacle = ( + position: PreviewMiniPlayerPosition, + player: PreviewMiniPlayerSize, + container: PreviewMiniPlayerSize, + obstacles: PreviewMiniPlayerObstacles, +): boolean => + position.y + player.height > floorFor(spanOf(position.x, player.width), container, obstacles); + +/** + * Keeps the player inside the container and off the composer. An overlapping + * player is pushed out along whichever side needs the smaller move, so a drag + * slides along the composer into the margin beside it instead of stopping at + * its top edge; when no side leaves it fully clear it sits above the composer. + */ export function clampPreviewMiniPlayerPosition( position: PreviewMiniPlayerPosition, container: PreviewMiniPlayerSize, player: PreviewMiniPlayerSize, - bottomInset = 0, + obstacles: PreviewMiniPlayerObstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES, ): PreviewMiniPlayerPosition { - const reservedBottomSpace = Math.max(0, bottomInset); - const maxX = Math.max( - PREVIEW_MINI_PLAYER_EDGE_GAP, - container.width - player.width - PREVIEW_MINI_PLAYER_EDGE_GAP, - ); - const maxY = Math.max( - PREVIEW_MINI_PLAYER_EDGE_GAP, - container.height - reservedBottomSpace - player.height - PREVIEW_MINI_PLAYER_EDGE_GAP, - ); - return { - x: Math.min(Math.max(position.x, PREVIEW_MINI_PLAYER_EDGE_GAP), maxX), - y: Math.min(Math.max(position.y, PREVIEW_MINI_PLAYER_EDGE_GAP), maxY), - }; + const inside = clampToContainer(position, container, player); + const { composer } = obstacles; + if (!composer || !overlapsObstacle(inside, player, container, obstacles)) return inside; + const gap = PREVIEW_MINI_PLAYER_EDGE_GAP; + const above = { x: inside.x, y: container.height - composer.height - gap - player.height }; + const beside = [ + { x: composer.left - gap - player.width, y: inside.y }, + { x: composer.right + gap, y: inside.y }, + ]; + let best = clampToContainer(above, container, player); + let bestDistance = Math.abs(best.y - inside.y); + for (const candidate of beside) { + const clamped = clampToContainer(candidate, container, player); + if (clamped.x !== candidate.x || overlapsObstacle(candidate, player, container, obstacles)) { + continue; + } + const distance = Math.abs(candidate.x - inside.x); + if (distance < bestDistance) { + best = candidate; + bestDistance = distance; + } + } + return best; } /** @@ -106,19 +235,25 @@ export function resolvePreviewMiniPlayerFrame(input: { readonly position: PreviewMiniPlayerPosition | null; readonly source: PreviewMiniPlayerSize; readonly container: PreviewMiniPlayerSize; - readonly bottomInset?: number; + readonly obstacles?: PreviewMiniPlayerObstacles; }): PreviewMiniPlayerFrame { - const { width, position, source, container, bottomInset = 0 } = input; + const { + width, + position, + source, + container, + obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES, + } = input; const size = fitPreviewMiniPlayerWidth( width ?? defaultPreviewMiniPlayerWidth(source), source, - availableArea(container, bottomInset), + availableArea(container, obstacles, position && width ? spanOf(position.x, width) : null), ); const anchored = position ?? { x: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - size.width, y: PREVIEW_MINI_PLAYER_EDGE_GAP, }; - return { ...clampPreviewMiniPlayerPosition(anchored, container, size, bottomInset), ...size }; + return { ...clampPreviewMiniPlayerPosition(anchored, container, size, obstacles), ...size }; } /** @@ -134,27 +269,37 @@ export function resizePreviewMiniPlayer(input: { readonly delta: PreviewMiniPlayerPosition; readonly source: PreviewMiniPlayerSize; readonly container: PreviewMiniPlayerSize; - readonly bottomInset?: number; + readonly obstacles?: PreviewMiniPlayerObstacles; }): PreviewMiniPlayerFrame { - const { start, direction, delta, source, container, bottomInset = 0 } = input; + const { + start, + direction, + delta, + source, + container, + obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES, + } = input; const east = direction.includes("east"); const west = direction.includes("west"); const north = direction.includes("north"); const south = direction.includes("south"); - const available = availableArea(container, bottomInset); const right = start.x + start.width; const bottom = start.y + start.height; + // Growth stops where the player's current columns meet the composer, and a + // plain edge drag lets the free axis use everything those columns have. A + // wider player may reach new columns; the clamp below slides it clear. + const floor = floorFor(spanOf(start.x, start.width), container, obstacles); const max = { width: west ? right - PREVIEW_MINI_PLAYER_EDGE_GAP : east ? container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - start.x - : available.width, + : container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, height: north ? bottom - PREVIEW_MINI_PLAYER_EDGE_GAP : south - ? container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP - start.y - : available.height, + ? floor - PREVIEW_MINI_PLAYER_EDGE_GAP - start.y + : floor - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, }; const desiredWidth = start.width + (east ? delta.x : west ? -delta.x : 0); const desiredHeight = start.height + (south ? delta.y : north ? -delta.y : 0); @@ -176,7 +321,7 @@ export function resizePreviewMiniPlayer(input: { { x: west ? right - size.width : start.x, y: north ? bottom - size.height : start.y }, container, size, - bottomInset, + obstacles, ); return { ...position, ...size }; } diff --git a/apps/web/src/components/projectScriptEditor.test.tsx b/apps/web/src/components/projectScriptEditor.test.tsx new file mode 100644 index 000000000000..becb3a369f62 --- /dev/null +++ b/apps/web/src/components/projectScriptEditor.test.tsx @@ -0,0 +1,245 @@ +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, StrictMode, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("./ui/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? children : null), + DialogDescription: "p", + DialogFooter: "footer", + DialogHeader: "header", + DialogPanel: "section", + DialogPopup: "section", + DialogTitle: "h2", +})); +vi.mock("./ui/alert-dialog", () => ({ + AlertDialog: ({ open, children }: { open: boolean; children: ReactNode }) => + open ? children : null, + AlertDialogClose: "button", + AlertDialogDescription: "p", + AlertDialogFooter: "footer", + AlertDialogHeader: "header", + AlertDialogPopup: "section", + AlertDialogTitle: "h2", +})); +vi.mock("./ui/button", () => ({ Button: "button" })); +vi.mock("./ui/input", () => ({ Input: "input" })); +vi.mock("./ui/label", () => ({ Label: "label" })); +vi.mock("./ui/popover", () => ({ + Popover: ({ children }: { children: ReactNode }) => children, + PopoverPopup: () => null, + PopoverTrigger: "button", +})); +vi.mock("./ui/switch", () => ({ Switch: "input" })); +vi.mock("./ui/textarea", () => ({ Textarea: "textarea" })); + +import { + EMPTY_PROJECT_SCRIPT_INPUT, + ProjectScriptEditorDialog, + type ProjectScriptActionResult, + type ProjectScriptEditorRequest, +} from "./projectScriptEditor"; + +const onSubmit = vi.fn[0]["onSubmit"]>(); +const onClose = vi.fn(); +const onDelete = vi.fn(); +let renderer: ReactTestRenderer | null; + +function request(name: string, error?: string): ProjectScriptEditorRequest { + return { + scriptId: name, + initial: { ...EMPTY_PROJECT_SCRIPT_INPUT, name, command: `run-${name}` }, + ...(error === undefined ? {} : { error }), + }; +} + +function editor(nextRequest: ProjectScriptEditorRequest) { + return ( + + + + ); +} + +function open(nextRequest: ProjectScriptEditorRequest) { + act(() => { + if (renderer) renderer.update(editor(nextRequest)); + else renderer = create(editor(nextRequest)); + }); +} + +function submit(): Promise { + return renderer!.root.findByType("form").props.onSubmit({ preventDefault() {} }); +} + +function saveButton() { + return renderer!.root.findAllByType("button").find((button) => button.props.type === "submit")!; +} + +function deferredSave() { + let resolve!: (result: ProjectScriptActionResult) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolveResult, rejectResult) => { + resolve = resolveResult; + reject = rejectResult; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + renderer = null; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + onSubmit.mockReset(); + onClose.mockReset(); + onDelete.mockReset(); +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +describe("project action editor save lifecycle", () => { + it("blocks repeated submits and edits until the current save completes", async () => { + const save = deferredSave(); + onSubmit.mockReturnValue(save.promise); + open(request("build")); + + let completion!: Promise; + act(() => { + completion = submit(); + void submit(); + }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(saveButton().props.disabled).toBe(true); + expect(renderer!.root.findByType("fieldset").props.disabled).toBe(true); + const cancel = renderer!.root + .findAllByType("button") + .find((button) => button.children.includes("Cancel"))!; + expect(cancel.props.disabled).not.toBe(true); + + await act(async () => { + save.resolve(AsyncResult.success(undefined)); + await completion; + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not close a replacement request or release its in-flight save", async () => { + const first = deferredSave(); + const second = deferredSave(); + onSubmit.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + open(request("build")); + let firstCompletion!: Promise; + act(() => { + firstCompletion = submit(); + }); + + open(request("test")); + expect(saveButton().props.disabled).toBe(false); + expect(renderer!.root.findByProps({ id: "script-name" }).props.value).toBe("test"); + let secondCompletion!: Promise; + act(() => { + secondCompletion = submit(); + }); + + await act(async () => { + first.resolve(AsyncResult.success(undefined)); + await firstCompletion; + }); + expect(onClose).not.toHaveBeenCalled(); + expect(saveButton().props.disabled).toBe(true); + + await act(async () => { + second.resolve(AsyncResult.success(undefined)); + await secondCompletion; + }); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls.map(([scriptId]) => scriptId)).toEqual(["build", "test"]); + }); + + it.each(["failure", "rejection"] as const)( + "ignores a stale %s after the request changes", + async (outcome) => { + const save = deferredSave(); + onSubmit.mockReturnValue(save.promise); + open(request("build")); + let completion!: Promise; + act(() => { + completion = submit(); + }); + + open(request("test", "New request error")); + await act(async () => { + if (outcome === "failure") + save.resolve(AsyncResult.failure(Cause.fail(new Error("Old save error")))); + else save.reject(new Error("Old save error")); + await completion; + }); + + const messages = renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children); + expect(messages).toContain("New request error"); + expect(messages).not.toContain("Old save error"); + expect(saveButton().props.disabled).toBe(false); + expect(onClose).not.toHaveBeenCalled(); + }, + ); + + it("shows a current save error and allows retry", async () => { + onSubmit.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Save failed")))); + onSubmit.mockResolvedValueOnce(AsyncResult.success(undefined)); + open(request("build")); + + await act(async () => { + await submit(); + }); + expect(renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children)).toContain( + "Save failed", + ); + expect(saveButton().props.disabled).toBe(false); + expect(renderer!.root.findByType("fieldset").props.disabled).toBe(false); + expect(onClose).not.toHaveBeenCalled(); + + await act(async () => { + await submit(); + }); + expect(onSubmit).toHaveBeenCalledTimes(2); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it.each(["cancel", "unmount"] as const)("ignores save completion after %s", async (exit) => { + const save = deferredSave(); + onSubmit.mockReturnValue(save.promise); + open(request("build")); + let completion!: Promise; + act(() => { + completion = submit(); + }); + + act(() => { + if (exit === "cancel") { + renderer!.root + .findAllByType("button") + .find((button) => button.children.includes("Cancel"))! + .props.onClick(); + } else { + renderer!.unmount(); + renderer = null; + } + }); + onClose.mockClear(); + await act(async () => { + save.resolve(AsyncResult.success(undefined)); + await completion; + }); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 4ffd453955e9..74b189a02fc5 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -16,7 +16,14 @@ import { PlayIcon, WrenchIcon, } from "lucide-react"; -import React, { type FormEvent, type KeyboardEvent, useEffect, useState } from "react"; +import React, { + type FormEvent, + type KeyboardEvent, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import { keybindingValueForCommand, @@ -156,9 +163,22 @@ export function ProjectScriptEditorDialog({ const [autoOpenPreview, setAutoOpenPreview] = useState(false); const [validationError, setValidationError] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [savingRequest, setSavingRequest] = useState(null); + const pendingSubmissionRef = useRef<{ request: ProjectScriptEditorRequest } | null>(null); const isOpen = request !== null; const isEditing = request?.scriptId != null; + const isSaving = request !== null && savingRequest === request; + + // A save completion must not affect a replacement request or an unmounted editor. + useLayoutEffect( + () => () => { + if (pendingSubmissionRef.current?.request === request) { + pendingSubmissionRef.current = null; + } + }, + [request], + ); // Hydrate the form whenever a new request opens the dialog. useEffect(() => { @@ -172,8 +192,16 @@ export function ProjectScriptEditorDialog({ setPreviewUrl(request.initial.previewUrl ?? ""); setAutoOpenPreview(request.initial.autoOpenPreview); setValidationError(request.error ?? null); + setSavingRequest(null); }, [request]); + const close = () => { + pendingSubmissionRef.current = null; + setSavingRequest(null); + setIconPickerOpen(false); + onClose(); + }; + const captureKeybinding = (event: KeyboardEvent) => { if (event.key === "Tab") return; event.preventDefault(); @@ -188,7 +216,7 @@ export function ProjectScriptEditorDialog({ const submit = async (event: FormEvent) => { event.preventDefault(); - if (!request) return; + if (!request || pendingSubmissionRef.current !== null) return; const trimmedName = name.trim(); const trimmedCommand = command.trim(); if (trimmedName.length === 0) { @@ -228,16 +256,31 @@ export function ProjectScriptEditorDialog({ return; } - const result = await onSubmit(request.scriptId, payload); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); + const submission = { request }; + pendingSubmissionRef.current = submission; + setSavingRequest(request); + setIconPickerOpen(false); + try { + const result = await onSubmit(request.scriptId, payload); + if (pendingSubmissionRef.current === submission) { + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setValidationError(error instanceof Error ? error.message : "Failed to save action."); + } + } else { + close(); + } + } + } catch (error) { + if (pendingSubmissionRef.current === submission) { setValidationError(error instanceof Error ? error.message : "Failed to save action."); } - return; } - setIconPickerOpen(false); - onClose(); + if (pendingSubmissionRef.current === submission) { + pendingSubmissionRef.current = null; + setSavingRequest(null); + } }; return ( @@ -246,8 +289,7 @@ export function ProjectScriptEditorDialog({ open={isOpen} onOpenChange={(open) => { if (!open) { - setIconPickerOpen(false); - onClose(); + close(); } }} > @@ -259,112 +301,115 @@ export function ProjectScriptEditorDialog({ -
-
- -
- - - } - > - - - -
- {SCRIPT_ICONS.map((entry) => { - const isSelected = entry.id === icon; - return ( - - ); - })} -
-
-
+ +
+
+ +
+ + + } + > + + + +
+ {SCRIPT_ICONS.map((entry) => { + const isSelected = entry.id === icon; + return ( + + ); + })} +
+
+
+ setName(event.target.value)} + /> +
+
+
+ setName(event.target.value)} + id="script-keybinding" + placeholder="Press shortcut" + value={keybinding} + readOnly + onKeyDown={captureKeybinding} /> +

+ Press a shortcut. Use Backspace to clear. Shortcuts are + environment-wide. Projects using the same action share its shortcut. +

-
-
- - -

- Press a shortcut. Use Backspace to clear. -

-
-
- -