diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2c896fa16954..fc8f572e5d17 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.48", + "version": "0.0.49", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 2a9149892037..46675d70b5a5 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -15,10 +15,7 @@ import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; -import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; -import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; -import * as FileSystem from "effect/FileSystem"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; @@ -218,59 +215,6 @@ const bootstrap = Effect.gen(function* () { // in parallel rather than blocking primary readiness on a possibly // slow first wsl.exe spawn. yield* Effect.forkScoped(wslBackend.reconcile); - // Personal T3 Code home: when ~/.t3 (the pre-rebrand home the standalone - // t3code app used to serve) still holds a database, host it as a second - // local backend so the personal T3 Code + Clerk environment survives - // without a second app install. Forked for the same reason as WSL: a - // port scan or spawn must never block primary readiness. Port scan - // starts at primary+2 — the WSL secondary scans from primary+1. - yield* Effect.forkScoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const net = yield* NetService.NetService; - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const personalHome = environment.path.join( - environment.path.dirname(environment.baseDir), - ".t3", - ); - const hasData = yield* fs - .exists(environment.path.join(personalHome, "userdata", "state.sqlite")) - .pipe(Effect.orElseSucceed(() => false)); - if (!hasData) { - return; - } - let personalPort: number | null = null; - for (let candidate = backendPort + 2; candidate <= 65535; candidate += 1) { - if (yield* net.canListenOnHost(candidate, "127.0.0.1")) { - personalPort = candidate; - break; - } - } - if (personalPort === null) { - yield* logBootstrapInfo("no loopback port for the personal T3 backend; skipping"); - return; - } - yield* logBootstrapInfo("registering personal T3 backend", { - home: personalHome, - port: personalPort, - }); - const instance = yield* pool.register({ - id: DesktopBackendManager.BackendInstanceId("local:t3"), - label: Effect.succeed("T3 Code (personal)"), - configResolve: configuration.resolveLocalHome({ - port: personalPort, - t3Home: personalHome, - }), - }); - yield* instance.start; - }).pipe( - Effect.catchCause((cause) => - logBootstrapInfo("personal T3 backend registration failed", { - cause: Cause.pretty(cause), - }), - ), - ), - ); } }).pipe(Effect.withSpan("desktop.bootstrap")); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 2b83f7c5f070..9fc67c96f070 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -56,18 +56,6 @@ export class DesktopBackendConfiguration extends Context.Service< DesktopBackendManager.DesktopBackendStartConfig, PlatformError.PlatformError >; - // Build a start config for a secondary local backend serving another T3 - // home directory (e.g. ~/.t3 — the pre-rebrand home the standalone t3code - // app used to serve), so the personal T3 Code + Clerk environment stays - // available without a second app install. Loopback-only: the primary owns - // LAN exposure. - readonly resolveLocalHome: (input: { - readonly port: number; - readonly t3Home: string; - }) => Effect.Effect< - DesktopBackendManager.DesktopBackendStartConfig, - PlatformError.PlatformError - >; // The renderer-facing label for the primary instance, derived from the // same decision resolvePrimary makes (including the WSL-availability // fall-back to Windows), so the env switcher can't show "WSL" for a @@ -531,59 +519,6 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv }, ); -const resolveLocalHomeStartConfig = Effect.fn("desktop.backendConfiguration.resolveLocalHome")( - function* ( - input: SharedBootstrapInput & { - readonly port: number; - readonly t3Home: string; - }, - ): Effect.fn.Return< - DesktopBackendManager.DesktopBackendStartConfig, - never, - DesktopEnvironment.DesktopEnvironment - > { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - - const bootstrap = { - mode: "desktop" as const, - noBrowser: true, - port: input.port, - t3Home: input.t3Home, - // Loopback-only on purpose: this secondary serves a personal local - // environment; the primary owns LAN/tailscale exposure when the user - // opts in. - host: "127.0.0.1", - desktopBootstrapToken: input.bootstrapToken, - // PortSchema rejects 0, so the disabled pair still needs a valid - // number; the backend reads tailscaleServePort only when serve is - // enabled, so the value is inert. - tailscaleServeEnabled: false, - tailscaleServePort: 443, - // No telemetry fds and no resource-monitor sidecar: those pipelines - // belong to the primary. Like the WSL secondary, this instance reports - // resource telemetry unavailable. - ...buildObservabilityFragment(input.observabilitySettings), - }; - - return { - executablePath: process.execPath, - args: [environment.backendEntryPath, "--bootstrap-fd", "3"], - entryPath: environment.backendEntryPath, - cwd: environment.backendCwd, - env: { - ...backendChildEnvPatch(), - ELECTRON_RUN_AS_NODE: "1", - }, - extendEnv: true, - bootstrap, - bootstrapDelivery: "fd3", - httpBaseUrl: new URL(`http://127.0.0.1:${input.port}`), - captureOutput: true, - preflightFailure: Option.none(), - } satisfies DesktopBackendManager.DesktopBackendStartConfig; - }, -); - const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl")(function* ( input: SharedBootstrapInput & { readonly port: number; @@ -934,17 +869,6 @@ export const make = Effect.gen(function* () { attributes: { port: input.port, distro: input.distro ?? null }, }), ), - resolveLocalHome: (input) => - Effect.gen(function* () { - const shared = yield* sharedInputs; - return yield* resolveLocalHomeStartConfig({ ...shared, ...input }).pipe( - Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), - ); - }).pipe( - Effect.withSpan("desktop.backendConfiguration.resolveLocalHome", { - attributes: { port: input.port, t3Home: input.t3Home }, - }), - ), }); }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 4a66a13cc63b..97d4359e1663 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -78,7 +78,6 @@ function makePoolLayer( resolvePrimary: Effect.die("unexpected primary config resolve"), resolvePrimaryLabel: Ref.get(labelRef), resolveWsl: () => Effect.die("unexpected WSL config resolve"), - resolveLocalHome: () => Effect.die("unexpected local-home config resolve"), } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), DesktopAppSettings.layerTest(), DesktopWslEnvironment.layerTest(), diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 718ebd2a3cf3..5229d36c31f1 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -11,7 +11,6 @@ import { DesktopPreviewConfigInputSchema, DesktopPreviewNavigateInputSchema, DesktopPreviewRecordingArtifactSchema, - DesktopPreviewRecordingSourceSchema, DesktopPreviewRecordingSaveInputSchema, DesktopPreviewRegisterWebviewInputSchema, DesktopPreviewScreenshotArtifactSchema, @@ -174,15 +173,11 @@ export const cancelPickElement = tabMethod( "desktop.ipc.preview.cancelPickElement", (manager, tabId) => manager.cancelPickElement(tabId), ); -export const startRecording = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL, - payload: DesktopPreviewTabInputSchema, - result: DesktopPreviewRecordingSourceSchema, - handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) { - const manager = yield* PreviewManager.PreviewManager; - return yield* manager.startRecording(tabId); - }), -}); +export const startRecording = tabMethod( + IpcChannels.PREVIEW_RECORDING_START_CHANNEL, + "desktop.ipc.preview.startRecording", + (manager, tabId) => manager.startRecording(tabId), +); export const stopRecording = tabMethod( IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, "desktop.ipc.preview.stopRecording", diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c13be7858ec3..ffb82e023ba4 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -132,9 +132,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, onQuitShortcut: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { - if (state !== "down" && state !== "up") return; - listener(state); + const wrappedListener = (_event: Electron.IpcRendererEvent, hint: unknown) => { + if (typeof hint !== "object" || hint === null || !("state" in hint)) return; + if (hint.state === "up") { + listener({ state: "up" }); + return; + } + if ( + hint.state === "down" && + "mode" in hint && + (hint.mode === "hold" || hint.mode === "double-click") + ) { + listener({ state: "down", mode: hint.mode }); + } }; ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 0cd85c710eaa..75271d76386a 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,4 +1,5 @@ import { it as effectIt } from "@effect/vitest"; +import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; @@ -123,7 +124,7 @@ const { } = vi.hoisted(() => ({ browserWindowConstructor: vi.fn(), createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), - fromId: vi.fn((_id?: number) => null), + fromId: vi.fn<(_id?: number) => Electron.WebContents | null>((_id?: number) => null), getFocusedWebContents: vi.fn(() => null), mkdir: vi.fn((_path: string) => undefined), showItemInFolder: vi.fn(), @@ -209,14 +210,52 @@ interface TestCapturedPreviewImage { readonly getSize: () => { readonly width: number; readonly height: number }; } +type TestDisplayMediaHandler = ( + request: { readonly frame: { readonly frameTreeNodeId: number } | null }, + callback: (streams: { video?: unknown }) => void, +) => void; + +interface TestHostWebContents { + readonly id: number; + readonly mainFrame: { readonly frameTreeNodeId: number }; + readonly executeJavaScript: ReturnType; + readonly isDestroyed: () => boolean; + readonly session: { + readonly setDisplayMediaRequestHandler: ReturnType; + }; + readonly displayMediaHandler: () => TestDisplayMediaHandler | undefined; +} + +type TestPreviewWebContents = Electron.WebContents & { + readonly setBackgroundThrottling: ReturnType void>>; +}; + +const makeTestHostWebContents = (): TestHostWebContents => { + let handler: TestDisplayMediaHandler | undefined; + return { + id: 7, + mainFrame: { frameTreeNodeId: 7 }, + executeJavaScript: vi.fn(async () => true), + isDestroyed: () => false, + session: { + setDisplayMediaRequestHandler: vi.fn((next: TestDisplayMediaHandler) => { + handler = next; + }), + }, + displayMediaHandler: () => handler, + }; +}; + const makeTestPreviewWebContents = ( capturePage: () => Promise, id = 42, -) => - ({ + hostWebContents: TestHostWebContents = makeTestHostWebContents(), +) => { + const setBackgroundThrottling = vi.fn<(enabled: boolean) => void>(); + return { id, - hostWebContents: { id: 7 }, - getMediaSourceId: vi.fn(() => `tab:${id}`), + mainFrame: { routingId: id }, + hostWebContents, executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), isDestroyed: () => false, getType: () => "webview", @@ -226,6 +265,7 @@ const makeTestPreviewWebContents = ( getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), + setBackgroundThrottling, isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), @@ -241,7 +281,45 @@ const makeTestPreviewWebContents = ( off: vi.fn(), }, capturePage, - }) as never; + } as unknown as TestPreviewWebContents; +}; + +/** Two ready tabs (41, 42) sharing one window, so they contend for the single display-media slot. */ +const setupRecordingRaceTabs = (manager: PreviewManager.PreviewManager["Service"]) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("unused-recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const host = makeTestHostWebContents(); + const destroyedIds = new Set(); + const makeWebContents = (id: number) => + Object.assign(makeTestPreviewWebContents(capturePage, id, host), { + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), + isDestroyed: () => destroyedIds.has(id), + }); + const webContentsById = new Map([ + [41, makeWebContents(41)], + [42, makeWebContents(42)], + ]); + fromId.mockImplementation((id) => + id === undefined ? null : (webContentsById.get(id) ?? null), + ); + yield* manager.createTab("tab_race_a"); + yield* manager.createTab("tab_race_b"); + yield* manager.registerWebview("tab_race_a", 41); + yield* manager.registerWebview("tab_race_b", 42); + const grants: Array<{ video?: unknown }> = []; + return { + host, + grants, + destroy: (id: number) => destroyedIds.add(id), + takeGrant: (frame = host.mainFrame) => + host.displayMediaHandler()?.({ frame }, (value) => { + grants.push(value); + }), + }; + }); const TEST_FAVICON = "data:image/png;base64,cG5n"; @@ -1865,7 +1943,7 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("keeps window unthrottled until the final frame capture stops", () => + effectIt.effect("keeps every recorded guest unthrottled until its frame capture stops", () => withManager((manager) => Effect.gen(function* () { const setBackgroundThrottling = vi.fn(); @@ -1873,9 +1951,12 @@ describe("PreviewManager", () => { toJPEG: () => Buffer.from("recording-frame"), getSize: () => ({ width: 1280, height: 720 }), })); + const host = makeTestHostWebContents(); + const firstWebContents = makeTestPreviewWebContents(capturePage, 41, host); + const secondWebContents = makeTestPreviewWebContents(capturePage, 42, host); const webContentsById = new Map([ - [41, makeTestPreviewWebContents(capturePage, 41)], - [42, makeTestPreviewWebContents(capturePage, 42)], + [41, firstWebContents], + [42, secondWebContents], ]); fromId.mockImplementation((id) => id === undefined ? null : (webContentsById.get(id) ?? null), @@ -1892,14 +1973,21 @@ describe("PreviewManager", () => { } as never); yield* manager.startRecording("tab_capture_throttling_1"); + // The first renderer takes its grant, freeing the arm slot for the second tab. + host.displayMediaHandler()?.({ frame: host.mainFrame }, () => {}); yield* manager.startRecording("tab_capture_throttling_2"); expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(firstWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); yield* manager.stopRecording("tab_capture_throttling_1"); expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(firstWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); yield* manager.stopRecording("tab_capture_throttling_2"); expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); }), ), ); @@ -1953,6 +2041,48 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("rolls back window throttling when a recorded guest cannot be unthrottled", () => + withManager((manager) => + Effect.gen(function* () { + const setWindowBackgroundThrottling = vi.fn(); + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const wc = makeTestPreviewWebContents(capturePage); + fromId.mockReturnValue(wc); + + yield* manager.createTab("tab_guest_throttling_failure"); + yield* manager.registerWebview("tab_guest_throttling_failure", 42); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: { setBackgroundThrottling: setWindowBackgroundThrottling }, + } as never); + + wc.setBackgroundThrottling.mockImplementationOnce(() => { + throw new Error("guest throttling update failed"); + }); + const failedStart = yield* Effect.exit( + manager.startRecording("tab_guest_throttling_failure"), + ); + expect(Exit.isFailure(failedStart)).toBe(true); + expect(setWindowBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); + + yield* manager.startRecording("tab_guest_throttling_failure"); + yield* manager.stopRecording("tab_guest_throttling_failure"); + expect(setWindowBackgroundThrottling.mock.calls).toEqual([ + [false], + [true], + [false], + [true], + ]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [false], [true]]); + }), + ), + ); + effectIt.effect("does not publish a replacement window when capture reconciliation fails", () => withManager((manager) => Effect.gen(function* () { @@ -2031,9 +2161,10 @@ describe("PreviewManager", () => { toJPEG: () => Buffer.from("recording-frame"), getSize: () => ({ width: 1280, height: 720 }), })); + const host = makeTestHostWebContents(); const webContentsById = new Map([ - [42, makeTestPreviewWebContents(capturePage, 42)], - [43, makeTestPreviewWebContents(capturePage, 43)], + [42, makeTestPreviewWebContents(capturePage, 42, host)], + [43, makeTestPreviewWebContents(capturePage, 43, host)], ]); fromId.mockImplementation((id) => id === undefined ? null : (webContentsById.get(id) ?? null), @@ -2065,6 +2196,10 @@ describe("PreviewManager", () => { yield* Effect.yieldNow; yield* Effect.yieldNow; + const grants: Array<{ video?: unknown }> = []; + host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => grants.push(value)); + expect(grants).toEqual([{}]); + yield* manager.setMainWindow({ isDestroyed: () => false, once: vi.fn(), @@ -2075,7 +2210,60 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("returns native media sources for concurrent preview recordings", () => + effectIt.effect("does not arm recording after the main window closes during warmup", () => + withManager((manager) => + Effect.gen(function* () { + let closeMainWindow: (() => void) | undefined; + let finishWarmup!: (image: TestCapturedPreviewImage) => void; + let markWarmupStarted!: () => void; + const warmupStarted = new Promise((resolve) => { + markWarmupStarted = resolve; + }); + const capturedImage = { + toJPEG: () => Buffer.from("recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + }; + const capturePage = vi.fn( + () => + new Promise((resolve) => { + markWarmupStarted(); + finishWarmup = resolve; + }), + ); + const host = makeTestHostWebContents(); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42, host)); + + yield* manager.createTab("tab_window_close_warmup"); + yield* manager.registerWebview("tab_window_close_warmup", 42); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closeMainWindow = listener; + }), + webContents: { setBackgroundThrottling: vi.fn() }, + } as never); + + const start = yield* manager + .startRecording("tab_window_close_warmup") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => warmupStarted); + closeMainWindow?.(); + finishWarmup(capturedImage); + + const exit = yield* Fiber.await(start); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewMainWindowClosedError", + tabId: "tab_window_close_warmup", + }); + } + expect(host.session.setDisplayMediaRequestHandler).not.toHaveBeenCalled(); + }), + ), + ); + + effectIt.effect("grants each concurrent preview recording its own tab frame", () => withManager((manager) => Effect.gen(function* () { const firstJpeg = Buffer.from("first-recording-frame"); @@ -2090,6 +2278,8 @@ describe("PreviewManager", () => { })); const firstSendCommand = vi.fn(async () => undefined); const secondSendCommand = vi.fn(async () => undefined); + // Both webviews live in the same window, so they share one display-media handler. + const host = makeTestHostWebContents(); const makeWebContents = ( id: number, capturePage: typeof firstCapturePage, @@ -2097,8 +2287,8 @@ describe("PreviewManager", () => { ) => ({ id, - hostWebContents: { id: 7 }, - getMediaSourceId: vi.fn(() => `tab:${id}`), + mainFrame: { routingId: id }, + hostWebContents: host, executeJavaScript: vi.fn(async () => id === 41 ? { width: 800, height: 600 } : { width: 390, height: 844 }, ), @@ -2110,6 +2300,7 @@ describe("PreviewManager", () => { getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), + setBackgroundThrottling: vi.fn(), isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), @@ -2137,15 +2328,19 @@ describe("PreviewManager", () => { yield* manager.createTab("tab_2"); yield* manager.registerWebview("tab_1", 41); yield* manager.registerWebview("tab_2", 42); - const sources = yield* Effect.all( - [manager.startRecording("tab_1"), manager.startRecording("tab_2")], - { concurrency: 2 }, - ); - expect(sources).toEqual([ - { sourceId: "tab:41", width: 800, height: 600 }, - { sourceId: "tab:42", width: 390, height: 844 }, - ]); + const grants: Array<{ video?: unknown }> = []; + const takeGrant = () => + host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => { + grants.push(value); + }); + + yield* manager.startRecording("tab_1"); + takeGrant(); + yield* manager.startRecording("tab_2"); + takeGrant(); + expect(grants).toEqual([{ video: { routingId: 41 } }, { video: { routingId: 42 } }]); + expect(firstCapturePage).toHaveBeenCalledOnce(); expect(secondCapturePage).toHaveBeenCalledOnce(); expect(firstSendCommand).not.toHaveBeenCalledWith( @@ -2165,121 +2360,158 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("continues native recording when the source warmup fails", () => + effectIt.effect("requests display media with a fresh renderer gesture", () => withManager((manager) => Effect.gen(function* () { - const capturePage = vi.fn(async () => { - throw new Error("source is not ready"); - }); - const getMediaSourceId = vi.fn(() => "tab:42"); - const webContents = Object.assign(makeTestPreviewWebContents(capturePage), { - executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), - getMediaSourceId, - }); - fromId.mockReturnValue(webContents); + const { host, takeGrant } = yield* setupRecordingRaceTabs(manager); - yield* manager.createTab("tab_recording_warmup_failure"); - yield* manager.registerWebview("tab_recording_warmup_failure", 42); + yield* manager.startRecording("tab_race_a"); + + expect(host.executeJavaScript).toHaveBeenCalledWith( + expect.stringContaining(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER), + true, + ); + expect(host.executeJavaScript).toHaveBeenCalledWith( + expect.stringContaining("tab_race_a"), + true, + ); + takeGrant(); + yield* manager.stopRecording("tab_race_a"); + }), + ), + ); - expect(yield* manager.startRecording("tab_recording_warmup_failure")).toEqual({ - sourceId: "tab:42", - width: 1280, - height: 720, + // Runs on the real clock: an earlier queueing design only settled under TestClock and stalled the + // losing start forever in the desktop app. + effectIt.live("settles both starts when two tabs race for the capture stream", () => + withManager((manager) => + Effect.gen(function* () { + const { host, grants, takeGrant } = yield* setupRecordingRaceTabs(manager); + + const exits = yield* Effect.all( + [ + Effect.exit(manager.startRecording("tab_race_a")), + Effect.exit(manager.startRecording("tab_race_b")), + ], + { concurrency: 2 }, + ); + + const [exitA, exitB] = exits; + // Exactly one start owns the stream; the other fails fast instead of hanging. + expect(exits.filter(Exit.isSuccess)).toHaveLength(1); + const loserExit = Exit.isSuccess(exitA) ? exitB : exitA; + if (Exit.isSuccess(loserExit)) return; + expect(Option.getOrThrow(Cause.findErrorOption(loserExit.cause))).toMatchObject({ + _tag: "PreviewRecordingArmConflictError", }); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(getMediaSourceId).toHaveBeenCalledOnce(); - yield* manager.stopRecording("tab_recording_warmup_failure"); + // The single grant goes to the tab that actually won the slot, never the other one. + takeGrant(); + expect(grants).toEqual([{ video: { routingId: Exit.isSuccess(exitA) ? 41 : 42 } }]); + expect(host.session.setDisplayMediaRequestHandler).toHaveBeenCalledOnce(); }), ), ); - effectIt.effect("reports invalid native recording dimensions as a structured error", () => + effectIt.effect("releases an armed slot that the renderer never redeemed", () => withManager((manager) => Effect.gen(function* () { - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("unused-recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - const getMediaSourceId = vi.fn(() => "tab:42"); - const webContents = Object.assign(makeTestPreviewWebContents(capturePage), { - executeJavaScript: vi.fn(async () => ({ width: 0, height: 720 })), - getMediaSourceId, + const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); + + yield* manager.startRecording("tab_race_a"); + const blocked = yield* Effect.exit(manager.startRecording("tab_race_b")); + if (Exit.isSuccess(blocked)) throw new Error("expected the second tab to be refused"); + expect(Option.getOrThrow(Cause.findErrorOption(blocked.cause))).toMatchObject({ + _tag: "PreviewRecordingArmConflictError", + tabId: "tab_race_b", + armedTabId: "tab_race_a", }); - fromId.mockReturnValue(webContents); - yield* manager.createTab("tab_invalid_recording_size"); - yield* manager.registerWebview("tab_invalid_recording_size", 42); - const exit = yield* Effect.exit(manager.startRecording("tab_invalid_recording_size")); + // Nothing ever captured the armed tab, so the slot goes stale and stops blocking starts. + yield* TestClock.adjust(10_000); + yield* manager.startRecording("tab_race_b"); + takeGrant(); + expect(grants).toEqual([{ video: { routingId: 42 } }]); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isSuccess(exit)) return; - expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ - _tag: "PreviewRecordingSourceSizeUnavailableError", - tabId: "tab_invalid_recording_size", - webContentsId: 42, - }); - expect(getMediaSourceId).not.toHaveBeenCalled(); + yield* manager.stopRecording("tab_race_a"); + yield* manager.stopRecording("tab_race_b"); }), ), ); - effectIt.effect("keeps a newer recording lease when an earlier start fails", () => + effectIt.effect("denies a display-media request that arrives after the arm went stale", () => withManager((manager) => Effect.gen(function* () { - const setBackgroundThrottling = vi.fn(); - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("unused-recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - let markMeasurementStarted!: () => void; - const measurementStarted = new Promise((resolve) => { - markMeasurementStarted = resolve; - }); - let rejectFirstMeasurement!: (error: Error) => void; - const executeJavaScript = vi - .fn<(expression: string, userGesture?: boolean) => Promise>() - .mockImplementationOnce(() => { - markMeasurementStarted(); - return new Promise((_, reject) => { - rejectFirstMeasurement = reject; - }); - }) - .mockResolvedValue({ width: 1280, height: 720 }); - const webContents = Object.assign(makeTestPreviewWebContents(capturePage), { - executeJavaScript, + const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); + + yield* manager.startRecording("tab_race_a"); + yield* TestClock.adjust(10_000); + // The handler cannot read a clock, so the expiry fiber must have dropped the frame. + takeGrant(); + expect(grants).toEqual([{}]); + + yield* manager.stopRecording("tab_race_a"); + }), + ), + ); + + effectIt.effect("only lets the host frame that armed a recording claim its stream", () => + withManager((manager) => + Effect.gen(function* () { + const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); + + yield* manager.startRecording("tab_race_a"); + takeGrant({ frameTreeNodeId: 999 }); + takeGrant(); + + expect(grants).toEqual([{}, { video: { routingId: 41 } }]); + yield* manager.stopRecording("tab_race_a"); + }), + ), + ); + + effectIt.effect("reclaims the arm slot from a destroyed webContents", () => + withManager((manager) => + Effect.gen(function* () { + const { grants, takeGrant, destroy } = yield* setupRecordingRaceTabs(manager); + + yield* manager.startRecording("tab_race_a"); + destroy(41); + yield* manager.startRecording("tab_race_b"); + takeGrant(); + expect(grants).toEqual([{ video: { routingId: 42 } }]); + + yield* manager.stopRecording("tab_race_b"); + }), + ), + ); + + effectIt.effect("continues native recording when the source warmup fails", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => { + throw new Error("source is not ready"); + }); + const host = makeTestHostWebContents(); + const webContents = Object.assign(makeTestPreviewWebContents(capturePage, 42, host), { + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), }); fromId.mockReturnValue(webContents); - yield* manager.createTab("tab_recording_start_race"); - yield* manager.registerWebview("tab_recording_start_race", 42); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: { setBackgroundThrottling }, - } as never); + yield* manager.createTab("tab_recording_warmup_failure"); + yield* manager.registerWebview("tab_recording_warmup_failure", 42); - const firstStart = yield* manager - .startRecording("tab_recording_start_race") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => measurementStarted); - const secondStart = yield* manager - .startRecording("tab_recording_start_race") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.yieldNow; - expect(executeJavaScript).toHaveBeenCalledOnce(); + yield* manager.startRecording("tab_recording_warmup_failure"); + expect(capturePage).toHaveBeenCalledTimes(2); - rejectFirstMeasurement(new Error("first measurement failed")); - const firstExit = yield* Fiber.await(firstStart); - expect(Exit.isFailure(firstExit)).toBe(true); - expect(yield* Fiber.join(secondStart)).toEqual({ - sourceId: "tab:42", - width: 1280, - height: 720, - }); + // The armed tab answers exactly one display-media request, then further requests are denied. + const handler = host.displayMediaHandler(); + const streams: Array<{ video?: unknown }> = []; + handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); + handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); + expect(streams).toEqual([{ video: { routingId: 42 } }, {}]); - yield* manager.stopRecording("tab_recording_start_race"); - expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true], [false], [true]]); + yield* manager.stopRecording("tab_recording_warmup_failure"); }), ), ); @@ -2287,25 +2519,26 @@ describe("PreviewManager", () => { effectIt.effect("serializes recording source acquisition with webview replacement", () => withManager((manager) => Effect.gen(function* () { - const capturePage = vi.fn(async () => ({ + const capturedImage = { toJPEG: () => Buffer.from("unused-recording-frame"), getSize: () => ({ width: 1280, height: 720 }), - })); - let markMeasurementStarted!: () => void; - const measurementStarted = new Promise((resolve) => { - markMeasurementStarted = resolve; - }); - let finishMeasurement!: (size: { readonly width: number; readonly height: number }) => void; - const executeJavaScript = vi.fn( - () => - new Promise<{ readonly width: number; readonly height: number }>((resolve) => { - markMeasurementStarted(); - finishMeasurement = resolve; - }), - ); - const initialWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 42), { - executeJavaScript, - }); + }; + let markWarmupStarted!: () => void; + const warmupStarted = new Promise((resolve) => { + markWarmupStarted = resolve; + }); + let finishWarmup!: (image: TestCapturedPreviewImage) => void; + const capturePage = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + markWarmupStarted(); + finishWarmup = resolve; + }), + ) + .mockResolvedValue(capturedImage); + const initialWebContents = makeTestPreviewWebContents(capturePage, 42); const replacementOn = vi.fn(); const replacementWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 43), { on: replacementOn, @@ -2321,22 +2554,25 @@ describe("PreviewManager", () => { const start = yield* manager .startRecording("tab_recording_replacement_race") .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => measurementStarted); + yield* Effect.promise(() => warmupStarted); const replacement = yield* manager .registerWebview("tab_recording_replacement_race", 43) .pipe(Effect.forkChild({ startImmediately: true })); yield* Effect.yieldNow; expect(replacementOn).not.toHaveBeenCalled(); - finishMeasurement({ width: 1280, height: 720 }); - expect(yield* Fiber.join(start)).toEqual({ - sourceId: "tab:42", - width: 1280, - height: 720, - }); + finishWarmup(capturedImage); + yield* Fiber.join(start); yield* Fiber.join(replacement); expect(replacementOn).toHaveBeenCalled(); + expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); yield* manager.stopRecording("tab_recording_replacement_race"); + expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([ + [false], + [true], + ]); }), ), ); @@ -2345,7 +2581,9 @@ describe("PreviewManager", () => { withManager((manager) => Effect.gen(function* () { const setBackgroundThrottling = vi.fn(); - const mainWindowWebContents = { setBackgroundThrottling }; + const mainWindowWebContents = Object.assign(makeTestHostWebContents(), { + setBackgroundThrottling, + }); const jpeg = Buffer.from("shared-preview-frame"); const capturePage = vi.fn(async () => ({ toJPEG: () => jpeg, @@ -2353,8 +2591,8 @@ describe("PreviewManager", () => { })); fromId.mockReturnValue({ id: 42, + mainFrame: { routingId: 42 }, hostWebContents: mainWindowWebContents, - getMediaSourceId: vi.fn(() => "tab:42"), executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), isDestroyed: () => false, getType: () => "webview", @@ -2364,6 +2602,7 @@ describe("PreviewManager", () => { getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), + setBackgroundThrottling: vi.fn(), isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 7d11b2614c1e..8ee312110d86 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -5,6 +5,7 @@ * elements live in the renderer; we only attach listeners and forward state * here). Single layer-scoped browser session partition. */ +import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; import type { DesktopPreviewAnnotationTheme, DesktopPreviewAutomationStatus, @@ -16,7 +17,6 @@ import type { PreviewAnnotationSubmissionResult, DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, - DesktopPreviewRecordingSource, DesktopPreviewScreenshotArtifact, DesktopPreviewTabDefaults, PreviewAutomationClickInput, @@ -109,8 +109,8 @@ const MAX_EVALUATION_BYTES = 64_000; const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_INTERACTIVE_ELEMENTS = 200; const MAX_SCREENSHOT_WIDTH = 1280; -const RECORDING_SOURCE_SIZE_EXPRESSION = - "({ width: Math.round(globalThis.innerWidth), height: Math.round(globalThis.innerHeight) })"; +/** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */ +const RECORDING_ARM_GRACE_MS = 10_000; const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; @@ -122,6 +122,8 @@ const DIAGNOSTIC_BUFFER_LIMIT = 200; const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; +const requestRecordingCaptureExpression = (tabId: string): string => + `globalThis[${JSON.stringify(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER)}]?.(${JSON.stringify(tabId)}) === true`; const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { colorScheme: "light", @@ -390,6 +392,7 @@ type FrameCaptureConsumer = "picture-in-picture" | "recording"; interface FrameCaptureSession { readonly scope: Scope.Closeable | null; readonly consumers: ReadonlySet; + readonly unthrottledWebContentsIds: ReadonlySet; readonly lastPictureInPictureFrame: Buffer | null; } @@ -400,6 +403,14 @@ interface PictureInPictureSession { readonly initializationScope: Scope.Closeable; } +/** The tab whose frame the next `getDisplayMedia()` request is allowed to capture. */ +interface PendingRecording { + readonly tabId: string; + readonly webContents: Electron.WebContents; + readonly requestingFrameTreeNodeId: number; + readonly armedAtMillis: number; +} + interface PickSession { readonly cancel: Effect.Effect; } @@ -591,6 +602,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const pictureInPictureAspectRatiosRef = yield* Ref.make>(new Map()); const pictureInPictureMutationSemaphore = yield* Semaphore.make(1); const closingTabIdsRef = yield* Ref.make>(new Set()); + // Tab recording uses `setDisplayMediaRequestHandler` because Electron's legacy + // `getMediaSourceId` + `chromeMediaSource: "tab"` capture path was removed upstream + // (electron#44618) and now always rejects with NotAllowedError. + let pendingRecording: PendingRecording | null = null; + const displayMediaHandlerSessions = new WeakSet(); let frameCaptureWindowOpen = true; let currentMainWindow: BrowserWindow | undefined; let mainWindowCleanupFiber: Fiber.Fiber | undefined; @@ -665,6 +681,65 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (Option.isNone(mainWindow)) return; yield* setWindowBackgroundThrottling(mainWindow.value, enabled); }); + const setFrameCaptureWebContentsBackgroundThrottling = Effect.fnUntraced(function* ( + wc: Electron.WebContents, + enabled: boolean, + ) { + if (wc.isDestroyed()) return; + yield* attempt( + { + operation: "frameCapture.setBackgroundThrottling", + webContentsId: wc.id, + }, + () => wc.setBackgroundThrottling(enabled), + ); + }); + const restoreFrameCaptureWebContentsBackgroundThrottling = Effect.fnUntraced(function* ( + webContentsIds: ReadonlySet, + ) { + yield* Effect.forEach( + webContentsIds, + (webContentsId) => { + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) return Effect.void; + return setFrameCaptureWebContentsBackgroundThrottling(wc, true).pipe( + Effect.retry({ times: 2 }), + Effect.catch((error) => + Effect.logWarning("Failed to restore preview webview frame capture throttling.", { + webContentsId, + error, + }), + ), + ); + }, + { concurrency: "unbounded", discard: true }, + ); + }); + const keepFrameCaptureWebContentsUnthrottled = Effect.fnUntraced(function* ( + tabId: string, + wc: Electron.WebContents, + ) { + yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => { + const current = sessions.get(tabId); + if (!current || current.unthrottledWebContentsIds.has(wc.id)) { + return Effect.succeed([undefined, sessions] as const); + } + return setFrameCaptureWebContentsBackgroundThrottling(wc, false).pipe( + Effect.map( + () => + [ + undefined, + replaceMap(sessions, (copy) => { + copy.set(tabId, { + ...current, + unthrottledWebContentsIds: new Set([...current.unthrottledWebContentsIds, wc.id]), + }); + }), + ] as const, + ), + ); + }); + }); const stopFrameCapture = Effect.fn("PreviewManager.stopFrameCapture")(function* ( tabId: string, consumer: FrameCaptureConsumer, @@ -694,6 +769,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const remainingSessions = replaceMap(sessions, (copy) => { copy.delete(tabId); }); + yield* restoreFrameCaptureWebContentsBackgroundThrottling( + current.unthrottledWebContentsIds, + ); if (remainingSessions.size === 0) { yield* setFrameCaptureBackgroundThrottling(true).pipe( Effect.retry({ times: 2 }), @@ -713,6 +791,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const stopAllRecordings = Effect.fn("PreviewManager.stopAllRecordings")(function* () { + pendingRecording = null; const sessions = yield* SynchronizedRef.get(frameCaptureSessionsRef); yield* Effect.forEach(sessions.keys(), (tabId) => stopFrameCapture(tabId, "recording"), { concurrency: "unbounded", @@ -1889,6 +1968,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) { if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return; + clearPendingRecording(tabId); yield* Effect.all( [ cancelPickElement(tabId), @@ -1980,6 +2060,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const attached = yield* Ref.get(attachedRef); const annotationTheme = yield* Ref.get(annotationThemeRef); const currentAttachment = attached.get(webContentsId); + yield* keepFrameCaptureWebContentsUnthrottled(tabId, wc); if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { // The guest we already own re-announced itself, so nothing about the tab // changed. Only push its zoom back down — Chromium may have just handed @@ -1996,6 +2077,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ? tab.webContentsId : null; if (replacedWebContentsId !== null) { + // The replaced guest can no longer redeem a display-media grant. + clearPendingRecording(tabId); yield* Effect.all( [ detachControlSession(replacedWebContentsId), @@ -2728,7 +2811,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { // Recording keeps only the activity lease. Picture-in-picture owns the // capturePage loop and tolerates transient compositor warmup failures. - yield* requireWebContents(tabId); const captureNextFrame = Effect.sleep(PICTURE_IN_PICTURE_FRAME_INTERVAL_MS).pipe( Effect.andThen(capturePreviewFrame(tabId)), Effect.catch((error) => @@ -2749,11 +2831,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) { return yield* new PreviewTabNotFoundError({ tabId }); } + const wc = yield* requireWebContents(tabId); const current = sessions.get(tabId); if (current) { if (current.consumers.has(consumer)) { return [false, sessions] as const; } + if (!current.unthrottledWebContentsIds.has(wc.id)) { + yield* setFrameCaptureWebContentsBackgroundThrottling(wc, false); + } let scope = current.scope; if (consumer === "picture-in-picture" && scope === null) { scope = yield* Scope.fork(parentScope, "sequential"); @@ -2766,6 +2852,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ...current, scope, consumers: new Set([...current.consumers, consumer]), + unthrottledWebContentsIds: new Set([...current.unthrottledWebContentsIds, wc.id]), }); }), ] as const; @@ -2773,6 +2860,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (sessions.size === 0) { yield* setFrameCaptureBackgroundThrottling(false); } + yield* setFrameCaptureWebContentsBackgroundThrottling(wc, false).pipe( + Effect.onError(() => + sessions.size === 0 + ? setFrameCaptureBackgroundThrottling(true).pipe(Effect.ignore) + : Effect.void, + ), + ); const scope = consumer === "picture-in-picture" ? yield* Scope.fork(parentScope, "sequential") : null; if (scope !== null) { @@ -2784,6 +2878,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function copy.set(tabId, { scope, consumers: new Set([consumer]), + unthrottledWebContentsIds: new Set([wc.id]), lastPictureInPictureFrame: null, }); }), @@ -3099,6 +3194,83 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* Effect.failCause(initializationExit.cause); }); + /** Only drops the armed target when it still belongs to `tabId`, so tabs cannot clobber each other. */ + const clearPendingRecording = (tabId: string) => { + if (pendingRecording?.tabId === tabId) pendingRecording = null; + }; + + /** + * Claims the single arm slot for `tabId`. A display-media request carries no tab identity, so the + * slot is exclusive: a second tab arming before the first request lands would redirect the first + * renderer's stream. Rather than queue (which can only ever stall a start), a colliding start + * fails fast and the renderer can retry. An arm the renderer never redeemed goes stale after + * `RECORDING_ARM_GRACE_MS` so it cannot hold the slot forever. + */ + const armPendingRecording = Effect.fn("PreviewManager.armPendingRecording")(function* ( + tabId: string, + wc: Electron.WebContents, + requestingFrameTreeNodeId: number, + ) { + const now = yield* Clock.currentTimeMillis; + const previous = pendingRecording; + if ( + previous !== null && + previous.tabId !== tabId && + !previous.webContents.isDestroyed() && + now - previous.armedAtMillis < RECORDING_ARM_GRACE_MS + ) { + return yield* new PreviewRecordingArmConflictError({ + tabId, + webContentsId: wc.id, + armedTabId: previous.tabId, + }); + } + const armed: PendingRecording = { + tabId, + webContents: wc, + requestingFrameTreeNodeId, + armedAtMillis: now, + }; + pendingRecording = armed; + // The handler callback is sync and cannot read a clock, so expiry is driven from here. + // Identity compare: a re-arm replaces the object, and this fiber must not clobber it. + yield* Effect.forkIn( + Effect.sleep(RECORDING_ARM_GRACE_MS).pipe( + Effect.andThen( + Effect.sync(() => { + if (pendingRecording === armed) pendingRecording = null; + }), + ), + ), + parentScope, + ); + }); + + // Installed once per session: answers the renderer's `getDisplayMedia()` with the tab that + // `startRecording` armed, and denies anything else so pages cannot capture on their own. + const installDisplayMediaRequestHandler = (session: Session) => { + if (displayMediaHandlerSessions.has(session)) return; + displayMediaHandlerSessions.add(session); + session.setDisplayMediaRequestHandler((request, callback) => { + const armed = pendingRecording; + if (!armed) { + callback({}); + return; + } + if (armed.webContents.isDestroyed()) { + pendingRecording = null; + callback({}); + return; + } + if (request.frame?.frameTreeNodeId !== armed.requestingFrameTreeNodeId) { + callback({}); + return; + } + pendingRecording = null; + callback({ video: armed.webContents.mainFrame }); + }); + }; + const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) { return yield* new PreviewTabNotFoundError({ tabId }); @@ -3112,31 +3284,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (requestWebContents === null) { return yield* new PreviewMainWindowClosedError({ tabId }); } - const measuredSize = yield* attemptPromise( - { - operation: "recording.measureSource", - tabId, - webContentsId: wc.id, - }, - () => wc.executeJavaScript(RECORDING_SOURCE_SIZE_EXPRESSION, true), - ); - if ( - typeof measuredSize !== "object" || - measuredSize === null || - !("width" in measuredSize) || - !("height" in measuredSize) || - typeof measuredSize.width !== "number" || - typeof measuredSize.height !== "number" || - !Number.isInteger(measuredSize.width) || - !Number.isInteger(measuredSize.height) || - measuredSize.width <= 0 || - measuredSize.height <= 0 - ) { - return yield* new PreviewRecordingSourceSizeUnavailableError({ - tabId, - webContentsId: wc.id, - }); - } yield* attemptPromise( { operation: "recording.warmSource", @@ -3152,25 +3299,44 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function webContentsId: wc.id, }); } - const sourceId = yield* attempt( + if (!frameCaptureWindowOpen || requestWebContents.isDestroyed()) { + return yield* new PreviewMainWindowClosedError({ tabId }); + } + installDisplayMediaRequestHandler(requestWebContents.session); + yield* armPendingRecording(tabId, wc, requestWebContents.mainFrame.frameTreeNodeId); + const captureRequested = yield* attemptPromise( { - operation: "recording.getMediaSourceId", + operation: "recording.requestCapture", tabId, - webContentsId: wc.id, + webContentsId: requestWebContents.id, }, - () => wc.getMediaSourceId(requestWebContents), + () => + requestWebContents.executeJavaScript(requestRecordingCaptureExpression(tabId), true), ); - return { - sourceId, - width: measuredSize.width, - height: measuredSize.height, - } satisfies DesktopPreviewRecordingSource; - }).pipe(Effect.onError(() => stopFrameCapture(tabId, "recording").pipe(Effect.ignore))), + if (captureRequested !== true) { + return yield* new PreviewRecordingCaptureUnavailableError({ + tabId, + webContentsId: requestWebContents.id, + }); + } + }).pipe( + Effect.onError(() => { + clearPendingRecording(tabId); + return stopFrameCapture(tabId, "recording").pipe(Effect.ignore); + }), + ), ); }); const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { - yield* withTabLifecycleLock(tabId, stopFrameCapture(tabId, "recording")); + // Clearing runs under the tab lock so it cannot land before an in-flight start arms. + yield* withTabLifecycleLock( + tabId, + Effect.suspend(() => { + clearPendingRecording(tabId); + return stopFrameCapture(tabId, "recording"); + }), + ); }); const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( @@ -3980,12 +4146,28 @@ export class PreviewMainWindowClosedError extends Schema.TaggedErrorClass()( - "PreviewRecordingSourceSizeUnavailableError", - { tabId: Schema.String, webContentsId: Schema.Number }, +export class PreviewRecordingArmConflictError extends Schema.TaggedErrorClass()( + "PreviewRecordingArmConflictError", + { + tabId: Schema.String, + webContentsId: Schema.Number, + armedTabId: Schema.String, + }, ) { override get message(): string { - return `Preview media source dimensions are unavailable for tab ${this.tabId}`; + return `Preview tab ${this.armedTabId} is still claiming the capture stream, so recording could not start for tab ${this.tabId}`; + } +} + +export class PreviewRecordingCaptureUnavailableError extends Schema.TaggedErrorClass()( + "PreviewRecordingCaptureUnavailableError", + { + tabId: Schema.String, + webContentsId: Schema.Number, + }, +) { + override get message(): string { + return `Preview recording capture is unavailable for tab ${this.tabId} in WebContents ${this.webContentsId}`; } } @@ -4196,7 +4378,8 @@ export const PreviewManagerError = Schema.Union([ PreviewWebContentsNotFoundError, PreviewWebviewNotInitializedError, PreviewMainWindowClosedError, - PreviewRecordingSourceSizeUnavailableError, + PreviewRecordingArmConflictError, + PreviewRecordingCaptureUnavailableError, PreviewOperationError, PreviewArtifactPathOutsideDirectoryError, PreviewArtifactImageLoadError, @@ -4274,9 +4457,7 @@ export class PreviewManager extends Context.Service< readonly copyArtifactToClipboard: (path: string) => Effect.Effect; readonly openPictureInPicture: (tabId: string) => Effect.Effect; readonly closePictureInPicture: (tabId: string) => Effect.Effect; - readonly startRecording: ( - tabId: string, - ) => Effect.Effect; + readonly startRecording: (tabId: string) => Effect.Effect; readonly stopRecording: (tabId: string) => Effect.Effect; readonly saveRecording: ( tabId: string, diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 81ba55d01276..ce7bbcaf409c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -19,7 +19,7 @@ const clientSettings: ClientSettings = { browserDefaultAppearance: "dark", browserRecordingFrameRate: 60, browserAutoShowFloatingPreview: false, - confirmQuit: true, + confirmQuit: "double-click", confirmThreadArchive: true, confirmThreadDelete: false, confirmThreadUnpin: false, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 56411711eb6c..f1155175a5f2 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -27,7 +27,7 @@ import * as PreviewManager from "../preview/Manager.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; -import { makeQuitHoldHandler } from "./QuitHold.ts"; +import { makeQuitShortcutHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -551,12 +551,11 @@ export const make = Effect.gen(function* () { // close-terminal shortcut can outlive the terminal that handled its first // press, so reject repeats before they reach the native window accelerator. // Deliberate presses still flow through the renderer or native menu. - // Chrome-style hold-to-quit: intercept the quit accelerator before the - // native menu sees it and only quit after the shortcut is held. The - // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL. - const quitHoldHandler = makeQuitHoldHandler({ + // Intercept the quit accelerator before the native menu sees it and apply + // the configured direct, hold, or double-press behavior. + const quitShortcutHandler = makeQuitShortcutHandler({ platform: environment.platform, - isEnabled: () => + getMode: () => runPromise( Effect.map( clientSettings.get, @@ -566,9 +565,9 @@ export const make = Effect.gen(function* () { }), ), ), - notify: (state) => { + notify: (hint) => { if (!window.isDestroyed()) { - window.webContents.send(QUIT_SHORTCUT_CHANNEL, state); + window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint); } }, quit: () => { @@ -576,7 +575,7 @@ export const make = Effect.gen(function* () { }, }); window.webContents.on("before-input-event", (event, input) => { - quitHoldHandler(event, input); + quitShortcutHandler(event, input); if (input.type !== "keyDown" || !input.isAutoRepeat) return; const modifier = environment.platform === "darwin" ? input.meta : input.control; if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index 75fed4b08f21..c4388ad39aea 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -1,12 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { - makeQuitHoldHandler, - QUIT_DOUBLE_TAP_MS, + makeQuitShortcutHandler, + QUIT_DOUBLE_PRESS_MS, QUIT_HOLD_DURATION_MS, QUIT_HOLD_RELEASE_GRACE_MS, } from "./QuitHold.ts"; -import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts"; +import type { QuitHoldKeyInput } from "./QuitHold.ts"; +import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; + +const HOLD_DOWN = { state: "down", mode: "hold" } as const; +const DOUBLE_CLICK_DOWN = { state: "down", mode: "double-click" } as const; +const UP = { state: "up" } as const; function makeInput(overrides: Partial): QuitHoldKeyInput { return { @@ -22,22 +27,22 @@ function makeInput(overrides: Partial): QuitHoldKeyInput { } function makeHarness(options?: { - enabled?: boolean; + mode?: QuitConfirmationMode; platform?: NodeJS.Platform; - isEnabled?: () => Promise; + getMode?: () => Promise; }) { - const notifications: Array = []; + const notifications: Array = []; const quit = vi.fn(); - const handler = makeQuitHoldHandler({ + const handler = makeQuitShortcutHandler({ platform: options?.platform ?? "darwin", - isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)), - notify: (state) => notifications.push(state), + getMode: options?.getMode ?? (() => Promise.resolve(options?.mode ?? "hold")), + notify: (event) => notifications.push(event), quit, }); const preventDefault = vi.fn(); const send = async (input: QuitHoldKeyInput) => { handler({ preventDefault }, input); - // Let the isEnabled promise settle. + // Let the getMode promise settle. await Promise.resolve(); await Promise.resolve(); }; @@ -55,7 +60,7 @@ function makeHarness(options?: { return { notifications, quit, preventDefault, send, holdFor }; } -describe("makeQuitHoldHandler", () => { +describe("makeQuitShortcutHandler", () => { beforeEach(() => { vi.useFakeTimers(); }); @@ -69,12 +74,12 @@ describe("makeQuitHoldHandler", () => { const harness = makeHarness(); await harness.send(makeInput({})); expect(harness.preventDefault).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual(["down"]); + expect(harness.notifications).toEqual([HOLD_DOWN]); vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).not.toHaveBeenCalled(); // The watchdog dismisses the hint once the press is clearly over. - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("quits after a completed hold is released", async () => { @@ -86,7 +91,7 @@ describe("makeQuitHoldHandler", () => { expect(harness.quit).not.toHaveBeenCalled(); vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("waits for Q release when Cmd is released first", async () => { @@ -108,7 +113,7 @@ describe("makeQuitHoldHandler", () => { await harness.send(makeInput({})); await harness.holdFor(500); await harness.send(makeInput({ type: "keyUp" })); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); expect(harness.quit).not.toHaveBeenCalled(); }); @@ -117,61 +122,179 @@ describe("makeQuitHoldHandler", () => { const harness = makeHarness(); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); expect(harness.quit).not.toHaveBeenCalled(); }); - it("quits without showing a hint when hold-to-quit is disabled", async () => { - const harness = makeHarness({ enabled: false }); + it("quits without showing a hint in direct mode", async () => { + const harness = makeHarness({ mode: "direct" }); await harness.send(makeInput({})); expect(harness.quit).toHaveBeenCalledTimes(1); expect(harness.notifications).toEqual([]); }); - it("discards a stale isEnabled resolution from a superseded press", async () => { - // Press #1's isEnabled is still pending when the user releases and + it("honors direct mode when the key is released before its mode read settles", async () => { + let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; + const harness = makeHarness({ + getMode: () => + new Promise((resolve) => { + resolveMode = resolve; + }), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + + resolveMode?.("direct"); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + }); + + it("does not arm hold mode after a released key's mode read settles", async () => { + let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; + const harness = makeHarness({ + getMode: () => + new Promise((resolve) => { + resolveMode = resolve; + }), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + + resolveMode?.("hold"); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([]); + }); + + it("honors a quick double press when both key releases beat their mode reads", async () => { + const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; + const harness = makeHarness({ + getMode: () => new Promise((resolve) => resolvers.push(resolve)), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + + resolvers[1]?.("double-click"); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + }); + + it("discards a stale mode resolution from a superseded press", async () => { + // Press #1's mode is still pending when the user releases and // presses again; its late resolution must not act for press #2. - const resolvers: Array<(enabled: boolean) => void> = []; + const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; const harness = makeHarness({ - isEnabled: () => new Promise((resolve) => resolvers.push(resolve)), + getMode: () => new Promise((resolve) => resolvers.push(resolve)), }); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp" })); - // Outside the double-tap window, so the second press starts a new hold. - vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + // Outside the double-press window, so the second press starts a new hold. + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100); await harness.send(makeInput({})); expect(resolvers).toHaveLength(2); - // Press #1 resolves late with "disabled" — it must not quit press #2. - resolvers[0]?.(false); + // Press #1 resolves late with "direct". It must not quit press #2. + resolvers[0]?.("direct"); await Promise.resolve(); await Promise.resolve(); expect(harness.quit).not.toHaveBeenCalled(); - // Press #2 resolves enabled and completes a full hold. - resolvers[1]?.(true); + // Press #2 resolves to hold and completes the gesture. + resolvers[1]?.("hold"); await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); await harness.send(makeInput({ type: "keyUp" })); expect(harness.quit).toHaveBeenCalledTimes(1); }); - it("quits on a quick double tap, even when the first release was never seen", async () => { - const harness = makeHarness(); + it("quits on a quick double press in double-click mode when the first release is unseen", async () => { + const harness = makeHarness({ mode: "double-click" }); await harness.send(makeInput({})); - vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); await harness.send(makeInput({})); expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); + }); + + it("keeps the double-press hint visible after key release until the window ends", async () => { + const harness = makeHarness({ mode: "double-click" }); + await harness.send(makeInput({})); + vi.advanceTimersByTime(100); + await harness.send(makeInput({ type: "keyUp" })); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 101); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + vi.advanceTimersByTime(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); }); - it("treats two slow taps as separate presses", async () => { + it("accepts a second full shortcut after the modifier is released and pressed again", async () => { + const harness = makeHarness({ mode: "double-click" }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); + vi.advanceTimersByTime(100); + + await harness.send(makeInput({ key: "Meta" })); + await harness.send(makeInput({})); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); + }); + + it("expires a delayed double-press hint from keydown rather than mode resolution", async () => { + let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; + const harness = makeHarness({ + getMode: () => + new Promise((resolve) => { + resolveMode = resolve; + }), + }); + await harness.send(makeInput({})); + vi.advanceTimersByTime(100); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(100); + resolveMode?.("double-click"); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 201); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + vi.advanceTimersByTime(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); + }); + + it("treats two slow presses as separate attempts in double-click mode", async () => { + const harness = makeHarness({ mode: "double-click" }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100); + await harness.send(makeInput({})); + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); + }); + + it("does not treat two quick presses as a quit in hold mode", async () => { const harness = makeHarness(); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp" })); - vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); await harness.send(makeInput({})); expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual(["down", "up", "down"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]); }); it("cancels the hold when another key interrupts it", async () => { @@ -180,21 +303,20 @@ describe("makeQuitHoldHandler", () => { await harness.holdFor(500); // Shift pressed mid-hold breaks the gesture... await harness.send(makeInput({ shift: true })); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); // ...so later repeats past the threshold must not quit. await harness.holdFor(QUIT_HOLD_DURATION_MS); expect(harness.quit).not.toHaveBeenCalled(); }); - it("does not count an interrupted press toward a double tap", async () => { - const harness = makeHarness(); + it("does not count an interrupted press toward a double press", async () => { + const harness = makeHarness({ mode: "double-click" }); await harness.send(makeInput({})); await harness.send(makeInput({ shift: true })); - // A fresh press right after the interruption starts a new hold, not a - // double-tap quit. + // A fresh press right after the interruption starts a new attempt. await harness.send(makeInput({})); expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual(["down", "up", "down"]); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); }); it("ignores other shortcuts", async () => { diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index 885770accfa2..5756de64d417 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -1,15 +1,12 @@ // @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime. -// Chrome-style hold-to-quit. The quit accelerator is intercepted in -// before-input-event (which runs before the native menu accelerator), and the -// app only quits after the shortcut has been held for QUIT_HOLD_DURATION_MS -// and released. -// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap -// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application -// menu itself is untouched and quits immediately. +import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; + +// The quit accelerator is intercepted in before-input-event, which runs +// before the native menu accelerator. Quitting from the application menu is +// untouched and always quits immediately. export const QUIT_HOLD_DURATION_MS = 1200; -// A second quick tap of the shortcut is the user insisting: quit immediately. -export const QUIT_DOUBLE_TAP_MS = 500; +export const QUIT_DOUBLE_PRESS_MS = 500; // "Still held" is proven by auto-repeat keydowns, not by the absence of a // release: macOS suppresses a letter keyUp while the command key is down, so a // tap release can go completely unseen and a release-based timer would quit @@ -18,8 +15,6 @@ export const QUIT_DOUBLE_TAP_MS = 500; // auto-repeat disabled fall back to the application menu Quit action. export const QUIT_HOLD_RELEASE_GRACE_MS = 600; -export type QuitHoldState = "down" | "up"; - export interface QuitHoldKeyInput { readonly type: string; readonly key: string; @@ -30,27 +25,29 @@ export interface QuitHoldKeyInput { readonly isAutoRepeat: boolean; } -export interface QuitHoldOptions { +export interface QuitShortcutOptions { readonly platform: NodeJS.Platform; - readonly isEnabled: () => Promise; - readonly notify: (state: QuitHoldState) => void; + readonly getMode: () => Promise; + readonly notify: (event: QuitShortcutHintEvent) => void; readonly quit: () => void; } -export function makeQuitHoldHandler( - options: QuitHoldOptions, +export function makeQuitShortcutHandler( + options: QuitShortcutOptions, ): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void { const modifierKey = options.platform === "darwin" ? "meta" : "control"; let watchdog: NodeJS.Timeout | undefined; let holding = false; - // Set once isEnabled resolves true; auto-repeats may only complete the hold when armed. + let mode: QuitConfirmationMode | undefined; + let notified = false; + // Set once getMode resolves to hold; auto-repeats may only complete the hold when armed. let armed = false; let quitOnRelease = false; let heldSince = 0; let lastPressAt = 0; - // Incremented on every new press and every release/quit so a pending - // isEnabled() resolution from a superseded press cannot arm (or quit for) - // the current one. + // Incremented when a press is superseded or explicitly cancelled. A plain + // key release does not invalidate its pending mode read: direct mode and a + // completed second press must still be honored after that read settles. let generation = 0; const clearWatchdog = () => { @@ -60,19 +57,24 @@ export function makeQuitHoldHandler( } }; - const release = () => { - if (!holding) return; - const shouldNotify = armed || quitOnRelease; - generation += 1; + const release = (cancelPendingMode = true, keepDoublePressHint = false) => { + if (!holding && !notified) return; + const keepHint = keepDoublePressHint && mode === "double-click" && notified; + if (cancelPendingMode) generation += 1; holding = false; armed = false; quitOnRelease = false; + if (keepHint) return; + + mode = undefined; clearWatchdog(); - if (shouldNotify) options.notify("up"); + if (notified) { + notified = false; + options.notify({ state: "up" }); + } }; - // Dismisses any overlay first: if the quit is cancelled downstream the - // renderer must not be left with a stuck "Hold to Quit" hint. + // Dismisses any overlay first so a cancelled quit cannot leave a stale hint. const quitNow = () => { release(); options.quit(); @@ -83,11 +85,11 @@ export function makeQuitHoldHandler( if (input.type === "keyUp") { if (key === "q") { const shouldQuit = quitOnRelease; - release(); + release(false, true); if (shouldQuit) options.quit(); } else if (key === modifierKey) { if (!quitOnRelease) { - release(); + release(false, true); } else { watchdog = setTimeout(quitNow, QUIT_HOLD_RELEASE_GRACE_MS); } @@ -104,13 +106,17 @@ export function makeQuitHoldHandler( const modifierDown = options.platform === "darwin" ? input.meta : input.control; if (!modifierDown || input.alt || input.shift || key !== "q") { + // Re-pressing the platform modifier is the first half of a second full + // quit shortcut, so it must not cancel an active double-press window. + if (key === modifierKey && !input.alt && !input.shift) return; + // Any other key (or an extra modifier) pressed mid-hold breaks the // gesture; without this the hold timer keeps running through the // interruption and the next qualifying repeat would quit early. The - // interrupted press also stops counting toward a double tap — but only + // interrupted press also stops counting toward a double press, but only // here, not in release(), which runs mid-restart on an unseen-release // re-press and must not wipe that press's own tap timestamp. - if (holding && !input.isAutoRepeat) { + if ((holding || notified) && !input.isAutoRepeat) { lastPressAt = 0; release(); } @@ -120,7 +126,7 @@ export function makeQuitHoldHandler( event.preventDefault(); if (input.isAutoRepeat) { - if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { + if (mode === "hold" && armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { armed = false; quitOnRelease = true; clearWatchdog(); @@ -131,28 +137,51 @@ export function makeQuitHoldHandler( const now = Date.now(); const previousPressAt = lastPressAt; lastPressAt = now; - // A fresh keydown while "holding" means the key came back down after a - // release macOS never delivered — so both branches below see real taps. - if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) { - quitNow(); - return; - } - if (holding) release(); + // A fresh keydown supersedes the current physical hold or the hint kept + // alive after a detected release. + if (holding || notified) release(); generation += 1; const pressGeneration = generation; holding = true; heldSince = now; - void options.isEnabled().then( - (enabled) => { + void options.getMode().then( + (resolvedMode) => { if (generation !== pressGeneration) return; - if (!enabled) { - // Hold-to-quit disabled: a single press quits immediately. + if (resolvedMode === "direct") { quitNow(); return; } + if ( + resolvedMode === "double-click" && + previousPressAt !== 0 && + now - previousPressAt <= QUIT_DOUBLE_PRESS_MS + ) { + quitNow(); + return; + } + + if (resolvedMode === "double-click") { + const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now); + if (remainingMs <= 0) { + release(); + return; + } + mode = resolvedMode; + notified = true; + options.notify({ state: "down", mode: resolvedMode }); + watchdog = setTimeout(release, remainingMs); + return; + } + + // A hold cannot be armed after its physical press has ended. + if (!holding) return; + + mode = resolvedMode; + notified = true; + options.notify({ state: "down", mode: resolvedMode }); + armed = true; - options.notify("down"); // No auto-repeat by then means the key was released (possibly with a // suppressed keyUp) or repeat is disabled; either way, don't quit. watchdog = setTimeout(() => { diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index a5ad4c2838b4..ed8911d40075 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -71,7 +71,6 @@ const backendConfigurationLayer = Layer.succeed( resolvePrimary: Effect.die("unexpected resolvePrimary"), resolvePrimaryLabel: Effect.succeed("Windows"), resolveWsl: () => Effect.die("unexpected resolveWsl"), - resolveLocalHome: () => Effect.die("unexpected resolveLocalHome"), } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"], ); diff --git a/apps/server/package.json b/apps/server/package.json index 9dc94bb06500..ed0210dd5579 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.48", + "version": "0.0.49", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts new file mode 100644 index 000000000000..b370c8e24d3d --- /dev/null +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -0,0 +1,137 @@ +import { assert, describe, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; + +import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; +import type { ModelManifestData } from "./ModelManifest.ts"; +import { + formatClaudeVersionUpgradeMessage, + normalizeClaudeCatalogEffort, + resolveClaudeCatalogApiModelId, + resolveClaudeModelCatalog, + resolveClaudeModelsForVersion, + resolveClaudeModelSlug, +} from "./ClaudeModelCatalog.ts"; + +/** + * Test policy: adding or changing a real Claude model in model-manifest.json + * must not add or update tests here. These synthetic fixtures cover resolver + * behavior once. Add a test only when Claude adapter semantics change, such + * as introducing a new compatibility rule or dispatch mapping type. + */ + +const manifest = (): ModelManifestData => ({ + version: 1, + currentModels: {}, + providers: { + claudeAgent: { + profiles: { + synthetic: { + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [{ id: "extreme", label: "Extreme", isDefault: true }], + }, + { + id: "contextWindow", + label: "Context Window", + type: "select", + options: [{ id: "large", label: "Large", isDefault: true }], + }, + ], + }, + adapter: { + claudeCode: { + effortMap: { extreme: "high" }, + modelSuffixes: { contextWindow: { large: "[large]" } }, + }, + }, + }, + }, + models: [ + { + slug: "claude-synthetic-next", + name: "Claude Synthetic Next", + aliases: ["synthetic"], + status: "current", + profile: "synthetic", + adapter: { claudeCode: { minVersion: "3.2.0" } }, + }, + ], + }, + }, +}); + +describe("Claude model catalog", () => { + it("filters models at runtime-version boundaries and derives the upgrade message", () => { + const catalog = resolveClaudeModelCatalog(manifest()); + assert.deepStrictEqual(resolveClaudeModelsForVersion(catalog, "3.1.9"), []); + assert.deepStrictEqual( + resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug), + ["claude-synthetic-next"], + ); + assert.strictEqual( + formatClaudeVersionUpgradeMessage(catalog, "3.1.9"), + "Claude Code v3.1.9 is too old for Claude Synthetic Next. Upgrade to v3.2.0 or newer to access it.", + ); + }); + + it("resolves aliases and declarative adapter mappings", () => { + const base = manifest(); + const input: ModelManifestData = { + ...base, + providers: { + ...base.providers, + claudeAgent: { + ...base.providers!.claudeAgent!, + models: [ + { + slug: "claude-synthetic-collision", + name: "Claude Synthetic Collision", + aliases: ["claude-synthetic-next"], + status: "current", + }, + ...base.providers!.claudeAgent!.models, + ], + }, + }, + }; + const catalog = resolveClaudeModelCatalog(input); + assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "claude-synthetic-next"); + assert.strictEqual( + resolveClaudeModelSlug(catalog, "claude-synthetic-next"), + "claude-synthetic-next", + ); + assert.strictEqual(normalizeClaudeCatalogEffort(catalog, "extreme", "synthetic"), "high"); + assert.strictEqual( + resolveClaudeCatalogApiModelId(catalog, { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "synthetic", + }), + "claude-synthetic-next[large]", + ); + }); + + it("rejects malformed adapter mappings", () => { + const base = manifest(); + const malformed: ModelManifestData = { + ...base, + providers: { + ...base.providers, + claudeAgent: { + ...base.providers!.claudeAgent!, + profiles: { + ...base.providers!.claudeAgent!.profiles, + synthetic: { + ...base.providers!.claudeAgent!.profiles.synthetic!, + adapter: { claudeCode: { effortMap: { extreme: 123 } } }, + }, + }, + }, + }, + }; + assert.isFalse(hasValidClaudeManifestAdapters(malformed)); + }); +}); diff --git a/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts b/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts new file mode 100644 index 000000000000..8fd9f5d76985 --- /dev/null +++ b/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts @@ -0,0 +1,83 @@ +import type { ClaudeModelCatalog } from "./ClaudeModelCatalog.ts"; + +// Transport tests must stay independent of bundled or remote manifest contents. +// Keep every model, alias, capability, and runtime mapping in this fixture synthetic. +export const SYNTHETIC_CLAUDE_CAPABLE_MODEL = "claude-synthetic-capable"; +export const SYNTHETIC_CLAUDE_COLLIDING_ALIAS = "synthetic-collision"; +export const SYNTHETIC_CLAUDE_STANDARD_MODEL = "claude-synthetic-standard"; +export const SYNTHETIC_CLAUDE_THINKING_MODEL = "claude-synthetic-thinking"; + +const effort = { + id: "effort", + label: "Reasoning", + type: "select" as const, + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + { id: "max", label: "Max" }, + { id: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], +}; + +const contextWindow = { + id: "contextWindow", + label: "Context Window", + type: "select" as const, + options: [ + { id: "standard", label: "Standard" }, + { id: "expanded", label: "Expanded", isDefault: true }, + ], +}; + +const runtime = { + effortMap: { ultrathink: null }, + modelSuffixes: { contextWindow: { expanded: "[expanded]" } }, + contextWindowTokens: { standard: 200_000, expanded: 1_000_000 }, +}; + +export const SYNTHETIC_CLAUDE_MODEL_CATALOG: ClaudeModelCatalog = { + models: [ + { + model: { + slug: SYNTHETIC_CLAUDE_CAPABLE_MODEL, + name: "Claude Synthetic Capable", + aliases: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS], + isCustom: false, + capabilities: { + optionDescriptors: [ + effort, + { id: "fastMode", label: "Fast Mode", type: "boolean" }, + contextWindow, + ], + }, + }, + runtime, + compatibility: {}, + }, + { + model: { + slug: SYNTHETIC_CLAUDE_STANDARD_MODEL, + name: "Claude Synthetic Standard", + isCustom: false, + capabilities: { + optionDescriptors: [effort, contextWindow], + }, + }, + runtime, + compatibility: {}, + }, + { + model: { + slug: SYNTHETIC_CLAUDE_THINKING_MODEL, + name: "Claude Synthetic Thinking", + isCustom: false, + capabilities: { + optionDescriptors: [{ id: "thinking", label: "Thinking", type: "boolean" }], + }, + }, + runtime: {}, + compatibility: {}, + }, + ], +}; diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts new file mode 100644 index 000000000000..bd554f042f0b --- /dev/null +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -0,0 +1,242 @@ +import { + type ModelCapabilities, + type ModelSelection, + ProviderDriverKind, + type ServerProviderModel, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { + getModelSelectionStringOptionValue, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, + normalizeCustomModelSlug, +} from "@t3tools/shared/model"; +import { compareSemverVersions } from "@t3tools/shared/semver"; + +import { + type ClaudeCodeCompatibility, + type ClaudeCodeProfile, + decodeClaudeModelAdapter, + decodeClaudeProfileAdapter, +} from "./ClaudeModelManifest.ts"; +import { + BUNDLED_MODEL_MANIFEST, + type ModelManifestData, + resolveProviderCatalog, +} from "./ModelManifest.ts"; + +const CLAUDE = ProviderDriverKind.make("claudeAgent"); +const EMPTY_CAPABILITIES: ModelCapabilities = { optionDescriptors: [] }; + +export interface ClaudeCatalogModel { + readonly model: ServerProviderModel; + readonly runtime: ClaudeCodeProfile; + readonly compatibility: ClaudeCodeCompatibility; +} + +export interface ClaudeModelCatalog { + readonly models: ReadonlyArray; +} + +function tryResolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog | null { + const resolved = resolveProviderCatalog(manifest, CLAUDE); + if (!resolved) return null; + + const models: Array = []; + for (const entry of resolved.models) { + const profile = decodeClaudeProfileAdapter(entry.profileAdapter ?? {}); + const adapter = decodeClaudeModelAdapter(entry.adapter ?? {}); + if (Option.isNone(profile) || Option.isNone(adapter)) return null; + models.push({ + model: entry.model, + runtime: profile.value.claudeCode ?? {}, + compatibility: adapter.value.claudeCode ?? {}, + }); + } + + return { + models, + }; +} + +export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog { + return ( + tryResolveClaudeModelCatalog(manifest) ?? + tryResolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST) ?? { + models: [], + } + ); +} + +export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST); + +/** Keeps custom model aliases opaque while preserving canonical built-in models and capabilities. */ +export function scopeClaudeModelCatalog( + catalog: ClaudeModelCatalog, + customModels: ReadonlyArray, +): ClaudeModelCatalog { + const customAliases = new Set( + customModels.flatMap((model) => { + const slug = normalizeCustomModelSlug(model); + return slug ? [slug.toLowerCase()] : []; + }), + ); + if (customAliases.size === 0) return catalog; + + return { + models: catalog.models.map((entry) => { + if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) { + return entry; + } + return { + ...entry, + model: { + ...entry.model, + aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())), + }, + }; + }), + }; +} + +export function resolveClaudeCatalogModel( + catalog: ClaudeModelCatalog, + slugOrAlias: string | null | undefined, +): ClaudeCatalogModel | undefined { + const value = slugOrAlias?.trim(); + if (!value) return undefined; + return ( + catalog.models.find((entry) => entry.model.slug === value) ?? + catalog.models.find((entry) => + entry.model.aliases?.some((alias) => alias.toLowerCase() === value.toLowerCase()), + ) + ); +} + +export function resolveClaudeModelSlug(catalog: ClaudeModelCatalog, slugOrAlias: string): string { + return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.slug ?? slugOrAlias; +} + +export function getClaudeCatalogModelCapabilities( + catalog: ClaudeModelCatalog, + slugOrAlias: string | null | undefined, +): ModelCapabilities { + return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.capabilities ?? EMPTY_CAPABILITIES; +} + +function isVersionSupported( + compatibility: ClaudeCodeCompatibility, + version: string | null | undefined, +): boolean { + if (!compatibility.minVersion && !compatibility.maxVersionExclusive) return true; + if (!version) return false; + if (compatibility.minVersion && compareSemverVersions(version, compatibility.minVersion) < 0) { + return false; + } + return !( + compatibility.maxVersionExclusive && + compareSemverVersions(version, compatibility.maxVersionExclusive) >= 0 + ); +} + +export function resolveClaudeModelsForVersion( + catalog: ClaudeModelCatalog, + version: string | null | undefined, +): ReadonlyArray { + return catalog.models + .filter((entry) => isVersionSupported(entry.compatibility, version)) + .map((entry) => entry.model); +} + +export function formatClaudeVersionUpgradeMessage( + catalog: ClaudeModelCatalog, + version: string | null, +): string | undefined { + const unavailable = catalog.models + .filter( + (entry) => + entry.compatibility.minVersion && + (!version || compareSemverVersions(version, entry.compatibility.minVersion) < 0), + ) + .toSorted((left, right) => + compareSemverVersions(left.compatibility.minVersion!, right.compatibility.minVersion!), + )[0]; + if (!unavailable?.compatibility.minVersion) return undefined; + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for ${unavailable.model.name}. Upgrade to v${unavailable.compatibility.minVersion} or newer to access it.`; +} + +export function resolveClaudeCatalogEffort( + catalog: ClaudeModelCatalog, + model: string | null | undefined, + raw: string | null | undefined, +): string | undefined { + const caps = getClaudeCatalogModelCapabilities(catalog, model); + const descriptors = getProviderOptionDescriptors({ + caps, + ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}), + }); + const descriptor = descriptors.find((candidate) => candidate.id === "effort"); + const value = getProviderOptionCurrentValue(descriptor); + return typeof value === "string" ? value : undefined; +} + +export function normalizeClaudeCatalogEffort( + catalog: ClaudeModelCatalog, + effort: string | null | undefined, + model: string | null | undefined, +): string | undefined { + if (!effort) return undefined; + const effortMap = resolveClaudeCatalogModel(catalog, model)?.runtime.effortMap; + if (!effortMap || !Object.prototype.hasOwnProperty.call(effortMap, effort)) return effort; + return effortMap[effort] ?? undefined; +} + +export function isClaudeCatalogUltracodeEffort(effort: string | null | undefined): boolean { + return effort === "ultracode"; +} + +export function resolveClaudeCatalogContextWindow( + catalog: ClaudeModelCatalog, + modelSelection: ModelSelection | undefined, +): string | undefined { + const caps = getClaudeCatalogModelCapabilities(catalog, modelSelection?.model); + const raw = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); + const descriptors = getProviderOptionDescriptors({ + caps, + ...(raw ? { selections: [{ id: "contextWindow", value: raw }] } : {}), + }); + const descriptor = descriptors.find((candidate) => candidate.id === "contextWindow"); + const value = getProviderOptionCurrentValue(descriptor); + return typeof value === "string" ? value : undefined; +} + +export function resolveClaudeCatalogApiModelId( + catalog: ClaudeModelCatalog, + modelSelection: ModelSelection, +): string { + const entry = resolveClaudeCatalogModel(catalog, modelSelection.model); + const slug = entry?.model.slug ?? modelSelection.model; + const descriptors = getProviderOptionDescriptors({ + caps: entry?.model.capabilities ?? EMPTY_CAPABILITIES, + selections: modelSelection.options, + }); + for (const [optionId, suffixes] of Object.entries(entry?.runtime.modelSuffixes ?? {})) { + const value = getProviderOptionCurrentValue( + descriptors.find((descriptor) => descriptor.id === optionId), + ); + if (typeof value === "string" && suffixes[value]) return `${slug}${suffixes[value]}`; + } + return slug; +} + +export function resolveClaudeCatalogContextWindowTokens( + catalog: ClaudeModelCatalog, + modelSelection: ModelSelection | undefined, +): number | undefined { + const entry = resolveClaudeCatalogModel(catalog, modelSelection?.model); + if (!entry) return undefined; + if (entry.runtime.fixedContextWindowTokens) return entry.runtime.fixedContextWindowTokens; + const contextWindow = resolveClaudeCatalogContextWindow(catalog, modelSelection); + return contextWindow ? entry.runtime.contextWindowTokens?.[contextWindow] : undefined; +} diff --git a/apps/server/src/provider/ClaudeModelManifest.ts b/apps/server/src/provider/ClaudeModelManifest.ts new file mode 100644 index 000000000000..1bac30b2ce30 --- /dev/null +++ b/apps/server/src/provider/ClaudeModelManifest.ts @@ -0,0 +1,82 @@ +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +export const ClaudeCodeProfileSchema = Schema.Struct({ + effortMap: Schema.optional( + Schema.Record(TrimmedNonEmptyString, Schema.NullOr(TrimmedNonEmptyString)), + ), + modelSuffixes: Schema.optional( + Schema.Record( + TrimmedNonEmptyString, + Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString), + ), + ), + contextWindowTokens: Schema.optional(Schema.Record(TrimmedNonEmptyString, Schema.Number)), + fixedContextWindowTokens: Schema.optional(Schema.Number), +}); + +export const ClaudeProfileAdapterSchema = Schema.Struct({ + claudeCode: Schema.optional(ClaudeCodeProfileSchema), +}); + +const ClaudeVersionSchema = TrimmedNonEmptyString.pipe( + Schema.check( + Schema.makeFilter((version) => parseSemver(version) !== null, { + expected: "a supported semantic version", + }), + ), +); + +const ClaudeCodeCompatibilitySchema = Schema.Struct({ + minVersion: Schema.optional(ClaudeVersionSchema), + maxVersionExclusive: Schema.optional(ClaudeVersionSchema), +}).pipe( + Schema.check( + Schema.makeFilter( + ({ minVersion, maxVersionExclusive }) => + minVersion === undefined || + maxVersionExclusive === undefined || + compareSemverVersions(minVersion, maxVersionExclusive) < 0, + { expected: "a minimum version below the exclusive maximum version" }, + ), + ), +); + +export const ClaudeModelAdapterSchema = Schema.Struct({ + claudeCode: Schema.optional(ClaudeCodeCompatibilitySchema), +}); + +export type ClaudeCodeProfile = typeof ClaudeCodeProfileSchema.Type; +export type ClaudeCodeCompatibility = NonNullable; + +export const decodeClaudeProfileAdapter = Schema.decodeUnknownOption(ClaudeProfileAdapterSchema); +export const decodeClaudeModelAdapter = Schema.decodeUnknownOption(ClaudeModelAdapterSchema); + +interface ClaudeManifestAdapterInput { + readonly providers?: + | Readonly< + Record< + string, + | { + readonly profiles: Readonly>; + readonly models: ReadonlyArray<{ readonly adapter?: unknown }>; + } + | undefined + > + > + | undefined; +} + +export function hasValidClaudeManifestAdapters(manifest: ClaudeManifestAdapterInput): boolean { + const catalog = manifest.providers?.claudeAgent; + if (!catalog) return true; + + return ( + Object.values(catalog.profiles).every((profile) => + Option.isSome(decodeClaudeProfileAdapter(profile.adapter ?? {})), + ) && + catalog.models.every((model) => Option.isSome(decodeClaudeModelAdapter(model.adapter ?? {}))) + ); +} diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 0409b7c691b1..571862c2a29e 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -35,6 +35,7 @@ import { probeClaudeCapabilities, } from "../Layers/ClaudeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { @@ -128,6 +129,7 @@ export const ClaudeDriver: ProviderDriver = { const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const modelManifest = yield* ModelManifest.ModelManifest; + const modelCatalog = modelManifest.current.pipe(Effect.map(resolveClaudeModelCatalog)); const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -149,10 +151,15 @@ export const ClaudeDriver: ProviderDriver = { const adapterOptions = { instanceId, environment: processEnv, + modelCatalog, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }; const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions); - const textGeneration = yield* makeClaudeTextGeneration(effectiveConfig, processEnv); + const textGeneration = yield* makeClaudeTextGeneration( + effectiveConfig, + processEnv, + modelCatalog, + ); // Per-instance capabilities cache: keyed on binary + resolved HOME so // account-specific probes never share auth metadata across instances. @@ -166,22 +173,21 @@ export const ClaudeDriver: ProviderDriver = { }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); - // Kick the TTL-gated manifest refresh in the background and classify - // with the in-memory manifest, so a slow or hung fetch never delays the - // provider check. A refresh that lands mid-probe applies on the next one. + // Start the TTL-gated refresh without delaying provider readiness. The + // next check observes a remote manifest after the background fetch lands. const checkProvider = modelManifest.refreshInBackground.pipe( Effect.andThen( - Effect.zipWith( - checkClaudeProviderStatus( - effectiveConfig, - () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), - processEnv, - cwd, + modelManifest.current.pipe( + Effect.flatMap((manifest) => + checkClaudeProviderStatus( + effectiveConfig, + () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), + processEnv, + cwd, + resolveClaudeModelCatalog(manifest), + ), ), - modelManifest.current, - (draft, manifest) => - stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), - { concurrent: true }, + Effect.map(stampIdentity), ), ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -196,11 +202,11 @@ export const ClaudeDriver: ProviderDriver = { streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - Effect.zipWith( - makePendingClaudeProvider(settings.provider), - modelManifest.current, - (draft, manifest) => - stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + modelManifest.current.pipe( + Effect.flatMap((manifest) => + makePendingClaudeProvider(settings.provider, resolveClaudeModelCatalog(manifest)), + ), + Effect.map(stampIdentity), ), checkProvider, enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index ba3b35252a0b..9d1ff9248ffe 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -35,6 +35,13 @@ import * as TestClock from "effect/testing/TestClock"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + SYNTHETIC_CLAUDE_MODEL_CATALOG, + SYNTHETIC_CLAUDE_STANDARD_MODEL, + SYNTHETIC_CLAUDE_THINKING_MODEL, +} from "../ClaudeModelCatalog.testFixtures.ts"; import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; @@ -166,6 +173,7 @@ function makeHarness(config?: { const adapterOptions: ClaudeAdapterLiveOptions = { ...(config?.instanceId ? { instanceId: config.instanceId } : {}), + modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), createQuery: (input) => { createInput = input; return query; @@ -265,6 +273,7 @@ async function readFirstPromptMessage( const THREAD_ID = ThreadId.make("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); +const SYNTHETIC_SUBAGENT_MODEL = "claude-synthetic-subagent[expanded]"; describe("ClaudeAdapterLive", () => { it.effect("returns validation error for non-claude provider on startSession", () => { @@ -441,7 +450,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", + SYNTHETIC_CLAUDE_CAPABLE_MODEL, [{ id: "effort", value: "max" }], ), runtimeMode: "full-access", @@ -464,7 +473,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", + SYNTHETIC_CLAUDE_CAPABLE_MODEL, ), runtimeMode: "full-access", }); @@ -480,167 +489,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-4-7", - }, - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "max"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("maps xhigh effort for Claude Opus 4.7 to the SDK-supported max value", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-7", - [{ id: "effort", value: "xhigh" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "max"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("preserves xhigh effort for Claude Fable 5.1", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-fable-5-1", - [{ id: "effort", value: "xhigh" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "xhigh"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("preserves xhigh effort for Claude Fable 5", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-fable-5", - [{ id: "effort", value: "xhigh" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "xhigh"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("preserves xhigh effort for Claude Opus 5", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-5", - [{ id: "effort", value: "xhigh" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "xhigh"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", - [{ id: "effort", value: "max" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "high"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("ignores adaptive effort for Haiku 4.5", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-haiku-4-5", - [{ id: "effort", value: "high" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, undefined); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("forwards Claude thinking toggle into SDK settings for Haiku 4.5", () => { + it.effect("forwards Claude thinking toggle for models that support it", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -649,7 +498,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-haiku-4-5", + SYNTHETIC_CLAUDE_THINKING_MODEL, [{ id: "thinking", value: false }], ), runtimeMode: "full-access", @@ -665,7 +514,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("ignores Claude thinking toggle for non-Haiku models", () => { + it.effect("ignores Claude thinking toggle for models without it", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -674,7 +523,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", + SYNTHETIC_CLAUDE_STANDARD_MODEL, [{ id: "thinking", value: false }], ), runtimeMode: "full-access", @@ -697,7 +546,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", + SYNTHETIC_CLAUDE_CAPABLE_MODEL, [{ id: "fastMode", value: true }], ), runtimeMode: "full-access", @@ -713,7 +562,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("ignores claude fast mode for non-opus models", () => { + it.effect("ignores claude fast mode for models without it", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -722,7 +571,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", + SYNTHETIC_CLAUDE_STANDARD_MODEL, [{ id: "fastMode", value: true }], ), runtimeMode: "full-access", @@ -736,6 +585,97 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect( + "keeps a configured custom alias opaque without disabling the canonical built-in", + () => { + const claudeConfig = { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] }; + const customHarness = makeHarness({ claudeConfig }); + const builtInHarness = makeHarness({ claudeConfig }); + const start = (harness: ReturnType, model: string) => + Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection(ProviderInstanceId.make("claudeAgent"), model, [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + { id: "contextWindow", value: "expanded" }, + ]), + runtimeMode: "full-access", + }); + return harness.getLastCreateQueryInput()!.options; + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + const runCustomFlow = Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + { id: "contextWindow", value: "expanded" }, + ], + ), + runtimeMode: "full-access", + }); + const options = customHarness.getLastCreateQueryInput()!.options; + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "use the built-in model", + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + [{ id: "contextWindow", value: "expanded" }], + ), + attachments: [], + }); + yield* Effect.promise(() => readFirstPromptText(customHarness.getLastCreateQueryInput())); + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "keep this prompt literal", + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + [{ id: "effort", value: "ultrathink" }], + ), + attachments: [], + }); + const prompt = yield* Effect.promise(() => + readFirstPromptText(customHarness.getLastCreateQueryInput()), + ); + return { options, prompt }; + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(customHarness.layer), + ); + + return Effect.gen(function* () { + const { options: customOptions, prompt: customPrompt } = yield* runCustomFlow; + assert.equal(customOptions.model, SYNTHETIC_CLAUDE_COLLIDING_ALIAS); + assert.equal(customOptions.effort, undefined); + assert.equal(customOptions.settings, undefined); + assert.deepEqual(customHarness.query.setModelCalls, [ + `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + ]); + assert.equal(customPrompt, "keep this prompt literal"); + + const builtInOptions = yield* start(builtInHarness, SYNTHETIC_CLAUDE_CAPABLE_MODEL); + assert.equal(builtInOptions.model, `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`); + assert.equal(builtInOptions.effort, "max"); + assert.deepEqual(builtInOptions.settings, { fastMode: true }); + }); + }, + ); + it.effect("treats ultrathink as a prompt keyword instead of a session effort", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -745,7 +685,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", + SYNTHETIC_CLAUDE_STANDARD_MODEL, [{ id: "effort", value: "ultrathink" }], ), runtimeMode: "full-access", @@ -757,7 +697,7 @@ describe("ClaudeAdapterLive", () => { attachments: [], modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", + SYNTHETIC_CLAUDE_STANDARD_MODEL, [{ id: "effort", value: "ultrathink" }], ), }); @@ -778,7 +718,7 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; const modelSelection = createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", + SYNTHETIC_CLAUDE_STANDARD_MODEL, [{ id: "effort", value: "ultrathink" }], ); const session = yield* adapter.startSession({ @@ -885,7 +825,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-5", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, }, runtimeMode: "full-access", }); @@ -1900,7 +1840,7 @@ describe("ClaudeAdapterLive", () => { output_tokens: 50, }, modelUsage: { - "claude-opus-4-6": { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200000, maxOutputTokens: 64000, }, @@ -1990,7 +1930,7 @@ describe("ClaudeAdapterLive", () => { output_tokens: 50, }, modelUsage: { - "claude-opus-4-6": { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200000, maxOutputTokens: 64000, }, @@ -2125,7 +2065,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", + SYNTHETIC_CLAUDE_CAPABLE_MODEL, [{ id: "effort", value: "max" }], ), runtimeMode: "full-access", @@ -2154,7 +2094,7 @@ describe("ClaudeAdapterLive", () => { type: "assistant", parent_tool_use_id: "toolu_agent_m", message: { - model: "claude-sonnet-5[1m]", + model: SYNTHETIC_SUBAGENT_MODEL, content: [], }, uuid: "subagent-snapshot-uuid", @@ -2174,13 +2114,13 @@ describe("ClaudeAdapterLive", () => { const started = taskEvents[0]; assert.equal(started?.type, "task.started"); if (started?.type === "task.started") { - assert.equal(started.payload.model, "claude-opus-4-6"); + assert.equal(started.payload.model, SYNTHETIC_CLAUDE_CAPABLE_MODEL); assert.equal(started.payload.effort, "max"); } const progress = taskEvents[1]; assert.equal(progress?.type, "task.progress"); if (progress?.type === "task.progress") { - assert.equal(progress.payload.model, "claude-sonnet-5[1m]"); + assert.equal(progress.payload.model, SYNTHETIC_SUBAGENT_MODEL); assert.equal(progress.payload.effort, "max"); } }).pipe( @@ -2206,7 +2146,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", + SYNTHETIC_CLAUDE_CAPABLE_MODEL, [{ id: "effort", value: "max" }], ), runtimeMode: "full-access", @@ -2223,7 +2163,7 @@ describe("ClaudeAdapterLive", () => { type: "assistant", parent_tool_use_id: "toolu_agent_early", message: { - model: "claude-sonnet-5[1m]", + model: SYNTHETIC_SUBAGENT_MODEL, content: [], }, uuid: "early-snapshot-uuid", @@ -2253,13 +2193,13 @@ describe("ClaudeAdapterLive", () => { const started = taskEvents[0]; assert.equal(started?.type, "task.started"); if (started?.type === "task.started") { - assert.equal(started.payload.model, "claude-sonnet-5[1m]"); + assert.equal(started.payload.model, SYNTHETIC_SUBAGENT_MODEL); assert.equal(started.payload.effort, "max"); } const progress = taskEvents[1]; assert.equal(progress?.type, "task.progress"); if (progress?.type === "task.progress") { - assert.equal(progress.payload.model, "claude-sonnet-5[1m]"); + assert.equal(progress.payload.model, SYNTHETIC_SUBAGENT_MODEL); } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), @@ -3031,7 +2971,7 @@ describe("ClaudeAdapterLive", () => { output_tokens: 679, }, modelUsage: { - "claude-opus-4-6": { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200000, maxOutputTokens: 64000, }, @@ -3095,7 +3035,7 @@ describe("ClaudeAdapterLive", () => { total_tokens: 535000, }, modelUsage: { - "claude-opus-4-6": { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200000, maxOutputTokens: 64000, }, @@ -3172,7 +3112,7 @@ describe("ClaudeAdapterLive", () => { total_tokens: 535000, }, modelUsage: { - "claude-opus-4-6": { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200000, maxOutputTokens: 64000, }, @@ -4216,7 +4156,7 @@ describe("ClaudeAdapterLive", () => { cwd: "/tmp/claude-adapter-test", tools: [], mcp_servers: [], - model: "claude-sonnet-4-5", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, permissionMode: "bypassPermissions", slash_commands: [], output_style: "default", @@ -4379,12 +4319,14 @@ describe("ClaudeAdapterLive", () => { input: "hello", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-4-6", + model: SYNTHETIC_CLAUDE_CAPABLE_MODEL, }, attachments: [], }); - assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]"]); + assert.deepEqual(harness.query.setModelCalls, [ + `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, + ]); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -4427,7 +4369,7 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; const modelSelection = { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-4-6", + model: SYNTHETIC_CLAUDE_CAPABLE_MODEL, }; const session = yield* adapter.startSession({ @@ -4474,8 +4416,8 @@ describe("ClaudeAdapterLive", () => { input: "hello", modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [{ id: "contextWindow", value: "1m" }], + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + [{ id: "contextWindow", value: "expanded" }], ), attachments: [], }); @@ -4484,13 +4426,16 @@ describe("ClaudeAdapterLive", () => { input: "hello again", modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [{ id: "contextWindow", value: "200k" }], + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + [{ id: "contextWindow", value: "standard" }], ), attachments: [], }); - assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]", "claude-opus-4-6"]); + assert.deepEqual(harness.query.setModelCalls, [ + `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + ]); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -4700,7 +4645,7 @@ describe("ClaudeAdapterLive", () => { uuid: "assistant-exit-plan", parent_tool_use_id: null, message: { - model: "claude-opus-4-6", + model: SYNTHETIC_CLAUDE_CAPABLE_MODEL, id: "msg-exit-plan", type: "message", role: "assistant", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 2855c771fdb7..f92858d595dc 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -80,13 +80,17 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { - getClaudeModelCapabilities, - isClaudeUltracodeEffort, - normalizeClaudeCliEffort, - resolveClaudeApiModelId, - resolveClaudeContextWindow, - resolveClaudeEffort, -} from "./ClaudeProvider.ts"; + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, + getClaudeCatalogModelCapabilities, + isClaudeCatalogUltracodeEffort, + normalizeClaudeCatalogEffort, + resolveClaudeCatalogApiModelId, + resolveClaudeCatalogContextWindowTokens, + resolveClaudeCatalogEffort, + resolveClaudeModelSlug, + scopeClaudeModelCatalog, +} from "../ClaudeModelCatalog.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -333,6 +337,7 @@ export interface ClaudeAdapterLiveOptions { }) => ClaudeQueryRuntime; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; + readonly modelCatalog?: Effect.Effect; } function isUuid(value: string): boolean { @@ -381,10 +386,11 @@ function normalizeClaudeStreamMessages( } function getEffectiveClaudeAgentEffort( + catalog: ClaudeModelCatalog, effort: string | null | undefined, model: string | null | undefined, ): ClaudeSdkEffort | null { - const normalized = normalizeClaudeCliEffort(effort, model); + const normalized = normalizeClaudeCatalogEffort(catalog, effort, model); return normalized ? (normalized as ClaudeSdkEffort) : null; } @@ -470,23 +476,10 @@ function maxClaudeContextWindowFromModelUsage( } function selectedClaudeContextWindow( + catalog: ClaudeModelCatalog, modelSelection: ModelSelection | undefined, ): number | undefined { - switch (modelSelection?.model) { - case "claude-opus-4-8": - case "claude-opus-4-7": - // Always 1M at the API; these models expose no contextWindow option. - return 1_000_000; - } - - switch (resolveClaudeContextWindow(modelSelection)) { - case "1m": - return 1_000_000; - case "200k": - return 200_000; - default: - return undefined; - } + return resolveClaudeCatalogContextWindowTokens(catalog, modelSelection); } function finiteNonNegativeInteger(value: unknown): number | undefined { @@ -1234,6 +1227,7 @@ const CLAUDE_SETTING_SOURCES = [ function buildPromptText( input: ProviderSendTurnInput, boundInstanceId: ProviderInstanceId, + catalog: ClaudeModelCatalog, ): string { const rawEffort = input.modelSelection?.instanceId === boundInstanceId @@ -1241,7 +1235,7 @@ function buildPromptText( : null; const claudeModel = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : undefined; - const caps = getClaudeModelCapabilities(claudeModel); + const caps = getClaudeCatalogModelCapabilities(catalog, claudeModel); const promptEffort = resolvePromptInjectedEffort(caps, rawEffort); return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); @@ -1281,9 +1275,10 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( readonly fileSystem: FileSystem.FileSystem; readonly attachmentsDir: string; readonly boundInstanceId: ProviderInstanceId; + readonly modelCatalog: ClaudeModelCatalog; }, ) { - const text = buildPromptText(input, dependencies.boundInstanceId); + const text = buildPromptText(input, dependencies.boundInstanceId, dependencies.modelCatalog); const sdkContent: Array> = []; if (text.length > 0) { @@ -1682,6 +1677,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( options?: ClaudeAdapterLiveOptions, ) { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("claudeAgent"); + const modelCatalogEffect = ( + options?.modelCatalog ?? Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG) + ).pipe(Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels))); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; @@ -3859,6 +3857,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const startSession: ClaudeAdapterShape["startSession"] = Effect.fn("startSession")( function* (input) { + const modelCatalog = yield* modelCatalogEffect; if (input.provider !== undefined && input.provider !== PROVIDER) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, @@ -4297,14 +4296,23 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const claudeBinaryPath = claudeSdkExecutablePath; const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; - const modelSelection = + const selectedModel = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const caps = getClaudeModelCapabilities(modelSelection?.model); + const modelSelection = selectedModel + ? { + ...selectedModel, + model: resolveClaudeModelSlug(modelCatalog, selectedModel.model), + } + : undefined; + const caps = getClaudeCatalogModelCapabilities(modelCatalog, modelSelection?.model); const descriptors = getProviderOptionDescriptors({ caps }); - const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; - const initialContextWindow = selectedClaudeContextWindow(modelSelection); + const apiModelId = modelSelection + ? resolveClaudeCatalogApiModelId(modelCatalog, modelSelection) + : undefined; + const initialContextWindow = selectedClaudeContextWindow(modelCatalog, modelSelection); const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); - const effort = resolveClaudeEffort(caps, rawEffort) ?? null; + const effort = + resolveClaudeCatalogEffort(modelCatalog, modelSelection?.model, rawEffort) ?? null; const fastModeSupported = descriptors.some( (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", ); @@ -4317,8 +4325,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const thinking = thinkingSupported ? getModelSelectionBooleanOptionValue(modelSelection, "thinking") : undefined; - const ultracode = isClaudeUltracodeEffort(effort); - const effectiveEffort = getEffectiveClaudeAgentEffort(effort, modelSelection?.model); + const ultracode = isClaudeCatalogUltracodeEffort(effort); + const effectiveEffort = getEffectiveClaudeAgentEffort( + modelCatalog, + effort, + modelSelection?.model, + ); const runtimeModeToPermission: Record = { "auto-accept-edits": "acceptEdits", auto: "auto", @@ -4549,10 +4561,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { const context = yield* requireSession(input.threadId); - const modelSelection = + const modelCatalog = yield* modelCatalogEffect; + const selectedModel = input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId ? input.modelSelection : undefined; + const modelSelection = selectedModel + ? { ...selectedModel, model: resolveClaudeModelSlug(modelCatalog, selectedModel.model) } + : undefined; // 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 @@ -4566,7 +4582,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } if (modelSelection?.model) { - const apiModelId = resolveClaudeApiModelId(modelSelection); + const apiModelId = resolveClaudeCatalogApiModelId(modelCatalog, modelSelection); if (context.currentApiModelId !== apiModelId) { yield* Effect.tryPromise({ try: () => context.query.setModel(apiModelId), @@ -4578,13 +4594,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...context.session, model: modelSelection.model, }; - const turnCaps = getClaudeModelCapabilities(modelSelection.model); - const turnEffort = resolveClaudeEffort( - turnCaps, + const turnEffort = resolveClaudeCatalogEffort( + modelCatalog, + modelSelection.model, getModelSelectionStringOptionValue(modelSelection, "effort"), ); context.currentEffort = - getEffectiveClaudeAgentEffort(turnEffort ?? null, modelSelection.model) ?? undefined; + getEffectiveClaudeAgentEffort(modelCatalog, turnEffort ?? null, modelSelection.model) ?? + undefined; } // Apply interaction mode by switching the SDK's permission mode. @@ -4643,6 +4660,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( fileSystem, attachmentsDir: serverConfig.attachmentsDir, boundInstanceId, + modelCatalog, }); yield* Queue.offer(context.promptQueue, { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 8cce4c59fe84..a442d4568849 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,8 +1,6 @@ import { type ClaudeSettings, type ModelCapabilities, - type ModelSelection, - type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -12,14 +10,8 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { - createModelCapabilities, - getModelSelectionStringOptionValue, - getProviderOptionCurrentValue, - getProviderOptionDescriptors, -} from "@t3tools/shared/model"; +import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, type Options as ClaudeQueryOptions, @@ -29,8 +21,6 @@ import { } from "@anthropic-ai/claude-agent-sdk"; import { - buildBooleanOptionDescriptor, - buildSelectOptionDescriptor, buildServerProvider, DEFAULT_TIMEOUT_MS, isCommandMissingCause, @@ -42,6 +32,12 @@ import { import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; +import { + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, + formatClaudeVersionUpgradeMessage, + resolveClaudeModelsForVersion, +} from "../ClaudeModelCatalog.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -51,468 +47,6 @@ const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, } as const; -const MINIMUM_CLAUDE_FABLE_5_1_VERSION = "2.1.257"; -const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; -const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; -const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; -const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; - -const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ - { - slug: "claude-fable-5-1", - name: "Claude Fable 5.1", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { - value: "ultracode", - label: "Ultracode", - description: "xhigh effort plus multi-agent workflow orchestration", - }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - options: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, - ], - }), - ], - }), - }, - { - slug: "claude-fable-5", - name: "Claude Fable 5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { - value: "ultracode", - label: "Ultracode", - description: "xhigh effort plus multi-agent workflow orchestration", - }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - options: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, - ], - }), - ], - }), - }, - { - slug: "claude-opus-5", - name: "Claude Opus 5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { - value: "ultracode", - label: "Ultracode", - description: "xhigh effort plus multi-agent workflow orchestration", - }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - // Claude Code selects the 1M variant explicitly (`claude-opus-5[1m]`). - options: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, - ], - }), - ], - }), - }, - { - slug: "claude-opus-4-8", - name: "Claude Opus 4.8", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { - value: "ultracode", - label: "Ultracode", - description: "xhigh effort plus multi-agent workflow orchestration", - }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - ], - }), - }, - { - slug: "claude-opus-4-7", - name: "Claude Opus 4.7", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - ], - }), - }, - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - options: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, - ], - }), - ], - }), - }, - { - slug: "claude-opus-4-5", - name: "Claude Opus 4.5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - ], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - ], - }), - }, - { - slug: "claude-sonnet-5", - name: "Claude Sonnet 5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - // Sonnet is 200k-default in Claude Code (1M is opt-in there too). - options: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - }), - ], - }), - }, - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - // Sonnet is 200k-default in Claude Code (1M is opt-in there too). - options: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - }), - ], - }), - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildBooleanOptionDescriptor({ - id: "thinking", - label: "Thinking", - }), - ], - }), - }, -]; - -// Legacy classification happens at the driver boundary via `applyModelManifest`, -// so the catalog itself carries no `isLegacy` flags. -const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG; - -function supportsClaudeFable51(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_1_VERSION) >= 0 : false; -} - -function supportsClaudeOpus5(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; -} - -function supportsClaudeFable5(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_VERSION) >= 0 : false; -} - -function supportsClaudeOpus48(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_8_VERSION) >= 0 : false; -} - -function supportsClaudeOpus47(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false; -} - -function getBuiltInClaudeModelsForVersion( - version: string | null | undefined, -): ReadonlyArray { - return BUILT_IN_MODELS.filter((model) => { - if (model.slug === "claude-fable-5-1") { - return supportsClaudeFable51(version); - } - if (model.slug === "claude-opus-5") { - return supportsClaudeOpus5(version); - } - if (model.slug === "claude-fable-5") { - return supportsClaudeFable5(version); - } - if (model.slug === "claude-opus-4-8") { - return supportsClaudeOpus48(version); - } - if (model.slug === "claude-opus-4-7") { - return supportsClaudeOpus47(version); - } - return true; - }); -} - -function formatClaudeFable51UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Fable 5.1. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_1_VERSION} or newer to access it.`; -} - -function formatClaudeOpus5UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_OPUS_5_VERSION} or newer to access it.`; -} - -function formatClaudeFable5UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Fable 5. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_VERSION} or newer to access it.`; -} - -function formatClaudeOpus48UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Opus 4.8. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_8_VERSION} or newer to access it.`; -} - -function formatClaudeOpus47UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Opus 4.7. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_7_VERSION} or newer to access it.`; -} - -export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { - const slug = model?.trim(); - return ( - BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? - DEFAULT_CLAUDE_MODEL_CAPABILITIES - ); -} - -export function resolveClaudeEffort( - caps: ModelCapabilities, - raw: string | null | undefined, -): string | undefined { - const descriptors = getProviderOptionDescriptors({ - caps, - ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}), - }); - const effortDescriptor = descriptors.find((descriptor) => descriptor.id === "effort"); - const value = getProviderOptionCurrentValue(effortDescriptor); - return typeof value === "string" ? value : undefined; -} - -/** - * Normalize a resolved Claude effort value into one suitable for the Claude - * CLI's `--effort` flag. - * - * Mirrors the mapping used when invoking the Claude Agent SDK - * ({@link getEffectiveClaudeAgentEffort} in ClaudeAdapter): `ultracode` is a - * Claude Code setting that pairs with `xhigh`, `ultrathink` is filtered out - * because it is a prompt-prefix mode, and older model compatibility mappings - * are preserved for current Claude Code behavior. - */ -export function normalizeClaudeCliEffort( - effort: string | null | undefined, - model: string | null | undefined, -): string | undefined { - if (!effort || effort === "ultrathink") { - return undefined; - } - if (effort === "ultracode") { - return "xhigh"; - } - if ( - effort === "xhigh" && - model !== "claude-fable-5-1" && - model !== "claude-fable-5" && - model !== "claude-opus-5" && - model !== "claude-opus-4-8" && - model !== "claude-sonnet-5" - ) { - return "max"; - } - if (effort === "max" && model === "claude-sonnet-4-6") { - return "high"; - } - return effort; -} - -export function isClaudeUltracodeEffort(effort: string | null | undefined): boolean { - return effort === "ultracode"; -} - -export function resolveClaudeContextWindow( - modelSelection: ModelSelection | undefined, -): string | undefined { - const caps = getClaudeModelCapabilities(modelSelection?.model); - const raw = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); - const descriptors = getProviderOptionDescriptors({ - caps, - ...(raw ? { selections: [{ id: "contextWindow", value: raw }] } : {}), - }); - const descriptor = descriptors.find((candidate) => candidate.id === "contextWindow"); - const value = getProviderOptionCurrentValue(descriptor); - return typeof value === "string" ? value : undefined; -} - -export function resolveClaudeApiModelId(modelSelection: ModelSelection): string { - switch (resolveClaudeContextWindow(modelSelection)) { - case "1m": - return `${modelSelection.model}[1m]`; - default: - return modelSelection.model; - } -} - function toTitleCaseWords(value: string): string { const parts: Array = []; for (const part of value.split(/[\s_-]+/g)) { @@ -860,6 +394,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ) => Effect.Effect, environment?: NodeJS.ProcessEnv, cwd?: string, + modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, ): Effect.fn.Return< ServerProviderDraft, never, @@ -868,7 +403,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const allModels = providerModelsFromSettings( - BUILT_IN_MODELS, + modelCatalog.models.map((entry) => entry.model), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); @@ -958,21 +493,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( } const models = providerModelsFromSettings( - getBuiltInClaudeModelsForVersion(parsedVersion), + resolveClaudeModelsForVersion(modelCatalog, parsedVersion), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); - const versionUpgradeMessage = supportsClaudeFable51(parsedVersion) - ? undefined - : supportsClaudeOpus5(parsedVersion) - ? formatClaudeFable51UpgradeMessage(parsedVersion) - : supportsClaudeFable5(parsedVersion) - ? formatClaudeOpus5UpgradeMessage(parsedVersion) - : supportsClaudeOpus48(parsedVersion) - ? formatClaudeFable5UpgradeMessage(parsedVersion) - : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) - : formatClaudeOpus47UpgradeMessage(parsedVersion); + const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion); const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) @@ -1035,11 +560,12 @@ const nowIso = Effect.map(DateTime.now, DateTime.formatIso); export const makePendingClaudeProvider = ( claudeSettings: ClaudeSettings, + modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, ): Effect.Effect => Effect.gen(function* () { const checkedAt = yield* nowIso; const models = providerModelsFromSettings( - BUILT_IN_MODELS, + modelCatalog.models.map((entry) => entry.model), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ad4ddd0d0fd1..bea9979a369d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2063,248 +2063,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); - it.effect("includes Claude Opus 5 on supported Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const opus5 = status.models.find((model) => model.slug === "claude-opus-5"); - assert.strictEqual(opus5?.name, "Claude Opus 5"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.219\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("hides Claude Opus 5 on older Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-5"), - false, - ); - assert.strictEqual( - status.message, - "Claude Code v2.1.218 is too old for Claude Opus 5. Upgrade to v2.1.219 or newer to access it.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.218\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("includes Claude Fable 5 on supported Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const fable5 = status.models.find((model) => model.slug === "claude-fable-5"); - assert.strictEqual(fable5?.name, "Claude Fable 5"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.169\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("includes Claude Fable 5.1 on supported Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const fable51 = status.models.find((model) => model.slug === "claude-fable-5-1"); - assert.strictEqual(fable51?.name, "Claude Fable 5.1"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.257\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("hides Claude Fable 5.1 on older Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-fable-5-1"), - false, - ); - assert.strictEqual( - status.message, - "Claude Code v2.1.256 is too old for Claude Fable 5.1. Upgrade to v2.1.257 or newer to access it.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.256\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("hides Claude Fable 5 on older Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-fable-5"), - false, - ); - assert.strictEqual( - status.message, - "Claude Code v2.1.168 is too old for Claude Fable 5. Upgrade to v2.1.169 or newer to access it.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.168\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect( - "includes Claude Opus 4.7 with xhigh as the default effort on supported versions", - () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7"); - if (!opus47) { - assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111."); - } - if (!opus47.capabilities) { - assert.fail( - "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", - ); - } - const effortDescriptor = opus47.capabilities.optionDescriptors?.find( - (descriptor) => descriptor.type === "select" && descriptor.id === "effort", - ); - assert.deepStrictEqual( - effortDescriptor?.type === "select" - ? effortDescriptor.options.find((option) => option.isDefault) - : undefined, - { id: "xhigh", label: "Extra High", isDefault: true }, - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.111\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("hides Claude Opus 4.7 on older Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-4-7"), - false, - ); - assert.strictEqual( - status.message, - "Claude Code v2.1.110 is too old for Claude Opus 4.7. Upgrade to v2.1.111 or newer to access it.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - it.effect("returns a display label for claude subscription types", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 102940ea2374..e46a462e438a 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ServerConfig from "../config.ts"; @@ -10,61 +11,20 @@ import * as ServerSettings from "../serverSettings.ts"; import { BUNDLED_MODEL_MANIFEST, classifyModels, - isLegacyModel, make, + resolveProviderCatalog, type ModelManifestData, } from "./ModelManifest.ts"; -const CODEX = ProviderDriverKind.make("codex"); -const CLAUDE = ProviderDriverKind.make("claudeAgent"); -const CURSOR = ProviderDriverKind.make("cursor"); - -describe("isLegacyModel (bundled manifest)", () => { - it("keeps current Codex models out of legacy models", () => { - assert.deepStrictEqual( - [ - "gpt-5.6-luna", - "gpt-5.6-terra", - "gpt-5.6-sol", - "gpt-daybreak-blue-latest", - "gpt-daybreak-red-latest", - "gpt-5.4", - ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CODEX, model)]), - [ - ["gpt-5.6-luna", false], - ["gpt-5.6-terra", false], - ["gpt-5.6-sol", false], - ["gpt-daybreak-blue-latest", false], - ["gpt-daybreak-red-latest", false], - ["gpt-5.4", true], - ], - ); - }); - - it("keeps only the Claude 5 family out of legacy models", () => { - assert.deepStrictEqual( - [ - "claude-fable-5-1", - "claude-fable-5", - "claude-opus-5", - "claude-sonnet-5", - "claude-opus-4-8", - ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model)]), - [ - ["claude-fable-5-1", false], - ["claude-fable-5", true], - ["claude-opus-5", false], - ["claude-sonnet-5", false], - ["claude-opus-4-8", true], - ], - ); - }); - - it("leaves driver kinds without a manifest entry unflagged", () => { - assert.isFalse(isLegacyModel(BUNDLED_MODEL_MANIFEST, CURSOR, "composer-1.5")); - }); -}); +/** + * Test policy: this file covers manifest machinery, not manifest contents. + * Do not add assertions for real model slugs, names, status, aliases, or + * profiles when editing model-manifest.json. Add tests only when fetch/cache + * behavior or the provider-neutral resolver semantics change, and use + * synthetic models for resolver coverage. + */ +const CODEX = ProviderDriverKind.make("codex"); const model = (overrides: Partial): ServerProviderModel => ({ slug: "gpt-test", name: "GPT Test", @@ -75,37 +35,227 @@ const model = (overrides: Partial): ServerProviderModel => describe("classifyModels", () => { it("flags non-current models, clears stale flags, and skips custom models", () => { + const manifest: ModelManifestData = { + version: 1, + currentModels: { codex: ["current-a", "current-b"] }, + }; const models = [ - model({ slug: "gpt-5.6-sol" }), + model({ slug: "current-a" }), // Stale flag from a previous classification pass must be cleared. - model({ slug: "gpt-5.6-luna", isLegacy: true }), - model({ slug: "gpt-5.4" }), + model({ slug: "current-b", isLegacy: true }), + model({ slug: "old-model" }), // Custom models are user-defined and never reclassified. model({ slug: "my-own-model", isCustom: true }), ]; assert.deepStrictEqual( - classifyModels(models, BUNDLED_MODEL_MANIFEST, CODEX).map((entry) => [ - entry.slug, - entry.isLegacy ?? false, - ]), + classifyModels(models, manifest, CODEX).map((entry) => [entry.slug, entry.isLegacy ?? false]), [ - ["gpt-5.6-sol", false], - ["gpt-5.6-luna", false], - ["gpt-5.4", true], + ["current-a", false], + ["current-b", false], + ["old-model", true], ["my-own-model", false], ], ); }); }); +describe("resolveProviderCatalog", () => { + it("resolves generic model presentation through a reusable profile", () => { + const manifest: ModelManifestData = { + version: 1, + currentModels: {}, + providers: { + synthetic: { + defaults: { chat: "model-next" }, + profiles: { + standard: { + capabilities: { + optionDescriptors: [ + { + id: "mode", + label: "Mode", + type: "select", + options: [{ id: "fast", label: "Fast", isDefault: true }], + }, + ], + }, + adapter: { opaque: true }, + }, + }, + models: [ + { + slug: "model-next", + name: "Model Next", + aliases: ["next"], + status: "current", + badge: "new", + profile: "standard", + }, + ], + }, + }, + }; + + const catalog = resolveProviderCatalog(manifest, ProviderDriverKind.make("synthetic")); + assert.deepStrictEqual(catalog?.models[0], { + model: { + slug: "model-next", + name: "Model Next", + aliases: ["next"], + badge: "new", + isCustom: false, + isDefault: true, + capabilities: manifest.providers!.synthetic!.profiles.standard!.capabilities!, + }, + adapter: undefined, + profileAdapter: { opaque: true }, + }); + }); + + it("rejects invalid catalog references", () => { + const invalidCatalog = (input: { + readonly models: NonNullable[string]["models"]; + readonly defaultChat?: string; + }): ModelManifestData => ({ + version: 1, + currentModels: {}, + providers: { + synthetic: { + ...(input.defaultChat ? { defaults: { chat: input.defaultChat } } : {}), + profiles: {}, + models: input.models, + }, + }, + }); + + for (const invalid of [ + invalidCatalog({ + models: [ + { slug: "duplicate", name: "First", status: "current" }, + { slug: "duplicate", name: "Second", status: "current" }, + ], + }), + invalidCatalog({ + models: [ + { + slug: "missing-profile", + name: "Missing Profile", + status: "current", + profile: "missing", + }, + ], + }), + invalidCatalog({ + models: [{ slug: "present", name: "Present", status: "current" }], + defaultChat: "absent", + }), + ]) { + assert.isNull(resolveProviderCatalog(invalid, ProviderDriverKind.make("synthetic"))); + } + }); +}); + const REMOTE_MANIFEST: ModelManifestData = { version: 1, currentModels: { - codex: ["gpt-5.4"], - claudeAgent: ["claude-fable-5"], + codex: ["remote-model"], + claudeAgent: ["remote-agent-model"], }, }; +const REMOTE_CLAUDE_MANIFEST: ModelManifestData = { + version: 1, + currentModels: {}, + providers: { + claudeAgent: { + profiles: { + synthetic: { + adapter: { claudeCode: { effortMap: { extreme: "high" } } }, + }, + }, + models: [ + { + slug: "remote-only-model", + name: "Remote Only Model", + status: "current", + profile: "synthetic", + }, + ], + }, + }, +}; + +const remoteClaudeManifestWithCompatibility = (compatibility: unknown): ModelManifestData => ({ + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.profiles, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models.map((model) => ({ + ...model, + adapter: { claudeCode: compatibility }, + })), + }, + }, +}); + +const INVALID_REMOTE_MANIFESTS: ReadonlyArray = [ + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: { + synthetic: { + adapter: { claudeCode: { effortMap: { extreme: 123 } } }, + }, + }, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + }, + }, + }, + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: {}, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + }, + }, + }, + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.profiles, + models: [ + ...REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + { + slug: "remote-only-model", + name: "Duplicate Remote Model", + status: "current", + profile: "synthetic", + }, + ], + }, + }, + }, + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + defaults: { chat: "absent-model" }, + profiles: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.profiles, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + }, + }, + }, + remoteClaudeManifestWithCompatibility({ minVersion: "2.x" }), + remoteClaudeManifestWithCompatibility({ maxVersionExclusive: "2.x" }), + remoteClaudeManifestWithCompatibility({ + minVersion: "2.2", + maxVersionExclusive: "2.1", + }), +]; + const httpClientLayer = (handler: () => Response) => Layer.succeed( HttpClient.HttpClient, @@ -129,8 +279,6 @@ describe("ModelManifest service", () => { const service = yield* make; const refreshed = yield* service.refresh; assert.deepStrictEqual(refreshed, REMOTE_MANIFEST); - assert.isTrue(isLegacyModel(refreshed, CODEX, "gpt-5.6-sol")); - assert.isFalse(isLegacyModel(refreshed, CODEX, "gpt-5.4")); // A fresh service instance sees the disk cache without another fetch: // its HTTP layer is still stubbed, but `current` never fetches at all. @@ -162,6 +310,33 @@ describe("ModelManifest service", () => { ), ); + it.effect("preserves the last-good remote cache when later payloads are invalid", () => { + let responseIndex = 0; + const responses = [REMOTE_CLAUDE_MANIFEST, ...INVALID_REMOTE_MANIFESTS]; + + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_CLAUDE_MANIFEST); + + for (const _invalid of INVALID_REMOTE_MANIFESTS) { + yield* TestClock.adjust("1 hour"); + responseIndex += 1; + assert.deepStrictEqual(yield* service.refresh, REMOTE_CLAUDE_MANIFEST); + } + + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.current, REMOTE_CLAUDE_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-last-good-test", + response: () => Response.json(responses[responseIndex]), + }), + ), + ); + }); + it.live("does not fetch when provider update checks are disabled", () => Effect.gen(function* () { let fetchCount = 0; diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index cb9494992287..2f378f835d67 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -1,20 +1,24 @@ /** - * ModelManifest — decides which provider models are current and which belong - * in the model picker's legacy section. + * ModelManifest — remote provider-model metadata with a bundled offline + * fallback. * - * The classification data (current slugs per driver kind) lives in - * `model-manifest.json` next to this file. The bundled copy ships with every - * release. At runtime the service refreshes it from the same file on `main` - * via raw.githubusercontent.com, so a new model can leave the legacy section - * with a commit to `main` instead of a release. Preference order is remote, - * then the on-disk copy of the last successful fetch, then the bundle. A - * failed fetch never fails a provider check. + * Provider catalogs and legacy classification live in `model-manifest.json`. + * The bundled copy ships with every release; at runtime the service refreshes + * it from the same file on `main`. Preference order is remote, then the last + * successful on-disk copy, then the bundle. A failed fetch never fails a + * provider check. * - * Drivers apply the manifest to snapshot drafts with `applyModelManifest` - * before publishing, so every path that produces models (pending, probe, - * error fallbacks) is classified the same way. + * Providers with authoritative discovery can use only the classification + * overlay. Providers with static catalogs can resolve presentation and + * capabilities from `providers`, then decode their own allowlisted adapter + * payload separately. */ -import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts"; +import { + ModelCapabilities, + TrimmedNonEmptyString, + type ProviderDriverKind, + type ServerProviderModel, +} from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -27,6 +31,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; import bundledManifestJson from "./model-manifest.json" with { type: "json" }; import type { ServerProviderDraft } from "./providerSnapshot.ts"; @@ -42,23 +47,135 @@ const MANIFEST_RETRY_MS = 5 * 60 * 1000; const FETCH_TIMEOUT_MS = 10_000; +const ManifestModelStatus = Schema.Literals(["current", "legacy"]); + +const ManifestModelProfile = Schema.Struct({ + capabilities: Schema.optional(ModelCapabilities), + adapter: Schema.optional(Schema.Unknown), +}); + +const ManifestProviderModel = Schema.Struct({ + slug: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + shortName: Schema.optional(TrimmedNonEmptyString), + subProvider: Schema.optional(TrimmedNonEmptyString), + aliases: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + status: ManifestModelStatus, + badge: Schema.optional(Schema.Literal("new")), + profile: Schema.optional(TrimmedNonEmptyString), + adapter: Schema.optional(Schema.Unknown), +}); + +const ManifestProviderCatalog = Schema.Struct({ + defaults: Schema.optional( + Schema.Struct({ + chat: Schema.optional(TrimmedNonEmptyString), + }), + ), + profiles: Schema.Record(Schema.String, ManifestModelProfile), + models: Schema.Array(ManifestProviderModel), +}); + /** - * `version` gates breaking schema changes: a build only accepts remote - * manifests whose version it understands, and keeps its bundled copy - * otherwise. `currentModels` is keyed by driver kind; kinds absent from the - * map have no legacy concept and their models are left unflagged. + * `version` gates breaking schema changes. Provider catalogs are additive so + * clients that only understand `currentModels` keep accepting this v1 file. */ -const ModelManifestSchema = Schema.Struct({ +const ModelManifestEnvelopeSchema = Schema.Struct({ version: Schema.Literal(1), currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), + providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)), }); + +const hasValidProviderCatalogReferences = ( + manifest: typeof ModelManifestEnvelopeSchema.Type, +): boolean => + Object.values(manifest.providers ?? {}).every((catalog) => { + const slugs = new Set(); + const modelsAreValid = catalog.models.every((model) => { + if (slugs.has(model.slug)) return false; + slugs.add(model.slug); + return model.profile === undefined || catalog.profiles[model.profile] !== undefined; + }); + return ( + modelsAreValid && (catalog.defaults?.chat === undefined || slugs.has(catalog.defaults.chat)) + ); + }); + +const ModelManifestSchema = ModelManifestEnvelopeSchema.pipe( + Schema.check( + Schema.makeFilter(hasValidProviderCatalogReferences, { + expected: "unique model slugs and existing model and profile references", + }), + Schema.makeFilter(hasValidClaudeManifestAdapters, { + expected: "valid Claude adapter metadata", + }), + ), +); export type ModelManifestData = typeof ModelManifestSchema.Type; +export interface ResolvedManifestModel { + readonly model: ServerProviderModel; + readonly adapter: unknown; + readonly profileAdapter: unknown; +} + +export interface ResolvedProviderCatalog { + readonly models: ReadonlyArray; + readonly defaults: { + readonly chat: string | undefined; + }; +} + const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema); export const BUNDLED_MODEL_MANIFEST: ModelManifestData = Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); +/** Resolve provider-neutral model presentation and capability data. */ +export function resolveProviderCatalog( + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): ResolvedProviderCatalog | null { + const catalog = manifest.providers?.[driverKind]; + if (!catalog) return null; + + const seen = new Set(); + const models: Array = []; + for (const entry of catalog.models) { + if (seen.has(entry.slug)) return null; + seen.add(entry.slug); + + const profile = entry.profile ? catalog.profiles[entry.profile] : undefined; + if (entry.profile && !profile) return null; + + models.push({ + model: { + slug: entry.slug, + name: entry.name, + ...(entry.shortName ? { shortName: entry.shortName } : {}), + ...(entry.subProvider ? { subProvider: entry.subProvider } : {}), + ...(entry.aliases ? { aliases: entry.aliases } : {}), + ...(entry.badge ? { badge: entry.badge } : {}), + isCustom: false, + ...(catalog.defaults?.chat === entry.slug ? { isDefault: true } : {}), + ...(entry.status === "legacy" ? { isLegacy: true } : {}), + capabilities: profile?.capabilities ?? null, + }, + adapter: entry.adapter, + profileAdapter: profile?.adapter, + }); + } + + if (catalog.defaults?.chat !== undefined && !seen.has(catalog.defaults.chat)) return null; + + return { + models, + defaults: { + chat: catalog.defaults?.chat, + }, + }; +} + /** On-disk shape of the last successfully fetched manifest. */ const ManifestCacheFile = Schema.Struct({ fetchedAtMs: Schema.Number, @@ -81,6 +198,10 @@ export function isLegacyModel( driverKind: ProviderDriverKind, slug: string, ): boolean { + const catalogModel = manifest.providers?.[driverKind]?.models.find( + (model) => model.slug === slug, + ); + if (catalogModel) return catalogModel.status === "legacy"; const currentModels = manifest.currentModels[driverKind]; if (!currentModels) return false; return !currentModels.includes(slug); diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 337c32e1fda1..713015965057 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -9,5 +9,364 @@ "gpt-daybreak-red-latest" ], "claudeAgent": ["claude-fable-5-1", "claude-opus-5", "claude-sonnet-5"] + }, + "providers": { + "claudeAgent": { + "defaults": { + "chat": "claude-sonnet-5" + }, + "profiles": { + "fable-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k" }, + { "id": "1m", "label": "1M", "isDefault": true } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultracode": "xhigh", "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "opus-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k" }, + { "id": "1m", "label": "1M", "isDefault": true } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultracode": "xhigh", "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "opus-4-8": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultracode": "xhigh", "ultrathink": null }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "opus-4-7": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High" }, + { "id": "xhigh", "label": "Extra High", "isDefault": true }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "xhigh": "max", "ultrathink": null }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "opus-4-6": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k" }, + { "id": "1m", "label": "1M", "isDefault": true } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "opus-4-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "max", "label": "Max" } + ] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } + ] + }, + "adapter": { "claudeCode": {} } + }, + "sonnet-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k", "isDefault": true }, + { "id": "1m", "label": "1M" } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "sonnet-4-6": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k", "isDefault": true }, + { "id": "1m", "label": "1M" } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "max": "high", "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "haiku-4-5": { + "capabilities": { + "optionDescriptors": [{ "id": "thinking", "label": "Thinking", "type": "boolean" }] + }, + "adapter": { "claudeCode": {} } + } + }, + "models": [ + { + "slug": "claude-fable-5-1", + "name": "Claude Fable 5.1", + "aliases": ["fable", "fable-5.1", "claude-fable-5.1"], + "status": "current", + "badge": "new", + "profile": "fable-5", + "adapter": { "claudeCode": { "minVersion": "2.1.257" } } + }, + { + "slug": "claude-fable-5", + "name": "Claude Fable 5", + "status": "legacy", + "profile": "fable-5", + "adapter": { "claudeCode": { "minVersion": "2.1.169" } } + }, + { + "slug": "claude-opus-5", + "name": "Claude Opus 5", + "aliases": ["opus", "opus-5", "claude-opus-5.0", "claude-opus-5-0"], + "status": "current", + "profile": "opus-5", + "adapter": { "claudeCode": { "minVersion": "2.1.219" } } + }, + { + "slug": "claude-opus-4-8", + "name": "Claude Opus 4.8", + "aliases": ["opus-4.8", "claude-opus-4.8"], + "status": "legacy", + "profile": "opus-4-8", + "adapter": { "claudeCode": { "minVersion": "2.1.154" } } + }, + { + "slug": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "aliases": ["opus-4.7", "claude-opus-4.7"], + "status": "legacy", + "profile": "opus-4-7", + "adapter": { "claudeCode": { "minVersion": "2.1.111" } } + }, + { + "slug": "claude-opus-4-6", + "name": "Claude Opus 4.6", + "aliases": ["opus-4.6", "claude-opus-4.6", "claude-opus-4-6-20251117"], + "status": "legacy", + "profile": "opus-4-6" + }, + { + "slug": "claude-opus-4-5", + "name": "Claude Opus 4.5", + "status": "legacy", + "profile": "opus-4-5" + }, + { + "slug": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "aliases": ["sonnet", "sonnet-5", "claude-sonnet-5.0", "claude-sonnet-5-0"], + "status": "current", + "profile": "sonnet-5" + }, + { + "slug": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "aliases": ["sonnet-4.6", "claude-sonnet-4.6", "claude-sonnet-4-6-20251117"], + "status": "legacy", + "profile": "sonnet-4-6" + }, + { + "slug": "claude-haiku-4-5", + "name": "Claude Haiku 4.5", + "aliases": ["haiku", "haiku-4.5", "claude-haiku-4.5", "claude-haiku-4-5-20251001"], + "status": "legacy", + "profile": "haiku-4-5" + } + ] + } } } diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index d1bd68cb19d4..8dcaa3720295 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -11,6 +11,13 @@ import * as Schema from "effect/Schema"; import { expect } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; +import { + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + SYNTHETIC_CLAUDE_MODEL_CATALOG, + SYNTHETIC_CLAUDE_STANDARD_MODEL, + SYNTHETIC_CLAUDE_THINKING_MODEL, +} from "../provider/ClaudeModelCatalog.testFixtures.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeThreadTitle } from "./TextGenerationUtils.ts"; import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; @@ -219,13 +226,17 @@ function withFakeClaudeEnv( ); const config = decodeClaudeSettings(input.claudeConfig ?? {}); - const textGeneration = yield* makeClaudeTextGeneration(config); + const textGeneration = yield* makeClaudeTextGeneration( + config, + undefined, + Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), + ); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { - it.effect("forwards Claude thinking settings for Haiku without passing effort", () => + it.effect("forwards Claude thinking settings without passing unsupported effort", () => withFakeClaudeEnv( { output: JSON.stringify({ @@ -245,10 +256,14 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { stagedSummary: "M README.md", stagedPatch: "diff --git a/README.md b/README.md", modelSelection: { - ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [ - { id: "thinking", value: false }, - { id: "effort", value: "high" }, - ]), + ...createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_THINKING_MODEL, + [ + { id: "thinking", value: false }, + { id: "effort", value: "high" }, + ], + ), }, }); @@ -257,39 +272,83 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { ), ); - it.effect("forwards Claude fast mode and supported effort", () => + it.effect("keeps a configured custom alias opaque to the Claude CLI", () => withFakeClaudeEnv( { output: JSON.stringify({ structured_output: { - title: "Improve orchestration flow", - body: "Body", + title: "Keep custom model", + body: "", }, }), - argsMustContain: '--effort max --settings {"fastMode":true}', + argsMustContain: `--model ${SYNTHETIC_CLAUDE_COLLIDING_ALIAS} --dangerously-skip-permissions`, + claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] }, }, (textGeneration) => Effect.gen(function* () { const generated = yield* textGeneration.generatePrContent({ cwd: process.cwd(), baseBranch: "main", - headBranch: "feature/claude-effect", - commitSummary: "Improve orchestration", + headBranch: "feature/custom-model", + commitSummary: "Keep custom model", diffSummary: "1 file changed", diffPatch: "diff --git a/README.md b/README.md", - modelSelection: { - ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + [ { id: "effort", value: "max" }, { id: "fastMode", value: true }, - ]), - }, + { id: "contextWindow", value: "expanded" }, + ], + ), }); - expect(generated.title).toBe("Improve orchestration flow"); + expect(generated.title).toBe("Keep custom model"); }), ), ); + it.effect( + "keeps canonical built-in capabilities when a custom model collides with its alias", + () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: "Improve orchestration flow", + body: "Body", + }, + }), + argsMustContain: `--model ${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded] --effort max --settings {"fastMode":true} --dangerously-skip-permissions`, + claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] }, + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feature/claude-effect", + commitSummary: "Improve orchestration", + diffSummary: "1 file changed", + diffPatch: "diff --git a/README.md b/README.md", + modelSelection: { + ...createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + ), + }, + }); + + expect(generated.title).toBe("Improve orchestration flow"); + }), + ), + ); + it.effect("generates thread titles through the Claude provider", () => withFakeClaudeEnv( { @@ -308,7 +367,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { message: "Please investigate reconnect failures after restarting the session.", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-6", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, }, }); @@ -343,7 +402,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { message: "thread title", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-6", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, }, }); @@ -369,7 +428,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { message: "Name this thread.", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-6", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, }, }); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index aa3e59e2bf21..e5b13b70f1b7 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -37,12 +37,16 @@ import { getProviderOptionDescriptors, } from "@t3tools/shared/model"; import { - getClaudeModelCapabilities, - isClaudeUltracodeEffort, - normalizeClaudeCliEffort, - resolveClaudeApiModelId, - resolveClaudeEffort, -} from "../provider/Layers/ClaudeProvider.ts"; + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, + getClaudeCatalogModelCapabilities, + isClaudeCatalogUltracodeEffort, + normalizeClaudeCatalogEffort, + resolveClaudeCatalogApiModelId, + resolveClaudeCatalogEffort, + resolveClaudeModelSlug, + scopeClaudeModelCatalog, +} from "../provider/ClaudeModelCatalog.ts"; import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -61,9 +65,13 @@ const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(Cla export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, + modelCatalog: Effect.Effect = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const scopedModelCatalog = modelCatalog.pipe( + Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)), + ); const readStreamAsString = ( operation: string, @@ -121,21 +129,34 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu outputSchemaJson: S; modelSelection: ModelSelection; }): Effect.fn.Return { + const catalog = yield* scopedModelCatalog; + const resolvedModelSelection = { + ...modelSelection, + model: resolveClaudeModelSlug(catalog, modelSelection.model), + }; const jsonSchemaStr = yield* encodeJsonForOperation( operation, toJsonSchemaObject(outputSchemaJson), "Failed to encode structured output schema.", ); - const caps = getClaudeModelCapabilities(modelSelection.model); + const caps = getClaudeCatalogModelCapabilities(catalog, resolvedModelSelection.model); const descriptors = getProviderOptionDescriptors({ caps, - selections: modelSelection.options, + selections: resolvedModelSelection.options, }); const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); - const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); - const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); - const cliEffort = normalizeClaudeCliEffort(resolvedEffort, modelSelection.model); - const ultracode = isClaudeUltracodeEffort(resolvedEffort); + const rawEffortSelection = getModelSelectionStringOptionValue(resolvedModelSelection, "effort"); + const resolvedEffort = resolveClaudeCatalogEffort( + catalog, + resolvedModelSelection.model, + rawEffortSelection, + ); + const cliEffort = normalizeClaudeCatalogEffort( + catalog, + resolvedEffort, + resolvedModelSelection.model, + ); + const ultracode = isClaudeCatalogUltracodeEffort(resolvedEffort); const thinkingDescriptor = findDescriptor("thinking"); const fastModeDescriptor = findDescriptor("fastMode"); const thinking = @@ -166,7 +187,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu "--json-schema", jsonSchemaStr, "--model", - resolveClaudeApiModelId(modelSelection), + resolveClaudeCatalogApiModelId(catalog, resolvedModelSelection), ...(cliEffort ? ["--effort", cliEffort] : []), ...(settingsJson ? ["--settings", settingsJson] : []), "--dangerously-skip-permissions", diff --git a/apps/web/package.json b/apps/web/package.json index c14e670f458d..62a2f28640f0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.48", + "version": "0.0.49", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 74db764005c0..77c65264aa94 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; -import { cn } from "~/lib/utils"; +import { cn, isMacPlatform } from "~/lib/utils"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; import { useActiveBrowserRecordingTabIds } from "./browserRecording"; @@ -241,6 +241,10 @@ export function HostedBrowserWebview(props: { const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ active, renderingActive, + // Electron 43 can permanently blank a macOS webview after `visibility: hidden`. + // Inactive macOS guests intentionally remain paintable offscreen; other platforms still + // suspend them, and automation continues to see the macOS guests as inactive. + keepPaintableWhenInactive: isMacPlatform(navigator.platform), cornerRadius: presentation.cornerRadius, rect: lastRect, hiddenSize, diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index f45a69c601c9..49145f314e98 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -1,37 +1,57 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, + EnvironmentId, + ThreadId, +} from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -const { clientSettings, events, getUserMedia, registrySet, save, startScreencast, stopScreencast } = - vi.hoisted(() => { - const events: string[] = []; - return { - clientSettings: { browserRecordingFrameRate: 30 as 30 | 60 }, - events, - getUserMedia: vi.fn(), - registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet }) => { - events.push( - value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`, - ); - }), - save: vi.fn(async (tabId: string) => ({ - id: "recording-test", - tabId, - path: "/tmp/recording-test.webm", - mimeType: "video/webm" as const, - sizeBytes: 0, - createdAt: "2026-06-26T00:00:00.000Z", - })), - startScreencast: vi.fn(async (tabId: string) => { - events.push("start-screencast"); - return { sourceId: `source:${tabId}`, width: 1000, height: 620 }; - }), - stopScreencast: vi.fn(async () => undefined), - }; - }); +const { + clientSettings, + events, + getDisplayMedia, + registrySet, + requestDisplayMediaCapture, + save, + startScreencast, + stopScreencast, +} = vi.hoisted(() => { + const events: string[] = []; + return { + clientSettings: { browserRecordingFrameRate: 30 as 30 | 60 }, + events, + getDisplayMedia: vi.fn(), + requestDisplayMediaCapture: vi.fn((_tabId: string) => undefined), + registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet }) => { + events.push( + value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`, + ); + }), + save: vi.fn(async (tabId: string) => ({ + id: "recording-test", + tabId, + path: "/tmp/recording-test.webm", + mimeType: "video/webm" as const, + sizeBytes: 0, + createdAt: "2026-06-26T00:00:00.000Z", + })), + startScreencast: vi.fn(async (_tabId: string) => { + events.push("start-screencast"); + }), + stopScreencast: vi.fn(async () => undefined), + }; +}); vi.mock("~/components/preview/previewBridge", () => ({ previewBridge: { - recording: { onFrame: vi.fn(), save, startScreencast, stopScreencast }, + recording: { + onFrame: vi.fn(), + save, + startScreencast: async (tabId: string) => { + await startScreencast(tabId); + requestDisplayMediaCapture(tabId); + }, + stopScreencast, + }, }, })); @@ -50,6 +70,7 @@ import { BrowserRecordingCaptureTimeoutError, BrowserRecordingConflictError, BrowserRecordingFormatUnavailableError, + BrowserRecordingStartCancelledError, findActiveBrowserRecordingRuntimeTabId, readActiveBrowserRecordingTabIds, readActiveBrowserRecordingTargets, @@ -122,10 +143,16 @@ describe("browser recording", () => { }); vi.stubGlobal("cancelAnimationFrame", vi.fn()); vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder); - getUserMedia.mockResolvedValue({ + getDisplayMedia.mockResolvedValue({ getTracks: () => [{ stop: vi.fn() }], }); - vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); + requestDisplayMediaCapture.mockImplementation((tabId: string) => { + const trigger = Reflect.get(globalThis, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER); + if (typeof trigger !== "function" || trigger(tabId) !== true) { + throw new Error(`No pending display-media capture for ${tabId}.`); + } + }); + vi.stubGlobal("navigator", { mediaDevices: { getDisplayMedia } }); useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); }); @@ -142,13 +169,20 @@ describe("browser recording", () => { expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]); }); + it("routes gesture-free starts through the desktop capture trigger", async () => { + await startBrowserRecording("automation-recording-tab"); + + expect(requestDisplayMediaCapture).toHaveBeenCalledWith("automation-recording-tab"); + expect(getDisplayMedia).toHaveBeenCalledOnce(); + await stopBrowserRecording("automation-recording-tab"); + }); + it("paints and holds a hidden browser surface for the recording lifetime", async () => { startScreencast.mockImplementationOnce(async (tabId: string) => { expect(animationFrameCount).toBe(2); expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBe(1); - return { sourceId: `source:${tabId}`, width: 1000, height: 620 }; }); - getUserMedia.mockImplementationOnce(async () => { + getDisplayMedia.mockImplementationOnce(async () => { expect(animationFrameCount).toBe(2); expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1); return { getTracks: () => [{ stop: vi.fn() }] }; @@ -178,26 +212,16 @@ describe("browser recording", () => { await stopBrowserRecording("hidden-window-tab"); }); - it("records the native tab stream at the preview's current dimensions", async () => { + it("records the native tab stream armed by the main process", async () => { const stopTrack = vi.fn(); const stream = { getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream; - getUserMedia.mockResolvedValueOnce(stream); + getDisplayMedia.mockResolvedValueOnce(stream); await startBrowserRecording("recording-tab"); - expect(getUserMedia).toHaveBeenCalledWith({ + expect(getDisplayMedia).toHaveBeenCalledWith({ audio: false, - video: { - mandatory: { - chromeMediaSource: "tab", - chromeMediaSourceId: "source:recording-tab", - minWidth: 1000, - maxWidth: 1000, - minHeight: 620, - maxHeight: 620, - maxFrameRate: 30, - }, - }, + video: { frameRate: { max: 30 } }, }); expect(FakeMediaRecorder.instances[0]?.stream).toBe(stream); @@ -210,18 +234,16 @@ describe("browser recording", () => { await startBrowserRecording("recording-tab"); - expect(getUserMedia).toHaveBeenCalledWith({ + expect(getDisplayMedia).toHaveBeenCalledWith({ audio: false, - video: { - mandatory: expect.objectContaining({ maxFrameRate: 60 }), - }, + video: { frameRate: { max: 60 } }, }); await stopBrowserRecording("recording-tab"); }); it("stops the native stream when MediaRecorder cleanup fails", async () => { const stopTrack = vi.fn(); - getUserMedia.mockResolvedValueOnce({ + getDisplayMedia.mockResolvedValueOnce({ getTracks: () => [{ stop: stopTrack }], }); @@ -285,7 +307,7 @@ describe("browser recording", () => { }); it("releases the native capture lease when stream acquisition fails", async () => { - getUserMedia.mockRejectedValueOnce(new Error("capture failed")); + getDisplayMedia.mockRejectedValueOnce(new Error("capture failed")); await expect(startBrowserRecording("recording-tab")).rejects.toMatchObject({ operation: "capture-media-stream", @@ -300,7 +322,7 @@ describe("browser recording", () => { vi.useFakeTimers(); let finishCapture!: (stream: MediaStream) => void; const stopTrack = vi.fn(); - getUserMedia.mockImplementationOnce( + getDisplayMedia.mockImplementationOnce( () => new Promise((resolve) => { finishCapture = resolve; @@ -308,7 +330,7 @@ describe("browser recording", () => { ); const startPromise = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(getUserMedia).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); const rejection = expect(startPromise).rejects.toMatchObject({ _tag: "BrowserRecordingCaptureTimeoutError", tabId: "recording-tab", @@ -356,6 +378,124 @@ describe("browser recording", () => { expect(save).toHaveBeenCalledTimes(2); }); + it("serializes display media grants for concurrent recording starts", async () => { + let finishFirstCapture!: (stream: MediaStream) => void; + const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; + getDisplayMedia + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstCapture = resolve; + }), + ) + .mockResolvedValueOnce(stream); + + const firstStart = startBrowserRecording("recording-tab"); + await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); + const secondStart = startBrowserRecording("recording-tab-2"); + await vi.waitFor(() => expect(readActiveBrowserRecordingTabIds().size).toBe(2)); + + expect(startScreencast).toHaveBeenCalledTimes(1); + finishFirstCapture(stream); + await Promise.all([firstStart, secondStart]); + + expect(startScreencast.mock.calls).toEqual([["recording-tab"], ["recording-tab-2"]]); + expect(getDisplayMedia).toHaveBeenCalledTimes(2); + await Promise.all([ + stopBrowserRecording("recording-tab"), + stopBrowserRecording("recording-tab-2"), + ]); + }); + + it("cancels a queued recording when stopped before its media grant", async () => { + let finishFirstCapture!: (stream: MediaStream) => void; + const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; + getDisplayMedia.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstCapture = resolve; + }), + ); + + const firstStart = startBrowserRecording("recording-tab"); + await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); + const secondStart = startBrowserRecording("recording-tab-2"); + await vi.waitFor(() => expect(readActiveBrowserRecordingTabIds().size).toBe(2)); + + const secondStop = stopBrowserRecording("recording-tab-2"); + await expect(secondStart).rejects.toBeInstanceOf(BrowserRecordingStartCancelledError); + await expect(secondStop).resolves.toBeNull(); + expect(startScreencast).toHaveBeenCalledTimes(1); + + finishFirstCapture(stream); + await firstStart; + await stopBrowserRecording("recording-tab"); + expect(getDisplayMedia).toHaveBeenCalledOnce(); + }); + + it("latches a stop that arrives before the start becomes queued", async () => { + let releaseDelayedPaint!: (timestamp: number) => void; + let frameId = 0; + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => { + frameId += 1; + if (frameId === 1) releaseDelayedPaint = callback; + else callback(frameId); + return frameId; + }), + ); + let finishBlockingCapture!: (stream: MediaStream) => void; + const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; + getDisplayMedia.mockImplementationOnce( + () => + new Promise((resolve) => { + finishBlockingCapture = resolve; + }), + ); + + const delayedStart = startBrowserRecording("delayed-tab"); + await vi.waitFor(() => + expect(readActiveBrowserRecordingTabIds().has("delayed-tab")).toBe(true), + ); + const blockingStart = startBrowserRecording("blocking-tab"); + await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); + + const delayedStop = stopBrowserRecording("delayed-tab"); + releaseDelayedPaint(1); + + await expect(delayedStart).rejects.toBeInstanceOf(BrowserRecordingStartCancelledError); + await expect(delayedStop).resolves.toBeNull(); + expect(startScreencast).toHaveBeenCalledOnce(); + + finishBlockingCapture(stream); + await blockingStart; + await stopBrowserRecording("blocking-tab"); + }); + + it("finishes an uncontended pre-grant start before stopping", async () => { + const animationFrames: FrameRequestCallback[] = []; + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => { + animationFrames.push(callback); + return animationFrames.length; + }), + ); + + const startPromise = startBrowserRecording("recording-tab"); + await vi.waitFor(() => + expect(readActiveBrowserRecordingTabIds().has("recording-tab")).toBe(true), + ); + const stopPromise = stopBrowserRecording("recording-tab"); + expect(startScreencast).not.toHaveBeenCalled(); + + animationFrames.shift()?.(1); + animationFrames.shift()?.(2); + await startPromise; + await expect(stopPromise).resolves.toMatchObject({ tabId: "recording-tab" }); + }); + it("keeps a recording reachable through its runtime id after a server epoch changes", async () => { const threadRef = { environmentId: EnvironmentId.make("environment-recording"), @@ -387,7 +527,6 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - return { sourceId: "source:recording-tab", width: 1000, height: 620 }; }); const firstStart = startBrowserRecording("recording-tab"); @@ -452,7 +591,6 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - return { sourceId: "source:recording-tab", width: 1000, height: 620 }; }); const startPromise = startBrowserRecording("recording-tab"); @@ -476,7 +614,6 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - return { sourceId: "source:recording-tab", width: 1000, height: 620 }; }); const firstStart = startBrowserRecording("recording-tab"); @@ -503,7 +640,6 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - return { sourceId: "source:recording-tab", width: 1000, height: 620 }; }); stopScreencast.mockRejectedValueOnce(new Error("initial stop failed")); @@ -537,7 +673,6 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - return { sourceId: "source:recording-tab", width: 1000, height: 620 }; }); const startPromise = startBrowserRecording("recording-tab"); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index e242f25c7d2d..73bc2708ddf6 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -1,8 +1,5 @@ -import type { - DesktopPreviewRecordingArtifact, - DesktopPreviewRecordingSource, - ScopedThreadRef, -} from "@t3tools/contracts"; +import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; +import type { DesktopPreviewRecordingArtifact, ScopedThreadRef } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; @@ -36,6 +33,17 @@ export class BrowserRecordingConflictError extends Schema.TaggedErrorClass()( + "BrowserRecordingStartCancelledError", + { + tabId: Schema.String, + }, +) { + override get message(): string { + return `Browser recording start was cancelled for tab ${this.tabId}.`; + } +} + export class BrowserRecordingFormatUnavailableError extends Schema.TaggedErrorClass()( "BrowserRecordingFormatUnavailableError", { tabId: Schema.String }, @@ -82,9 +90,21 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass; + readonly cancelBeforeGrant: () => void; + readonly setQueuedForGrant: (queued: boolean) => void; +} type BrowserRecordingLifecycle = - | { readonly phase: "starting" } + | StartingBrowserRecordingLifecycle | { readonly phase: "recording" } | { readonly phase: "stopping"; @@ -124,6 +144,53 @@ export function useActiveBrowserRecordingTabIds(): ReadonlySet { } const activeRecordings = new Map(); +let displayMediaGrantTail = Promise.resolve(); +let displayMediaGrantQueueDepth = 0; + +const makeStartingBrowserRecordingLifecycle = (): StartingBrowserRecordingLifecycle => { + let signalCancellation!: () => void; + const cancelledBeforeGrantSignal = new Promise((resolve) => { + signalCancellation = resolve; + }); + const lifecycle: StartingBrowserRecordingLifecycle = { + phase: "starting", + queuedForGrant: null, + grantStarted: false, + stopRequestedBeforeGrant: false, + cancelledBeforeGrant: false, + cancelledBeforeGrantSignal, + cancelBeforeGrant: () => { + // Queue position is unknown during paint/settings warmup. Keep the stop request so a start + // that later turns out to be contended can still be cancelled before native capture. + lifecycle.stopRequestedBeforeGrant = true; + if (lifecycle.queuedForGrant && !lifecycle.grantStarted && !lifecycle.cancelledBeforeGrant) { + lifecycle.cancelledBeforeGrant = true; + signalCancellation(); + } + }, + setQueuedForGrant: (queued) => { + lifecycle.queuedForGrant = queued; + if (queued && lifecycle.stopRequestedBeforeGrant) lifecycle.cancelBeforeGrant(); + }, + }; + return lifecycle; +}; + +const queueDisplayMediaGrant = ( + useGrant: () => Promise, +): { readonly queued: boolean; readonly result: Promise } => { + const queued = displayMediaGrantQueueDepth > 0; + displayMediaGrantQueueDepth += 1; + const result = displayMediaGrantTail.then(useGrant); + const settleGrant = () => { + displayMediaGrantQueueDepth -= 1; + }; + displayMediaGrantTail = result.then( + () => settleGrant(), + () => settleGrant(), + ); + return { queued, result }; +}; const publishActiveRecordingTabIds = (): void => { appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { @@ -184,23 +251,12 @@ const createMediaRecorder = (stream: MediaStream): MediaRecorder => { return mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); }; -const captureTabMediaStream = ( - source: DesktopPreviewRecordingSource, - frameRate: number, -): Promise => - navigator.mediaDevices.getUserMedia({ +const captureTabMediaStream = (frameRate: number): Promise => + // The desktop main process routes this request to the tab that `startScreencast` armed, so the + // stream already arrives at that tab's native size and needs no source or dimension constraints. + navigator.mediaDevices.getDisplayMedia({ audio: false, - video: { - mandatory: { - chromeMediaSource: "tab", - chromeMediaSourceId: source.sourceId, - minWidth: source.width, - maxWidth: source.width, - minHeight: source.height, - maxHeight: source.height, - maxFrameRate: frameRate, - }, - } as unknown as MediaTrackConstraints, + video: { frameRate: { max: frameRate } }, }); const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise => { @@ -216,14 +272,75 @@ const stopMediaStream = (stream: MediaStream | null): void => { for (const track of stream?.getTracks() ?? []) track.stop(); }; +interface PendingTabMediaCapture { + readonly start: () => void; +} + +const pendingTabMediaCaptures = new Map(); + +const prepareTabMediaCapture = (tabId: string, frameRate: number) => { + let acceptStream = true; + let capturedStream: MediaStream | null = null; + let resolveCapture!: (stream: MediaStream | PromiseLike) => void; + let rejectCapture!: (cause: unknown) => void; + const capturePromise = new Promise((resolve, reject) => { + resolveCapture = resolve; + rejectCapture = reject; + }).then((stream) => { + capturedStream = stream; + if (!acceptStream) { + stopMediaStream(stream); + capturedStream = null; + } + return stream; + }); + const pending: PendingTabMediaCapture = { + start: () => { + try { + // Electron invokes this callback through executeJavaScript(..., true), so even automated + // and delayed queued starts satisfy getDisplayMedia's transient-activation requirement. + resolveCapture(captureTabMediaStream(frameRate)); + } catch (cause) { + rejectCapture(cause); + } + }, + }; + pendingTabMediaCaptures.set(tabId, pending); + return { + capturePromise, + cancel: () => { + acceptStream = false; + if (capturedStream) { + stopMediaStream(capturedStream); + capturedStream = null; + } + if (pendingTabMediaCaptures.get(tabId) === pending) pendingTabMediaCaptures.delete(tabId); + void capturePromise.catch(() => undefined); + }, + }; +}; + +const triggerTabMediaCapture = (tabId: unknown): boolean => { + if (typeof tabId !== "string") return false; + const pending = pendingTabMediaCaptures.get(tabId); + if (!pending) return false; + pendingTabMediaCaptures.delete(tabId); + pending.start(); + return true; +}; + +Object.defineProperty(globalThis, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, { + configurable: true, + value: triggerTabMediaCapture, +}); + const captureTabMediaStreamWithTimeout = async ( tabId: string, - source: DesktopPreviewRecordingSource, - frameRate: number, + capturePromise: Promise, ): Promise => { let acceptStream = true; let timeoutId: number | null = null; - const streamPromise = captureTabMediaStream(source, frameRate).then((stream) => { + const streamPromise = capturePromise.then((stream) => { if (!acceptStream) stopMediaStream(stream); return stream; }); @@ -382,6 +499,7 @@ export async function startBrowserRecording( const startupSettled = new Promise((resolve) => { settleStartup = resolve; }); + const startingLifecycle = makeStartingBrowserRecordingLifecycle(); const releaseSurfaceActivity = acquireBrowserSurfaceActivity(tabId); const recording: ActiveRecording = { tabId, @@ -393,7 +511,7 @@ export async function startBrowserRecording( releaseSurfaceActivity, stream: null, recorder: null, - lifecycle: { phase: "starting" }, + lifecycle: startingLifecycle, }; activeRecordings.set(tabId, recording); publishActiveRecordingTabIds(); @@ -402,23 +520,9 @@ export async function startBrowserRecording( () => getClientSettings().browserRecordingFrameRate, ); const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]); - let source: DesktopPreviewRecordingSource; - try { - source = await bridge.recording.startScreencast(tabId); - } catch (cause) { - if (!isRecordingStarting(recording)) { - throw recordingStartupCancelledError(recording, cause); - } - clearActiveRecording(recording); - throw new BrowserRecordingOperationError({ - operation: "start-screencast", - tabId, - cause, - }); - } const throwIfStartupCancelled = async (): Promise => { - // A stop requested during startup should let startup finish so the - // caller receives a real artifact. Only replacement/removal cancels it. + // Once a grant starts, a stop lets startup finish so the caller receives an artifact. + // Only a contended start can be cancelled before it reaches native capture. if (activeRecordings.get(tabId) === recording) return; try { await bridge.recording.stopScreencast(tabId); @@ -434,30 +538,67 @@ export async function startBrowserRecording( } throw recordingStartupCancelledError(recording); }; - await throwIfStartupCancelled(); - try { - recording.stream = await captureTabMediaStreamWithTimeout(tabId, source, frameRate); - } catch (cause) { - const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); - if (isBrowserRecordingCaptureTimeoutError(cause) && cleanupCause === undefined) throw cause; - throw new BrowserRecordingOperationError({ - operation: "capture-media-stream", - tabId, - cause: - cleanupCause === undefined - ? cause - : new AggregateError( - [cause, cleanupCause], - `Browser media capture and cleanup failed for tab ${tabId}.`, - { cause }, - ), - }); - } + // The desktop process exposes one display-media grant at a time. Keep only the + // arm-to-capture handoff exclusive; acquired streams can record concurrently. + const grant = queueDisplayMediaGrant(async () => { + if (startingLifecycle.cancelledBeforeGrant) { + throw new BrowserRecordingStartCancelledError({ tabId }); + } + startingLifecycle.grantStarted = true; + await throwIfStartupCancelled(); + const capture = prepareTabMediaCapture(tabId, frameRate); + try { + await bridge.recording.startScreencast(tabId); + } catch (cause) { + capture.cancel(); + if (!isRecordingStarting(recording)) { + throw recordingStartupCancelledError(recording, cause); + } + clearActiveRecording(recording); + throw new BrowserRecordingOperationError({ + operation: "start-screencast", + tabId, + cause, + }); + } + try { + await throwIfStartupCancelled(); + } catch (cause) { + capture.cancel(); + throw cause; + } + try { + recording.stream = await captureTabMediaStreamWithTimeout(tabId, capture.capturePromise); + return recording.stream; + } catch (cause) { + const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); + if (isBrowserRecordingCaptureTimeoutError(cause) && cleanupCause === undefined) throw cause; + throw new BrowserRecordingOperationError({ + operation: "capture-media-stream", + tabId, + cause: + cleanupCause === undefined + ? cause + : new AggregateError( + [cause, cleanupCause], + `Browser media capture and cleanup failed for tab ${tabId}.`, + { cause }, + ), + }); + } + }); + startingLifecycle.setQueuedForGrant(grant.queued); + const stream = await Promise.race([ + grant.result, + startingLifecycle.cancelledBeforeGrantSignal.then(() => { + throw new BrowserRecordingStartCancelledError({ tabId }); + }), + ]); await throwIfStartupCancelled(); let recorder: MediaRecorder; try { - recorder = createMediaRecorder(recording.stream); + recorder = createMediaRecorder(stream); recording.recorder = recorder; recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) chunks.push(event.data); @@ -639,6 +780,7 @@ export function stopBrowserRecording( const recording = activeRecordings.get(tabId); if (!bridge || !recording) return Promise.resolve(null); if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise; + if (recording.lifecycle.phase === "starting") recording.lifecycle.cancelBeforeGrant(); const stopPromise = Promise.resolve() .then(() => finalizeBrowserRecording(bridge, recording)) diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts index c2c78f4ac61c..69216796af9f 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts @@ -61,7 +61,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { }); }); - it("keeps an active background task paintable offscreen", () => { + it("keeps an active background task paintable behind the app", () => { const style = resolveHostedBrowserWebviewWrapperStyle({ active: false, renderingActive: true, @@ -69,6 +69,26 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { hiddenSize: { width: 1280, height: 800 }, }); + expect(style).toEqual({ + left: 0, + top: 0, + width: 1280, + height: 800, + zIndex: -1, + pointerEvents: "none", + visibility: "visible", + }); + }); + + it("keeps an inactive webview paintable without marking it as rendering-active", () => { + const style = resolveHostedBrowserWebviewWrapperStyle({ + active: false, + renderingActive: false, + keepPaintableWhenInactive: true, + rect: null, + hiddenSize: { width: 1280, height: 800 }, + }); + expect(style).toEqual({ left: HIDDEN_BROWSER_WEBVIEW_OFFSET, top: HIDDEN_BROWSER_WEBVIEW_OFFSET, diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index 1e6ff0beabe5..a59a4a8b0083 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -21,11 +21,19 @@ export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; export function resolveHostedBrowserWebviewWrapperStyle(input: { readonly active: boolean; readonly renderingActive: boolean; + readonly keepPaintableWhenInactive?: boolean; readonly cornerRadius?: number; readonly rect: BrowserSurfaceRect | null; readonly hiddenSize: HostedBrowserWebviewSize; }): HostedBrowserWebviewWrapperStyle { - const { active, cornerRadius = 0, hiddenSize, rect, renderingActive } = input; + const { + active, + cornerRadius = 0, + hiddenSize, + keepPaintableWhenInactive = false, + rect, + renderingActive, + } = input; if (active && rect) { return { left: rect.x, @@ -38,6 +46,21 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { }; } + if (renderingActive) { + // Electron stops compositing a guest that is fully outside the window, even + // when background throttling is disabled. Keep capture-active guests inside + // the viewport but behind the app so recordings receive complete frames. + return { + left: 0, + top: 0, + width: hiddenSize.width, + height: hiddenSize.height, + zIndex: -1, + pointerEvents: "none", + visibility: "visible", + }; + } + return { left: HIDDEN_BROWSER_WEBVIEW_OFFSET, top: HIDDEN_BROWSER_WEBVIEW_OFFSET, @@ -45,6 +68,6 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { height: hiddenSize.height, zIndex: -1, pointerEvents: "none", - visibility: renderingActive ? "visible" : "hidden", + visibility: keepPaintableWhenInactive ? "visible" : "hidden", }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 40d316f68621..14dc308969c7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -262,6 +262,7 @@ import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; +import { linkedPullRequestDetailAtom } from "../state/pullRequests"; import { useEnvironmentQuery } from "../state/query"; import { environmentServerConfigsAtom, @@ -318,6 +319,7 @@ import { } from "./chat/ThreadErrorBanner"; import { resolveDisplayedThreadPr, + threadPullRequestRefreshSource, threadChangeRequestSnapshotsAtom, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; @@ -1775,7 +1777,7 @@ export function ChatViewContent(props: ChatViewProps) { // the tab is found again whether or not that surface was opened with an environment on it. const activePullRequestSurfaceId = activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; - const handlePullRequestTabStatusChange = useCallback( + const updatePullRequestTabStatusFromPanel = useCallback( (status: PullRequestTabStatus) => { const id = activePullRequestSurfaceId; if (id === undefined) return; @@ -1783,6 +1785,8 @@ export function ChatViewContent(props: ChatViewProps) { }, [activePullRequestSurfaceId], ); + const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); + const sidebarPrRefreshKeyRef = useRef(null); const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; const activePreviewState = useThreadPreviewState(activeThreadRef); @@ -4652,6 +4656,62 @@ export function ChatViewContent(props: ChatViewProps) { linkedPullRequest: linkedThreadPullRequest, linkedPullRequestStatus, }); + const handlePullRequestTabStatusChange = useCallback( + (status: PullRequestTabStatus) => { + updatePullRequestTabStatusFromPanel(status); + const source = threadPullRequestRefreshSource({ + panel: status, + thread: { + repository: threadRepository, + number: linkedThreadPullRequest?.number ?? activeThreadPr?.number ?? null, + state: activeThreadPr?.state ?? null, + linked: linkedThreadPullRequest !== null, + }, + }); + if (source === null) { + sidebarPrRefreshKeyRef.current = null; + return; + } + const refreshKey = `${activeThreadKey}:${source}:${status.repository}#${status.number}:${status.state}`; + if (sidebarPrRefreshKeyRef.current === refreshKey) return; + sidebarPrRefreshKeyRef.current = refreshKey; + + if (source === "linked-detail" && activeThreadRef && linkedThreadPullRequest) { + appAtomRegistry.refresh( + linkedPullRequestDetailAtom({ + environmentId: activeThreadRef.environmentId, + input: { + projectId: linkedThreadPullRequest.projectId, + repository: linkedThreadPullRequest.repository, + number: linkedThreadPullRequest.number, + }, + }), + ); + return; + } + if (source === "vcs" && activeThreadRef && gitCwd !== null) { + void refreshVcsStatus({ + environmentId: activeThreadRef.environmentId, + input: { cwd: gitCwd }, + }).then(() => { + if (sidebarPrRefreshKeyRef.current === refreshKey) { + sidebarPrRefreshKeyRef.current = null; + } + }); + } + }, + [ + activeThreadKey, + activeThreadPr?.number, + activeThreadPr?.state, + activeThreadRef, + gitCwd, + linkedThreadPullRequest, + refreshVcsStatus, + threadRepository, + updatePullRequestTabStatusFromPanel, + ], + ); const activeThreadReferenceCopyTarget = useMemo( () => activeThreadId === null || !isServerThread diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 189ffaa0fb9e..6514f7b4d591 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2165,11 +2165,21 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, + { id: "project-settings", label: "Project settings" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ], position, ); + if (clicked === "project-settings") { + if (isMobile) setOpenMobile(false); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey: project.projectKey }, + }); + return; + } + if (clicked === "new-thread-on-branch") { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -2252,9 +2262,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyThreadIdToClipboard, deleteThread, handleNewThread, + isMobile, markThreadUnread, memberProjectByScopedKey, + project.projectKey, project.workspaceRoot, + router, + setOpenMobile, startThreadRename, ], ); diff --git a/apps/web/src/components/QuitHoldOverlay.tsx b/apps/web/src/components/QuitHoldOverlay.tsx index 29c044015212..091fa60c2f71 100644 --- a/apps/web/src/components/QuitHoldOverlay.tsx +++ b/apps/web/src/components/QuitHoldOverlay.tsx @@ -2,29 +2,34 @@ import { useEffect, useState } from "react"; import { isMacPlatform } from "../lib/utils"; -// Matches the hold duration in apps/desktop/src/window/QuitHold.ts: the hint -// from a quick tap lingers for as long as a full hold would have taken. -const HIDE_AFTER_RELEASE_MS = 1200; +// A released hold hint lingers for the original hold duration. Double-press +// hints disappear as soon as their acceptance window closes. +const HOLD_HINT_LINGER_MS = 1200; /** - * Chrome-style "Hold ⌘Q to Quit" hint. The desktop main process intercepts - * the quit accelerator and pushes press/release states; a quick tap shows - * this pill while a full hold quits the app. + * The desktop main process intercepts the quit accelerator and pushes + * press/release states while it waits for a hold or second press. */ export function QuitHoldOverlay() { - const [visible, setVisible] = useState(false); + const [visibleMode, setVisibleMode] = useState<"hold" | "double-click" | null>(null); useEffect(() => { const subscribe = window.desktopBridge?.onQuitShortcut; if (!subscribe) return; let hideTimer: number | undefined; - const unsubscribe = subscribe((state) => { + let pressedMode: "hold" | "double-click" = "hold"; + const unsubscribe = subscribe((hint) => { window.clearTimeout(hideTimer); - if (state === "down") { - setVisible(true); + if (hint.state === "down") { + pressedMode = hint.mode; + setVisibleMode(hint.mode); return; } - hideTimer = window.setTimeout(() => setVisible(false), HIDE_AFTER_RELEASE_MS); + if (pressedMode === "double-click") { + setVisibleMode(null); + return; + } + hideTimer = window.setTimeout(() => setVisibleMode(null), HOLD_HINT_LINGER_MS); }); return () => { window.clearTimeout(hideTimer); @@ -32,15 +37,17 @@ export function QuitHoldOverlay() { }; }, []); - if (!visible) return null; + if (!visibleMode) return null; const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q"; + const message = + visibleMode === "hold" ? `Hold ${shortcut} to Quit` : `Press ${shortcut} again to Quit`; return (
- Hold {shortcut} to Quit + {message}
); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ec39d2458ff2..2153dc7e1791 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1895,6 +1895,8 @@ export default function Sidebar() { () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), [sidebarProjectSortOrder, threads, unsortedProjectGroups], ); + const projectGroupsRef = useRef(projectGroups); + projectGroupsRef.current = projectGroups; const serverConfigs = useAtomValue(environmentServerConfigsAtom); // Threads on non-primary environments (T3 Connect, hosted) resolve their // provider entry from their own environment's config: default instance ids @@ -2050,11 +2052,8 @@ export default function Sidebar() { clearSelection(); }, [clearSelection, projectScopeKey]); - const handleProjectSettings = useCallback( - (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { - event.preventDefault(); - event.stopPropagation(); - dispatchProjectScopeMenu({ type: "project-settings-opened" }); + const openProjectSettings = useCallback( + (projectGroup: SidebarProjectSnapshot) => { if (isMobile) { setOpenMobile(false); } @@ -2065,6 +2064,15 @@ export default function Sidebar() { }, [isMobile, router, setOpenMobile], ); + const handleProjectSettings = useCallback( + (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { + event.preventDefault(); + event.stopPropagation(); + dispatchProjectScopeMenu({ type: "project-settings-opened" }); + openProjectSettings(projectGroup); + }, + [openProjectSettings], + ); // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells: no archived-snapshot @@ -3134,6 +3142,17 @@ export default function Sidebar() { return; } switch (clicked.value) { + case "project-settings": { + const projectGroup = projectGroupsRef.current.find((group) => + group.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === thread.environmentId && + projectRef.projectId === thread.projectId, + ), + ); + if (projectGroup) openProjectSettings(projectGroup); + return; + } case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -3293,6 +3312,7 @@ export default function Sidebar() { deleteThread, handleMultiSelectContextMenu, markThreadUnread, + openProjectSettings, projectCwdByKey, serverConfigs, startThreadRename, diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 2663af161b55..078c3e97f5f5 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -10,6 +10,7 @@ import { resolveDisplayedThreadPrProvider, resolveThreadPr, settledPrHoverColorClass, + threadPullRequestRefreshSource, threadChangeRequestSnapshotsAtom, type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; @@ -56,6 +57,61 @@ function snapshotFor( return { branch, pr, sourceControlProvider }; } +describe("threadPullRequestRefreshSource", () => { + const panel = { repository: "pingdotgg/t3code", number: 42, state: "merged" as const }; + + it("refreshes the VCS stream when the open panel is newer than an inferred sidebar PR", () => { + expect( + threadPullRequestRefreshSource({ + panel, + thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false }, + }), + ).toBe("vcs"); + }); + + it("refreshes linked detail when the open panel is newer than a linked sidebar PR", () => { + expect( + threadPullRequestRefreshSource({ + panel, + thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: true }, + }), + ).toBe("linked-detail"); + }); + + it("refreshes when the sidebar has not resolved state yet", () => { + expect( + threadPullRequestRefreshSource({ + panel, + thread: { repository: "pingdotgg/t3code", number: 42, state: null, linked: false }, + }), + ).toBe("vcs"); + }); + + it("does nothing once sidebar state matches or the panel shows another PR", () => { + expect( + threadPullRequestRefreshSource({ + panel, + thread: { repository: "pingdotgg/t3code", number: 42, state: "merged", linked: false }, + }), + ).toBeNull(); + expect( + threadPullRequestRefreshSource({ + panel, + thread: { repository: "pingdotgg/t3code", number: 41, state: "open", linked: false }, + }), + ).toBeNull(); + }); + + it("matches repository identity without case sensitivity", () => { + expect( + threadPullRequestRefreshSource({ + panel: { ...panel, repository: "PingDotGG/T3Code" }, + thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false }, + }), + ).toBe("vcs"); + }); +}); + describe("resolveThreadPr", () => { it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { expect( diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 843d310dd441..e879c78b9710 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -39,6 +39,32 @@ export interface TerminalStatusIndicator { export type ThreadPr = VcsStatusResult["pr"]; +export type ThreadPullRequestRefreshSource = "linked-detail" | "vcs"; + +/** Refresh only when the panel has newer state for this thread's own pull request. */ +export function threadPullRequestRefreshSource(input: { + readonly panel: { + readonly repository: string; + readonly number: number; + readonly state: NonNullable["state"]; + }; + readonly thread: { + readonly repository: string | null; + readonly number: number | null; + readonly state: NonNullable["state"] | null; + readonly linked: boolean; + }; +}): ThreadPullRequestRefreshSource | null { + if ( + input.thread.repository?.toLowerCase() !== input.panel.repository.toLowerCase() || + input.thread.number !== input.panel.number || + input.thread.state === input.panel.state + ) { + return null; + } + return input.thread.linked ? "linked-detail" : "vcs"; +} + export interface LinkedThreadPullRequestStatus { readonly pr: NonNullable; readonly sourceControlProvider: NonNullable; diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index a8ebac4f4e6a..8d94fde1c0e8 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -15,7 +15,6 @@ import { parseModelPickerLegacySectionKey, parseModelPickerModelKey, } from "./modelPickerKeys"; -import { isModelPickerNewModel } from "./modelPickerModelHighlights"; import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; import { Combobox, @@ -47,6 +46,7 @@ type ModelPickerItem = { name: string; shortName?: string; subProvider?: string; + badge?: "new"; instanceId: ProviderInstanceId; driverKind: ProviderDriverKind; instanceDisplayName: string; @@ -256,6 +256,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { name: model.name, ...(model.shortName ? { shortName: model.shortName } : {}), ...(model.subProvider ? { subProvider: model.subProvider } : {}), + ...(model.badge ? { badge: model.badge } : {}), ...(model.isLegacy ? { isLegacy: true } : {}), ...(model.isUnavailable ? { isUnavailable: true } : {}), instanceId, @@ -804,7 +805,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { showProvider preferShortName={!isLocked} useTriggerLabel={false} - showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)} + showNewBadge={model.badge === "new"} unavailable={model.isUnavailable === true} jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} disabledReason={disabledReason} diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 63fd7c5cd21c..b419489bef83 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -291,11 +291,12 @@ describe("getComposerProviderState", () => { it("validates options for a known model selected through a legacy alias", () => { const state = getComposerProviderState({ provider: ProviderDriverKind.make("claudeAgent"), - model: "opus", + model: "legacy-test-model", models: [ { - slug: "claude-opus-5", - name: "Claude Opus 5", + slug: "test-model", + name: "Test Model", + aliases: ["legacy-test-model"], isCustom: false, capabilities: { optionDescriptors: [ diff --git a/apps/web/src/components/chat/modelPickerModelHighlights.ts b/apps/web/src/components/chat/modelPickerModelHighlights.ts deleted file mode 100644 index 2131480a6a23..000000000000 --- a/apps/web/src/components/chat/modelPickerModelHighlights.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ProviderDriverKind } from "@t3tools/contracts"; - -/** - * Model slugs that show a gold "NEW" chip in the model picker list. - * Add entries as `provider:slug` when you want to highlight freshly shipped models. - */ -const NEW_MODEL_KEYS = new Set([ - // Example: "claudeAgent:claude-opus-4-7", -]); - -export function isModelPickerNewModel(provider: ProviderDriverKind, slug: string): boolean { - return NEW_MODEL_KEYS.has(`${provider}:${slug}`); -} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 9e6cc1fbf82c..883dfde795ac 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -35,6 +35,7 @@ export type ModelEsque = { name: string; shortName?: string | undefined; subProvider?: string | undefined; + badge?: "new" | undefined; isLegacy?: boolean | undefined; isUnavailable?: boolean | undefined; }; diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 2ace29ae5c41..fd6ac25ceced 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -149,6 +149,7 @@ vi.mock("~/state/use-atom-command", () => ({ vi.mock("~/browser/browserRecording", () => ({ findActiveBrowserRecordingRuntimeTabId: vi.fn(() => null), + isBrowserRecordingStartCancelledError: vi.fn(() => false), startBrowserRecording: vi.fn(), stopBrowserRecording: vi.fn(), useActiveBrowserRecordingTabIds: () => new Set(), diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 3a2fb026da6d..ff77fe79ff44 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -55,6 +55,7 @@ import { ZoomIndicator } from "./ZoomIndicator"; import { AgentBrowserCursor } from "./AgentBrowserCursor"; import { findActiveBrowserRecordingRuntimeTabId, + isBrowserRecordingStartCancelledError, startBrowserRecording, stopBrowserRecording, useActiveBrowserRecordingTabIds, @@ -400,10 +401,12 @@ export function PreviewView({ } if (record) { void startBrowserRecording(runtimeTabId, threadRef, tabId).catch((error) => { + const description = error instanceof Error ? error.message : "An error occurred."; + if (isBrowserRecordingStartCancelledError(error)) return; toastManager.add({ type: "error", title: "Unable to start recording", - description: error instanceof Error ? error.message : "An error occurred.", + description, }); }); return; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 851605816975..ca59fbc37d9f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -34,6 +34,7 @@ import { MIN_PROMPT_FONT_SIZE, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_TERMINAL_FONT_SIZE, + type QuitConfirmationMode, } from "@t3tools/contracts/settings"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; @@ -163,6 +164,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const QUIT_CONFIRMATION_MODE_LABELS: Record = { + direct: "Direct", + hold: "Hold", + "double-click": "Double press", +}; + const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record = { balanced: "Balanced", performance: "Performance", @@ -542,9 +549,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), - ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit - ? ["Quit confirmation"] - : []), + ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ["Quit shortcut"] : []), ...(isTextGenerationModelDirty ? ["Text generation model"] : []), ...getChangedBrowserSettingLabels(settings), ...(settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess @@ -2422,11 +2427,11 @@ export function GeneralSettingsPanel() { {isElectron ? ( updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) } @@ -2434,11 +2439,25 @@ export function GeneralSettingsPanel() { ) : null } control={ - updateSettings({ confirmQuit: Boolean(checked) })} - aria-label="Hold to quit" - /> + } /> ) : null} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index d4b0ead1c7bf..3f181087df91 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -240,9 +240,9 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "quit-confirmation", - title: "Hold to quit", + title: "Quit shortcut", to: "/settings/general", - searchTerms: ["confirmation shortcut desktop app exit"], + searchTerms: ["confirmation desktop app exit direct hold double click press twice"], desktopOnly: true, }, { diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 96931bc8f875..1bdd04693759 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -33,7 +33,18 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy", "archive", "delete"]); + ).toEqual(["rename", "mark-unread", "copy", "project-settings", "archive", "delete"]); + }); + + it("groups project settings with utility actions before archive", () => { + const items = buildThreadActionMenuItems(baseState); + const copyIndex = items.findIndex((item) => item.id === "copy"); + expect(items[copyIndex + 1]).toMatchObject({ + id: "project-settings", + label: "Project settings", + icon: "settings", + }); + expect(items[copyIndex + 2]?.id).toBe("archive"); }); it("includes branch items only for threads with a branch", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index df983ee86773..5ba266f7709d 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -8,6 +8,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" */ export type ThreadActionMenuId = | "new-thread-on-branch" + | "project-settings" | "pin" | "unpin" | "settle" @@ -119,6 +120,7 @@ export function buildThreadActionMenuItems( { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, ], }, + { id: "project-settings", label: "Project settings", icon: "settings" }, // Archive removes the thread from the sidebar while keeping its // conversation under Settings > Archived threads — distinct from Settle // (stays visible in the Settled shelf) and Delete (clears history for diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index ce8b8950a8b9..bfdb98c2d229 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -96,6 +96,15 @@ const ICON_PATHS: Record void; }) { const { threadRef, projectCwd, onStartRename } = input; + const router = useRouter(); + const projects = useProjects(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const logicalProjectKeyByPhysicalKey = useMemo( + () => + buildPhysicalToLogicalProjectKeyMap({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }), + [primaryEnvironmentId, projectGroupingSettings, projects], + ); const { settleThread, unsettleThread, @@ -168,6 +190,22 @@ export function useThreadActionMenu(input: { } }; switch (action) { + case "project-settings": { + const project = projects.find( + (candidate) => + candidate.environmentId === thread.environmentId && + candidate.id === thread.projectId, + ); + if (!project) return; + const projectKey = + logicalProjectKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ?? + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey }, + }); + return; + } case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -300,10 +338,14 @@ export function useThreadActionMenu(input: { copyThreadIdToClipboard, deleteThread, handleNewThread, + logicalProjectKeyByPhysicalKey, markThreadUnread, onStartRename, pinThread, projectCwd, + projectGroupingSettings, + projects, + router, settleThread, snoozeThread, threadRef, diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index a3d93f3c24bb..12865d64a9b2 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -75,6 +75,8 @@ export interface AppModelOption { name: string; shortName?: string; subProvider?: string; + aliases?: ReadonlyArray; + badge?: "new"; isCustom: boolean; isDefault?: boolean; isLegacy?: boolean; @@ -109,6 +111,8 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti }; if (model.shortName) option.shortName = model.shortName; if (model.subProvider) option.subProvider = model.subProvider; + if (model.aliases) option.aliases = model.aliases; + if (model.badge) option.badge = model.badge; if (model.isDefault) option.isDefault = true; if (model.isLegacy) option.isLegacy = true; return option; diff --git a/apps/web/src/providerModels.test.ts b/apps/web/src/providerModels.test.ts new file mode 100644 index 000000000000..d28cc559062e --- /dev/null +++ b/apps/web/src/providerModels.test.ts @@ -0,0 +1,77 @@ +import { + ProviderDriverKind, + type ModelCapabilities, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { getProviderModelCapabilities } from "./providerModels"; + +const PROVIDER = ProviderDriverKind.make("claudeAgent"); + +function capabilities(id: string): ModelCapabilities { + return { + optionDescriptors: [{ id, label: id, type: "boolean" }], + }; +} + +function model(input: { + slug: string; + capabilities: ModelCapabilities; + aliases?: ReadonlyArray; + isCustom?: boolean; +}): ServerProviderModel { + return { + slug: input.slug, + name: input.slug, + ...(input.aliases ? { aliases: [...input.aliases] } : {}), + isCustom: input.isCustom ?? false, + capabilities: input.capabilities, + }; +} + +describe("getProviderModelCapabilities", () => { + it("resolves model-declared aliases", () => { + const aliasCapabilities = capabilities("aliased-option"); + const models = [ + model({ + slug: "synthetic-model", + aliases: ["Legacy-Synthetic-Model"], + capabilities: aliasCapabilities, + }), + ]; + + expect(getProviderModelCapabilities(models, "legacy-synthetic-model", PROVIDER)).toEqual( + aliasCapabilities, + ); + }); + + it("prefers an exact custom slug over a built-in model alias", () => { + const customCapabilities = capabilities("custom-option"); + const models = [ + model({ + slug: "synthetic-model", + aliases: ["custom-model"], + capabilities: capabilities("built-in-option"), + }), + model({ slug: "custom-model", capabilities: customCapabilities, isCustom: true }), + ]; + + expect(getProviderModelCapabilities(models, " custom-model ", PROVIDER)).toEqual( + customCapabilities, + ); + }); + + it("returns empty capabilities for an unknown slug", () => { + const models = [ + model({ + slug: "default-model", + capabilities: capabilities("default-option"), + }), + ]; + + expect(getProviderModelCapabilities(models, "unknown-model", PROVIDER)).toEqual({ + optionDescriptors: [], + }); + }); +}); diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 6fc8b5e122a9..2f640d582307 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -8,7 +8,7 @@ import { type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; -import { createModelCapabilities, normalizeModelSlug } from "@t3tools/shared/model"; +import { createModelCapabilities, resolveSelectableModel } from "@t3tools/shared/model"; const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -83,9 +83,9 @@ export function getProviderModelCapabilities( provider: ProviderDriverKind, planModeEnabled = true, ): ModelCapabilities { - const slug = normalizeModelSlug(model, provider); - const caps = - models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; + const slug = resolveSelectableModel(provider, model, models); + const selectedModel = models.find((candidate) => candidate.slug === slug); + const caps = selectedModel?.capabilities ?? EMPTY_CAPABILITIES; if (planModeEnabled) { return caps; } diff --git a/docs/internals/model-manifest.md b/docs/internals/model-manifest.md new file mode 100644 index 000000000000..bebf5c2ee527 --- /dev/null +++ b/docs/internals/model-manifest.md @@ -0,0 +1,40 @@ +# Model manifest + +`apps/server/src/provider/model-manifest.json` is bundled for offline startup and fetched from +`main` at runtime. A remote fetch replaces the in-memory and on-disk cache only after generic +catalog references and provider-owned adapter data validate. A failed or invalid fetch keeps the +last successful remote manifest. The bundle is used only when no valid remote cache exists. + +The top-level provider catalog is generic: models contain presentation metadata, aliases, status, +an optional badge, and a reusable capability profile. The profile and model `adapter` fields are +opaque until the owning provider validates them with its own allowlisted schema. + +Claude Code uses the manifest as its complete built-in model catalog. To add a Claude model that +uses an existing profile, add one object to `providers.claudeAgent.models`. Do not add a test or +change application code. Add or change a profile in the same JSON file only when the model exposes +a capability combination that does not already exist. + +`currentModels.claudeAgent` is retained as a frozen compatibility field for releases that predate +catalog discovery. New Claude models do not need to be added there. Codex still discovers models +from its app server and uses `currentModels.codex` only as a legacy-classification overlay. + +Claude model entries support: + +- `aliases`, `status`, `badge`, and `profile` for client presentation and selection. +- `adapter.claudeCode.minVersion` and `maxVersionExclusive` for installed-runtime compatibility. +- Profile-level effort mappings, model suffixes, and context-window metadata for dispatch. + +## Test policy + +Changing model data does not require tests. Do not add or update tests for a model slug, display +name, alias, legacy status, version boundary, badge, or profile assignment. The bundled manifest is +configuration and is validated by its schema when imported. + +Add tests only when implementation behavior changes: + +- Fetching, caching, fallback, or schema-version handling changes in the manifest service. +- Provider-neutral profile resolution gains new semantics. +- A provider adapter gains a new compatibility or dispatch mapping type. + +Resolver tests must use synthetic providers and model names so normal JSON edits never create test +churn. diff --git a/docs/operations/local-build.md b/docs/operations/local-build.md index a34228ee2417..b7eff139d8f2 100644 --- a/docs/operations/local-build.md +++ b/docs/operations/local-build.md @@ -56,7 +56,7 @@ apps/web/package.json Commit the bump to `turbo`. (History: 0.0.35 started the independent line, 0.0.36 added the personal `~/.t3` backend, 0.0.37 restored the connect -config to local builds.) +config to local builds, 0.0.49 retired the personal `~/.t3` backend.) ## 3. Build diff --git a/docs/operations/turbo-changelog.md b/docs/operations/turbo-changelog.md index 0cfada827e70..c2929d7c5213 100644 --- a/docs/operations/turbo-changelog.md +++ b/docs/operations/turbo-changelog.md @@ -8,6 +8,14 @@ per-commit — the ingestion PR entry records the upstream range instead. ## Unreleased — on `turbo`, not yet in a shipped build +- **0.0.49: T3 Turbo no longer hosts the legacy `~/.t3` (T3 Code personal) database as a second + backend; import it once via the official-data-import path if needed.** The desktop bootstrap no + longer probes for `~/.t3/userdata/state.sqlite` and no longer registers a `local:t3` instance in + the backend pool, and `DesktopBackendConfiguration.resolveLocalHome` — the start-config builder + that existed only for that instance — is gone with it. T3 Turbo now stands on its own home. The + one-way official import (`apps/server/src/turbo/officialImport/**`, seam `official-data-import`) + is untouched and remains the supported way to bring legacy data across. Also ingests upstream + through `692eb1a57`. - **Cold start replays projection history in batches again (0.0.48).** The fork's batched projection bootstrap was silently lost in the 0.0.45 ingest, because it had never been registered as a seam and upstream's `ProjectionPipeline.test.ts` asserts exact shell-update counts. Restored on top of @@ -224,7 +232,8 @@ No version manifests were bumped: this work ships with the next release, per the ## 0.0.36 — 2026-08-08 - Hosted the personal official T3 Code home as a second local backend on the desktop - (`cf516adb`), since replaced by the guarded one-way official import. + (`cf516adb`), since replaced by the guarded one-way official import and removed entirely in + 0.0.49. ## 0.0.35 — 2026-08-08 diff --git a/packages/contracts/package.json b/packages/contracts/package.json index a8145483064d..3f4079dd8e2a 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.48", + "version": "0.0.49", "private": true, "files": [ "dist" diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 8fa93538a687..84a37f57154b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -97,7 +97,7 @@ import { EnvironmentId } from "./baseSchemas.ts"; import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; -import type { ClientSettings } from "./settings.ts"; +import type { ClientSettings, QuitConfirmationMode } from "./settings.ts"; import type { EditorId } from "./editor.ts"; import type { SourceControlCloneRepositoryInput, @@ -122,6 +122,10 @@ export interface ContextMenuItem { children?: readonly ContextMenuItem[]; } +export type QuitShortcutHintEvent = + | { readonly state: "down"; readonly mode: Exclude } + | { readonly state: "up" }; + export interface ContextMenuItemSchemaType { readonly id: string; readonly label: string; @@ -788,19 +792,6 @@ export const DesktopPreviewRecordingFrameSchema: Schema.Codec = - Schema.Struct({ - sourceId: Schema.String, - width: Schema.Int.check(Schema.isGreaterThan(0)), - height: Schema.Int.check(Schema.isGreaterThan(0)), - }); - export interface DesktopPreviewRecordingArtifact { id: string; tabId: string; @@ -1211,11 +1202,10 @@ export interface DesktopBridge { probeRemoteEditors?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; /** - * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed, - * "up" when it is released before the hold completes. Optional: older - * desktop builds never emit it. + * Quit-confirmation hint pushes. Optional: older desktop builds never emit + * them. */ - onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; + onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getUpdateState: () => Promise; @@ -1231,6 +1221,9 @@ export interface DesktopBridge { preview?: DesktopPreviewBridge; } +/** Renderer callback invoked by Electron with a fresh user gesture before display-media capture. */ +export const DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER = "__t3DesktopPreviewRecordingCapture"; + export interface DesktopPreviewBridge { createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise; closeTab: (tabId: string) => Promise; @@ -1286,7 +1279,7 @@ export interface DesktopPreviewBridge { close: (tabId: string) => Promise; }; recording: { - startScreencast: (tabId: string) => Promise; + startScreencast: (tabId: string) => Promise; stopScreencast: (tabId: string) => Promise; save: ( tabId: string, diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index a9822b3c3488..2eecdadbb691 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -179,33 +179,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< "5.3-spark": "gpt-5.3-codex-spark", "gpt-5.3-spark": "gpt-5.3-codex-spark", }, - [CLAUDE_DRIVER_KIND]: { - fable: "claude-fable-5-1", - "fable-5.1": "claude-fable-5-1", - "claude-fable-5.1": "claude-fable-5-1", - opus: "claude-opus-5", - "opus-5": "claude-opus-5", - "claude-opus-5.0": "claude-opus-5", - "claude-opus-5-0": "claude-opus-5", - "opus-4.8": "claude-opus-4-8", - "claude-opus-4.8": "claude-opus-4-8", - "opus-4.7": "claude-opus-4-7", - "claude-opus-4.7": "claude-opus-4-7", - "opus-4.6": "claude-opus-4-6", - "claude-opus-4.6": "claude-opus-4-6", - "claude-opus-4-6-20251117": "claude-opus-4-6", - sonnet: "claude-sonnet-5", - "sonnet-5": "claude-sonnet-5", - "claude-sonnet-5.0": "claude-sonnet-5", - "claude-sonnet-5-0": "claude-sonnet-5", - "sonnet-4.6": "claude-sonnet-4-6", - "claude-sonnet-4.6": "claude-sonnet-4-6", - "claude-sonnet-4-6-20251117": "claude-sonnet-4-6", - haiku: "claude-haiku-4-5", - "haiku-4.5": "claude-haiku-4-5", - "claude-haiku-4.5": "claude-haiku-4-5", - "claude-haiku-4-5-20251001": "claude-haiku-4-5", - }, + [CLAUDE_DRIVER_KIND]: {}, [CURSOR_DRIVER_KIND]: { composer: "composer-2", "composer-1.5": "composer-1.5", diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 5fdde9c51278..7a1c9825058d 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -66,6 +66,8 @@ export const ServerProviderModel = Schema.Struct({ name: TrimmedNonEmptyString, shortName: Schema.optional(TrimmedNonEmptyString), subProvider: Schema.optional(TrimmedNonEmptyString), + aliases: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + badge: Schema.optional(Schema.Literal("new")), isCustom: Schema.Boolean, isDefault: Schema.optional(Schema.Boolean), isLegacy: Schema.optional(Schema.Boolean), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1eb4f002a8a7..dea7a56947a2 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -67,6 +67,31 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings quit confirmation", () => { + it("defaults to hold", () => { + expect(decodeClientSettings({}).confirmQuit).toBe("hold"); + }); + + it.each(["direct", "hold", "double-click"] as const)("accepts the %s mode", (mode) => { + expect(decodeClientSettings({ confirmQuit: mode }).confirmQuit).toBe(mode); + expect(decodeClientSettingsPatch({ confirmQuit: mode }).confirmQuit).toBe(mode); + }); + + it.each([ + [true, "hold"], + [false, "direct"], + ] as const)("migrates the legacy %s value to %s", (legacyValue, mode) => { + const settings = decodeClientSettings({ confirmQuit: legacyValue }); + + expect(settings.confirmQuit).toBe(mode); + expect(encodeClientSettings(settings).confirmQuit).toBe(mode); + }); + + it("rejects legacy booleans at the patch boundary", () => { + expect(() => decodeClientSettingsPatch({ confirmQuit: true })).toThrow(); + }); +}); + describe("ClientSettings browser recording frame rate", () => { it("defaults to 30 fps", () => { expect(decodeClientSettings({}).browserRecordingFrameRate).toBe(30); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 52033f44f5c1..0b7556a49ba9 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -125,6 +125,22 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; +export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]); +export type QuitConfirmationMode = typeof QuitConfirmationMode.Type; +export const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; + +const LegacyConfirmQuit = Schema.Boolean.pipe( + Schema.decodeTo( + QuitConfirmationMode, + SchemaTransformation.transform({ + decode: (confirmQuit): QuitConfirmationMode => (confirmQuit ? "hold" : "direct"), + encode: (mode) => mode === "hold", + }), + ), +); + +const QuitConfirmationModeSetting = Schema.Union([QuitConfirmationMode, LegacyConfirmQuit]); + /** * A user-chosen font family (a single name or a comma-separated list). Empty * means "use the app default"; clients compose their own fallback stacks. @@ -254,9 +270,11 @@ export const ClientSettingsSchema = Schema.Struct({ browserAutoShowFloatingPreview: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)), ), - // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the - // app quits; a quick tap only shows a hint. Browser clients ignore it. - confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // Desktop-only. Boolean values from older settings files decode to their + // equivalent mode and encode back as the canonical string value. + confirmQuit: QuitConfirmationModeSetting.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_QUIT_CONFIRMATION_MODE)), + ), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1093,7 +1111,7 @@ export const ClientSettingsPatch = Schema.Struct({ browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference), browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate), browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean), - confirmQuit: Schema.optionalKey(Schema.Boolean), + confirmQuit: Schema.optionalKey(QuitConfirmationMode), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 2b27c498d1bc..a65a60fa4cc7 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; +import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, @@ -11,8 +11,6 @@ import { getProviderOptionDescriptors, getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, - normalizeCustomModelSlug, - normalizeModelSlug, } from "./model.ts"; const codexCaps: ModelCapabilities = createModelCapabilities({ @@ -148,16 +146,6 @@ describe("descriptor helpers", () => { }); }); -describe("model slug normalization", () => { - it("preserves exact custom slugs instead of expanding provider aliases", () => { - const claude = ProviderDriverKind.make("claudeAgent"); - - expect(normalizeModelSlug("fable", claude)).toBe("claude-fable-5-1"); - expect(normalizeModelSlug("opus", claude)).toBe("claude-opus-5"); - expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); - }); -}); - describe("applyClaudePromptEffortPrefix", () => { it("keeps slash commands intact when ultrathink is selected", () => { expect(applyClaudePromptEffortPrefix("/compact", "ultrathink")).toBe("/compact"); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 3e47de3fb1d9..415ce2a465bc 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -15,6 +15,7 @@ const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); export interface SelectableModelOption { slug: string; name: string; + aliases?: ReadonlyArray | undefined; } export function createModelCapabilities(input: { @@ -281,6 +282,13 @@ export function resolveSelectableModel( return byName.slug; } + const byAlias = options.find((option) => + option.aliases?.some((alias) => alias.toLowerCase() === trimmed.toLowerCase()), + ); + if (byAlias) { + return byAlias.slug; + } + const normalized = normalizeModelSlug(trimmed, provider); if (!normalized) { return null;