diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index efb5cb2102ce..27425ed4b41b 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -70,6 +70,7 @@ function makeDesktopWindowLayer(input: { readonly activateCount: Ref.Ref handleBackendNotReady: Effect.die("unexpected handleBackendNotReady"), dispatchMenuAction: () => Effect.die("unexpected dispatchMenuAction"), syncAppearance: Effect.void, + flushMainWindowBounds: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); } diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 829d3aacce37..db8b90324395 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -73,8 +73,14 @@ function addScopedListener>( } const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( - function* (): Effect.fn.Return { + function* (): Effect.fn.Return< + void, + never, + DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow + > { const shutdown = yield* DesktopShutdown.DesktopShutdown; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.flushMainWindowBounds; yield* shutdown.request; yield* shutdown.awaitComplete; }, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index f3157334bad5..f11d8935c53e 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -77,6 +77,7 @@ function makePoolLayer( showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 1a107c5c8569..dcfee93778d1 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -250,6 +250,7 @@ describe("DesktopServerExposure", () => { const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), setUpdateChannel: () => Effect.die("unexpected update channel change"), diff --git a/apps/desktop/src/electron/ElectronNotification.test.ts b/apps/desktop/src/electron/ElectronNotification.test.ts index 3c96d0dd02b8..c5daf0fb7761 100644 --- a/apps/desktop/src/electron/ElectronNotification.test.ts +++ b/apps/desktop/src/electron/ElectronNotification.test.ts @@ -171,6 +171,7 @@ function makeLayer( handleBackendNotReady: Effect.die("unused"), dispatchMenuAction: () => Effect.die("unused"), syncAppearance: Effect.die("unused"), + flushMainWindowBounds: Effect.die("unused"), }), ), Layer.succeed( diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index e258bb2dfc5a..743fd6a1fcec 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -15,6 +15,7 @@ const { fromPartition, sessions } = vi.hoisted(() => ({ readonly clearStorageData: ReturnType; readonly getUserAgent: ReturnType; readonly setPermissionRequestHandler: ReturnType; + readonly setPermissionCheckHandler: ReturnType; readonly setUserAgent: ReturnType; } >(), @@ -40,6 +41,7 @@ describe("BrowserSession", () => { clearStorageData: vi.fn(() => Promise.resolve()), getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"), setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), setUserAgent: vi.fn(), }; sessions.set(partition, browserSession); @@ -61,6 +63,55 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("grants clipboard-sanitized-write through both the request and check handlers", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + + const browserSession = sessions.get(partition); + assert.isDefined(browserSession); + + const requestHandler = browserSession.setPermissionRequestHandler.mock.calls[0]?.[0]; + const checkHandler = browserSession.setPermissionCheckHandler.mock.calls[0]?.[0]; + assert.isFunction(requestHandler); + assert.isFunction(checkHandler); + + const requestAllows = (permission: string): boolean => { + let granted: boolean | undefined; + requestHandler(null, permission, (value: boolean) => { + granted = value; + }); + assert.isDefined(granted); + return granted; + }; + + for (const permission of [ + "clipboard-read", + "clipboard-sanitized-write", + "notifications", + "geolocation", + ]) { + assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`); + assert.isTrue( + checkHandler(null, permission) as boolean, + `check handler should allow ${permission}`, + ); + } + + // `clipboard-write` is not a real Electron permission — the async write API + // uses `clipboard-sanitized-write` — so the stale name must not be granted, + // and unrelated permissions stay denied. + for (const permission of ["clipboard-write", "midi"]) { + assert.isFalse(requestAllows(permission), `request handler should deny ${permission}`); + assert.isFalse( + checkHandler(null, permission) as boolean, + `check handler should deny ${permission}`, + ); + } + }).pipe(Effect.provide(layer)), + ); + it.effect("preserves partition scope and the platform failure chain", () => { const nativeCause = new Error("native digest failed"); const platformCause = PlatformError.systemError({ diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index afa8dafe976f..aa0b0743e933 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -11,6 +11,20 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; +// Permissions granted to preview web content. `clipboard-sanitized-write` is the +// Electron permission behind `navigator.clipboard.writeText()` — note it is NOT +// `clipboard-write`, which is not a valid Electron permission name. Async +// clipboard writes are gated by the permission *check* handler (not only the +// request handler), so both handlers must allow it; otherwise built-in "Copy" +// buttons — e.g. the Next.js / Vercel error overlay — fail with +// `Failed to execute 'writeText' on 'Clipboard': Write permission denied`. +const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ + "clipboard-read", + "clipboard-sanitized-write", + "notifications", + "geolocation", +]); + export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass()( "BrowserSessionPartitionDerivationError", { @@ -120,9 +134,11 @@ export const make = Effect.gen(function* BrowserSessionMake() { .replace(/\s*t3code\/[\d.]+/, ""); browserSession.setUserAgent(userAgent); browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { - const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; - callback(allowed.includes(permission)); + callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); }); + browserSession.setPermissionCheckHandler((_webContents, permission) => + ALLOWED_PREVIEW_PERMISSIONS.has(permission), + ); const next = new Map(sessions); next.set(partition, browserSession); return [browserSession, next] as const; diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 70b267982668..3878b0e36ad0 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,17 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + mainWindowBounds: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, + }), + ), + ), + mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -91,6 +102,8 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -116,6 +129,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -215,10 +230,13 @@ describe("DesktopSettings", () => { "serverExposureMode": "network-accessible", "tailscaleServeEnabled": true, "tailscaleServePort": 8443, + "mainWindowBounds": { "x": 120, "y": 80, "width": 1280, "height": 900 }, }\n`, ); assert.deepEqual(yield* settings.load, { + mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, + mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -232,6 +250,24 @@ describe("DesktopSettings", () => { ), ); + it.effect("rejects window bounds that do not satisfy the domain schema", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, + mainWindowMaximized: true, + serverExposureMode: "network-accessible", + }); + + const loaded = yield* settings.load; + assert.isNull(loaded.mainWindowBounds); + assert.isFalse(loaded.mainWindowMaximized); + assert.equal(loaded.serverExposureMode, "network-accessible"); + }), + ), + ); + it.effect("persists sparse desktop settings documents", () => withSettings( Effect.gen(function* () { @@ -239,12 +275,15 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); yield* settings.setServerExposureMode("network-accessible"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { + mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, + mainWindowMaximized: true, serverExposureMode: "network-accessible", } satisfies typeof DesktopSettingsPatch.Type); }), @@ -261,6 +300,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -286,6 +327,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -310,6 +353,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: true, tailscaleServePort: 443, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 6a26bf5a6e26..466c9a9b5f8a 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -20,6 +20,8 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly mainWindowBounds: DesktopWindowBounds | null; + readonly mainWindowMaximized: boolean; readonly serverExposureMode: DesktopServerExposureMode; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; @@ -48,8 +50,25 @@ export interface DesktopSettingsChange { } export const DEFAULT_TAILSCALE_SERVE_PORT = 443; +const MIN_MAIN_WINDOW_SIZE = { + width: 840, + height: 620, +} as const; +export const DesktopWindowBoundsSchema = Schema.Struct({ + x: Schema.Int, + y: Schema.Int, + width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.width)), + height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.height)), +}); +export type DesktopWindowBounds = typeof DesktopWindowBoundsSchema.Type; +export const DEFAULT_MAIN_WINDOW_SIZE = { + width: 1100, + height: 780, +} as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, @@ -60,7 +79,16 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { wslOnly: false, }; +const DesktopWindowBoundsDocument = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, +}); + const DesktopSettingsDocument = Schema.Struct({ + mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), + mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -81,6 +109,8 @@ type Mutable = { -readonly [K in keyof T]: T[K] }; const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument); const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson); const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson); +const decodeDesktopWindowBounds = Schema.decodeUnknownOption(DesktopWindowBoundsSchema); +const desktopWindowBoundsEquivalence = Schema.toEquivalence(DesktopWindowBoundsSchema); const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({ settings, @@ -114,6 +144,10 @@ export class DesktopAppSettings extends Context.Service< { readonly load: Effect.Effect; readonly get: Effect.Effect; + readonly setMainWindowBounds: ( + bounds: DesktopWindowBounds, + isMaximized: boolean, + ) => Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; @@ -158,11 +192,16 @@ function normalizeWslDistro(value: unknown): string | null { return typeof value === "string" && isValidDistroName(value) ? value : null; } +export function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | null { + return Option.getOrNull(decodeDesktopWindowBounds(value)); +} + function normalizeDesktopSettingsDocument( parsed: DesktopSettingsDocument, appVersion: string, ): DesktopSettings { const defaultSettings = resolveDefaultDesktopSettings(appVersion); + const mainWindowBounds = normalizeMainWindowBounds(parsed.mainWindowBounds); const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel); const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; const updateChannelConfiguredByUser = @@ -177,6 +216,8 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + mainWindowBounds, + mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, @@ -197,6 +238,12 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.mainWindowBounds !== null) { + document.mainWindowBounds = settings.mainWindowBounds; + } + if (settings.mainWindowMaximized) { + document.mainWindowMaximized = true; + } if (settings.serverExposureMode !== defaults.serverExposureMode) { document.serverExposureMode = settings.serverExposureMode; } @@ -237,6 +284,22 @@ function setServerExposureMode( }; } +function setMainWindowBounds( + settings: DesktopSettings, + bounds: DesktopWindowBounds, + isMaximized: boolean, +): DesktopSettings { + return settings.mainWindowBounds !== null && + desktopWindowBoundsEquivalence(settings.mainWindowBounds, bounds) && + settings.mainWindowMaximized === isMaximized + ? settings + : { + ...settings, + mainWindowBounds: bounds, + mainWindowMaximized: isMaximized, + }; +} + function setTailscaleServe( settings: DesktopSettings, input: { readonly enabled: boolean; readonly port: Option.Option }, @@ -431,6 +494,18 @@ export const make = Effect.gen(function* () { ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), + setMainWindowBounds: (bounds, isMaximized) => + persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe( + Effect.withSpan("desktop.settings.setMainWindowBounds", { + attributes: { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + isMaximized, + }, + }), + ), setServerExposureMode: (mode) => persist((settings) => setServerExposureMode(settings, mode)).pipe( Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), @@ -488,6 +563,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: SynchronizedRef.get(settingsRef), + setMainWindowBounds: (bounds, isMaximized) => + update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index ac26579d45a0..c66efec90807 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -166,6 +166,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.fail(setUpdateChannelError), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index aa991a817859..e76f91437347 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -77,6 +77,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 19f1584e0fd5..15ee118d118b 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -1,12 +1,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import type * as Electron from "electron"; +import * as Electron from "electron"; import { vi } from "vite-plus/test"; vi.mock("electron", async (importOriginal) => ({ @@ -18,12 +23,20 @@ vi.mock("electron", async (importOriginal) => ({ setUserAgent: vi.fn(), })), }, + screen: { + getAllDisplays: vi.fn(() => [ + { + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + }, + ]), + }, })); import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -75,18 +88,24 @@ function makeFakeBrowserWindow() { const window = { close: vi.fn(), focus: vi.fn(), + getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), + getNormalBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), isDestroyed: vi.fn(() => false), isFullScreen: vi.fn(() => false), + isMaximized: vi.fn(() => false), isMinimized: vi.fn(() => false), isVisible: vi.fn(() => true), loadURL: vi.fn((url: string) => { currentUrl = url; return Promise.resolve(); }), + maximize: vi.fn(), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { windowListeners.set(eventName, listener); }), - once: vi.fn(), + once: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + windowListeners.set(eventName, listener); + }), restore: vi.fn(), setBackgroundColor: vi.fn(), setAutoHideCursor: vi.fn(), @@ -98,7 +117,14 @@ function makeFakeBrowserWindow() { return { window: window as unknown as Electron.BrowserWindow, + getBounds: window.getBounds, + getNormalBounds: window.getNormalBounds, + isDestroyed: window.isDestroyed, + isFullScreen: window.isFullScreen, + isMaximized: window.isMaximized, + isMinimized: window.isMinimized, loadURL: window.loadURL, + maximize: window.maximize, openDevTools: webContents.openDevTools, reload: webContents.reload, send: webContents.send, @@ -158,14 +184,58 @@ const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( ), ); +const desktopWindowBoundsEquivalence = Schema.toEquivalence( + DesktopAppSettings.DesktopWindowBoundsSchema, +); + function makeTestLayer(input: { readonly window: Electron.BrowserWindow; readonly createCount: Ref.Ref; readonly mainWindow: Ref.Ref>; readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; + readonly desktopSettings?: DesktopAppSettings.DesktopSettings; + readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[]; + readonly mainWindowMaximizedUpdates?: boolean[]; + readonly beforeMainWindowBoundsUpdate?: ( + bounds: DesktopAppSettings.DesktopWindowBounds, + ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; readonly electronTheme?: ElectronTheme.ElectronTheme["Service"]; }) { + let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; + const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.sync(() => desktopSettings), + load: Effect.sync(() => desktopSettings), + setMainWindowBounds: (bounds, isMaximized) => + Effect.gen(function* () { + if (input.beforeMainWindowBoundsUpdate) { + yield* input.beforeMainWindowBoundsUpdate(bounds); + } + const changed = + desktopSettings.mainWindowBounds === null || + !desktopWindowBoundsEquivalence(desktopSettings.mainWindowBounds, bounds) || + desktopSettings.mainWindowMaximized !== isMaximized; + if (changed) { + desktopSettings = { + ...desktopSettings, + mainWindowBounds: bounds, + mainWindowMaximized: isMaximized, + }; + input.mainWindowBoundsUpdates?.push(bounds); + input.mainWindowMaximizedUpdates?.push(isMaximized); + } + return { settings: desktopSettings, changed }; + }), + setServerExposureMode: () => Effect.die("unexpected server exposure update"), + setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), + setUpdateChannel: () => Effect.die("unexpected update channel change"), + setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), + setWslDistro: () => Effect.die("unexpected WSL distro change"), + setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), + applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); + const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { create: (options) => Effect.sync(() => { @@ -190,6 +260,7 @@ function makeTestLayer(input: { Layer.mergeAll( desktopAssetsLayer, desktopEnvironmentLayer, + desktopAppSettingsLayer, desktopServerExposureLayer, NodeServices.layer, DesktopState.layer, @@ -288,6 +359,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n Layer.mergeAll( desktopAssetsLayer, desktopEnvironmentLayer, + DesktopAppSettings.layerTest(), desktopServerExposureLayer, NodeServices.layer, electronMenuLayer, @@ -311,6 +383,23 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n }); describe("DesktopWindow", () => { + it("restores bounds only when the window fits within a connected display", () => { + const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; + const displays = [ + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 1920, y: 0, width: 2560, height: 1440 }, + ]; + + assert.deepEqual( + DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, displays), + persistedBounds, + ); + assert.deepEqual( + DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, [displays[0]!]), + DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE, + ); + }); + it("recognizes only same-origin renderer navigations", () => { assert.isTrue( DesktopWindow.isSameOriginRendererNavigation({ @@ -355,6 +444,10 @@ describe("DesktopWindow", () => { const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.activate; assert.equal(yield* Ref.get(createCount), 1); + assert.equal(createdWindowOptions[0]?.width, 1100); + assert.equal(createdWindowOptions[0]?.height, 780); + assert.isUndefined(createdWindowOptions[0]?.x); + assert.isUndefined(createdWindowOptions[0]?.y); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.match(String(fakeWindow.loadURL.mock.calls[0]?.[0]), /^data:text\/html/); @@ -408,6 +501,427 @@ describe("DesktopWindow", () => { }), ); + it.effect("uses the persisted main window bounds when opening the window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + assert.equal(createdWindowOptions[0]?.width, 1320); + assert.equal(createdWindowOptions[0]?.height, 880); + assert.equal(createdWindowOptions[0]?.x, 120); + assert.equal(createdWindowOptions[0]?.y, 80); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("restores the persisted maximized state", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, + mainWindowMaximized: true, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + assert.equal(fakeWindow.maximize.mock.calls.length, 0); + const readyToShow = fakeWindow.windowListeners.get("ready-to-show"); + if (!readyToShow) { + return yield* Effect.die("window ready-to-show listener was not registered"); + } + readyToShow(); + assert.equal(fakeWindow.maximize.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("debounces move and resize bounds updates", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const move = fakeWindow.windowListeners.get("move"); + const resize = fakeWindow.windowListeners.get("resize"); + if (!move || !resize) { + return yield* Effect.die("window bounds listeners were not registered"); + } + + fakeWindow.getBounds.mockReturnValue({ x: 120, y: 80, width: 1280, height: 840 }); + move(); + yield* TestClock.adjust(250); + + fakeWindow.getBounds.mockReturnValue({ x: 160, y: 100, width: 1360, height: 900 }); + resize(); + yield* TestClock.adjust(499); + assert.deepEqual(mainWindowBoundsUpdates, []); + + yield* TestClock.adjust(1); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 160, y: 100, width: 1360, height: 900 }]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("persists normal bounds and state for a maximized window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.isMaximized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const mainWindowMaximizedUpdates: boolean[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + mainWindowMaximizedUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); + assert.deepEqual(mainWindowMaximizedUpdates, [true]); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("persists normal bounds and state from the native maximize event", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const mainWindowMaximizedUpdates: boolean[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + mainWindowMaximizedUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const maximize = fakeWindow.windowListeners.get("maximize"); + if (!maximize) { + return yield* Effect.die("window maximize listener was not registered"); + } + + fakeWindow.isMaximized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); + maximize(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); + assert.deepEqual(mainWindowMaximizedUpdates, [true]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("does not persist bounds that fail the domain schema", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: 100.4, y: 80.2, width: 839.4, height: 619.4 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); + } + resize(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, []); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("preserves unrestorable bounds until the user changes the window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 2040, y: 80, width: 1320, height: 880 }, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + const move = fakeWindow.windowListeners.get("move"); + if (!close || !move) { + return yield* Effect.die("window lifecycle listeners were not registered"); + } + + close(); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, []); + + fakeWindow.getBounds.mockReturnValue({ x: 80, y: 60, width: 1280, height: 840 }); + move(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 80, y: 60, width: 1280, height: 840 }]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("flushes normal bounds when fullscreen before the debounce completes", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 200, y: 130, width: 1400, height: 940 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); + } + resize(); + yield* TestClock.adjust(250); + fakeWindow.isFullScreen.mockReturnValue(true); + + yield* desktopWindow.flushMainWindowBounds; + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 200, y: 130, width: 1400, height: 940 }]); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("flushes normal bounds when minimized before the debounce completes", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: -32_000, y: -32_000, width: 160, height: 28 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 180, y: 120, width: 1440, height: 960 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); + } + resize(); + yield* TestClock.adjust(250); + fakeWindow.isMinimized.mockReturnValue(true); + + yield* desktopWindow.flushMainWindowBounds; + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 180, y: 120, width: 1440, height: 960 }]); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("logs display lookup failures before falling back to the default size", () => + Effect.gen(function* () { + const displayLookupFailure = new Error("screen API unavailable"); + vi.mocked(Electron.screen.getAllDisplays).mockImplementationOnce(() => { + throw displayLookupFailure; + }); + const logRecords: Array<{ + readonly message: unknown; + readonly annotations: Readonly>; + }> = []; + const logger = Logger.make(({ fiber, message }) => { + logRecords.push({ + message, + annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, + }); + }); + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + }).pipe( + Effect.provide(Layer.mergeAll(layer, Logger.layer([logger], { mergeWithExisting: false }))), + ); + + const warning = logRecords.find( + (record) => + Array.isArray(record.message) && + record.message[0] === "failed to read connected displays; using defaults", + ); + assert.isDefined(warning); + assert.strictEqual(warning.annotations.cause, displayLookupFailure); + assert.equal(createdWindowOptions[0]?.width, 1100); + assert.equal(createdWindowOptions[0]?.height, 780); + assert.isUndefined(createdWindowOptions[0]?.x); + assert.isUndefined(createdWindowOptions[0]?.y); + }), + ); + + it.effect("persists the current main window bounds before the window closes", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: 240, y: 160, width: 1410, height: 930 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const writeStarted = yield* Deferred.make(); + const allowWrite = yield* Deferred.make(); + const flushCompleted = yield* Deferred.make(); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + beforeMainWindowBoundsUpdate: () => + Deferred.succeed(writeStarted, undefined).pipe( + Effect.andThen(Deferred.await(allowWrite)), + Effect.asVoid, + ), + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* Deferred.await(writeStarted); + fakeWindow.isDestroyed.mockReturnValue(true); + + const flushFiber = yield* desktopWindow.flushMainWindowBounds.pipe( + Effect.andThen(Deferred.succeed(flushCompleted, undefined)), + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + assert.isFalse(yield* Deferred.isDone(flushCompleted)); + + yield* Deferred.succeed(allowWrite, undefined); + yield* Fiber.join(flushFiber); + assert.isTrue(yield* Deferred.isDone(flushCompleted)); + + assert.deepEqual(mainWindowBoundsUpdates, [ + { + x: 240, + y: 160, + width: 1410, + height: 930, + }, + ]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("publishes native macOS fullscreen changes to the renderer", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 899e584ea19b..c9ceba351ad7 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -8,7 +8,7 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import type * as Electron from "electron"; +import * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -20,11 +20,13 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; +const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ -2, // ERR_FAILED @@ -57,6 +59,7 @@ type DesktopWindowRuntimeServices = | FileSystem.FileSystem | Path.Path | DesktopAssets.DesktopAssets + | DesktopAppSettings.DesktopAppSettings | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme @@ -91,6 +94,7 @@ export class DesktopWindow extends Context.Service< // restart, crash). Clears the latch so menu IPC stops targeting the // renderer while the local backend is unavailable. readonly handleBackendNotReady: Effect.Effect; + readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; readonly syncAppearance: Effect.Effect; } @@ -149,6 +153,45 @@ function escapeHtml(value: string): string { .replaceAll("'", "'"); } +type DisplayBounds = Pick; + +function windowFitsWithinDisplay( + windowBounds: DesktopAppSettings.DesktopWindowBounds, + displayBounds: DisplayBounds, +): boolean { + return ( + windowBounds.x >= displayBounds.x && + windowBounds.y >= displayBounds.y && + windowBounds.x + windowBounds.width <= displayBounds.x + displayBounds.width && + windowBounds.y + windowBounds.height <= displayBounds.y + displayBounds.height + ); +} + +function windowBoundsEqual( + left: DesktopAppSettings.DesktopWindowBounds, + right: DesktopAppSettings.DesktopWindowBounds, +): boolean { + return ( + left.x === right.x && + left.y === right.y && + left.width === right.width && + left.height === right.height + ); +} + +export function resolveInitialMainWindowBounds( + persistedBounds: DesktopAppSettings.DesktopWindowBounds | null, + displays: readonly DisplayBounds[], +): DesktopAppSettings.DesktopWindowBounds | typeof DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE { + if ( + persistedBounds !== null && + displays.some((display) => windowFitsWithinDisplay(persistedBounds, display)) + ) { + return persistedBounds; + } + return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; +} + // A self-contained "Connecting to WSL" splash, shown immediately in wsl-only // mode while the WSL backend (which serves the renderer) cold-boots. Inlined as // a data URL so it needs no bundled asset and no backend — pure CSS, no JS. @@ -333,6 +376,7 @@ export const make = Effect.gen(function* () { const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared // by handleBackendNotReady (driven by onShutdown). It controls whether the @@ -346,6 +390,7 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); + let flushMainWindowBounds: Effect.Effect = Effect.void; const dismissConnectingSplash = Effect.gen(function* () { const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); @@ -381,9 +426,31 @@ export const make = Effect.gen(function* () { const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const persistedSettings = yield* desktopSettings.get; + const persistedBounds = persistedSettings.mainWindowBounds; + const displayBoundsResult = yield* Effect.sync(() => { + try { + return { + _tag: "Success" as const, + bounds: Electron.screen.getAllDisplays().map((display) => display.bounds), + }; + } catch (cause) { + return { _tag: "Failure" as const, cause }; + } + }); + const displayBounds = + displayBoundsResult._tag === "Success" + ? displayBoundsResult.bounds + : yield* logWindowWarning("failed to read connected displays; using defaults", { + cause: displayBoundsResult.cause, + }).pipe(Effect.as([])); + const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds); + const restoredPersistedBounds = persistedBounds !== null && initialBounds === persistedBounds; + if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) { + yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); + } const window = yield* electronWindow.create({ - width: 1100, - height: 780, + ...initialBounds, minWidth: 840, minHeight: 620, show: false, @@ -405,6 +472,92 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { window.setAutoHideCursor(false); } + let boundsPersistFiber: Fiber.Fiber | undefined; + let pendingBoundsPersistFiber: Fiber.Fiber | undefined; + let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds; + const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { + if (window.isDestroyed()) { + return null; + } + const bounds = + window.isFullScreen() || window.isMaximized() || window.isMinimized() + ? window.getNormalBounds() + : window.getBounds(); + return DesktopAppSettings.normalizeMainWindowBounds({ + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.round(bounds.width), + height: Math.round(bounds.height), + }); + }; + const fallbackWindowBounds = boundsPersistenceEnabled ? null : readPersistableBounds(); + const fallbackWindowMaximized = persistedSettings.mainWindowMaximized; + const persistCurrentBounds = (): Fiber.Fiber | undefined => { + if (!boundsPersistenceEnabled) { + return pendingBoundsPersistFiber; + } + const bounds = readPersistableBounds(); + if (bounds === null) { + return pendingBoundsPersistFiber; + } + pendingBoundsPersistFiber = runFork( + desktopSettings.setMainWindowBounds(bounds, window.isMaximized()).pipe( + Effect.asVoid, + Effect.catch((error) => + logWindowWarning("failed to persist main window bounds", { + message: error.message, + }), + ), + ), + ); + return pendingBoundsPersistFiber; + }; + const scheduleBoundsPersist = () => { + if (!boundsPersistenceEnabled) { + const currentBounds = readPersistableBounds(); + if ( + currentBounds === null || + (fallbackWindowBounds !== null && + windowBoundsEqual(currentBounds, fallbackWindowBounds) && + window.isMaximized() === fallbackWindowMaximized) + ) { + return; + } + } + boundsPersistenceEnabled = true; + if (boundsPersistFiber !== undefined) { + const fiber = boundsPersistFiber; + boundsPersistFiber = undefined; + runFork(Fiber.interrupt(fiber)); + } + boundsPersistFiber = runFork( + Effect.sleep(MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS).pipe( + Effect.andThen( + Effect.sync(() => { + boundsPersistFiber = undefined; + void persistCurrentBounds(); + }), + ), + ), + ); + }; + const clearBoundsPersist = () => { + if (boundsPersistFiber === undefined) { + return; + } + const fiber = boundsPersistFiber; + boundsPersistFiber = undefined; + runFork(Fiber.interrupt(fiber)); + }; + const flushBoundsPersist = Effect.sync(() => { + clearBoundsPersist(); + return persistCurrentBounds(); + }).pipe( + Effect.flatMap((fiber) => + fiber === undefined ? Effect.void : Fiber.join(fiber).pipe(Effect.asVoid), + ), + ); + flushMainWindowBounds = flushBoundsPersist; yield* previewManager.setMainWindow(window); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { @@ -496,6 +649,13 @@ export const make = Effect.gen(function* () { event.preventDefault(); window.setTitle(environment.displayName); }); + window.on("resize", scheduleBoundsPersist); + window.on("move", scheduleBoundsPersist); + window.on("maximize", scheduleBoundsPersist); + window.on("unmaximize", scheduleBoundsPersist); + window.on("close", () => { + runFork(flushBoundsPersist); + }); if (environment.platform === "darwin") { window.on("enter-full-screen", () => { @@ -635,6 +795,9 @@ export const make = Effect.gen(function* () { bindFirstRevealTrigger(revealSubscribers, () => { // Reveal the real window, then close the connecting splash (if any) so the // two don't overlap and there's no blank gap between them. + if (persistedSettings.mainWindowMaximized) { + window.maximize(); + } void runPromise( revealMainWindow.pipe( Effect.andThen( @@ -674,6 +837,7 @@ export const make = Effect.gen(function* () { window.on("closed", () => { clearDevelopmentLoadRetry(); + clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); }); @@ -812,6 +976,9 @@ export const make = Effect.gen(function* () { handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), ), + flushMainWindowBounds: Effect.suspend(() => flushMainWindowBounds).pipe( + Effect.withSpan("desktop.window.flushMainWindowBounds"), + ), dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { yield* Effect.annotateCurrentSpan({ action }); if (!(yield* Ref.get(backendReadyRef))) { diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index db244d7c726b..4cf787d9ce54 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -15,7 +15,6 @@ import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "re import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; -import { renderCompactBrandTitle } from "./components/CompactBrandTitle"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; @@ -60,6 +59,7 @@ import { EMPTY_INCOMING_SHARE_PRESENTATION_STATE, transitionIncomingSharePresentation, } from "./features/sharing/incoming-share-presentation"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; @@ -79,19 +79,24 @@ type AppScreenOptions = NativeStackNavigationOptions & { // Shared header presets. Screens only override genuinely dynamic values (titles, // subtitles, toolbar items, search callbacks) via NativeStackScreenOptions. // -// GLASS: transparent header over the screen's primary scroll view, with the iOS 26 -// scroll-edge blur sampling the content (Home, Thread, Files tree, settings sheet). +// GLASS: transparent header over the screen's primary scroll view on supported +// iOS versions. Pre-glass iOS gets the same solid material as internal-scroll +// surfaces so content is laid out below the bar instead of underlapping it. const GLASS_HEADER_OPTIONS: AppScreenOptions = { headerBackButtonDisplayMode: "minimal", headerBackTitle: "", headerLargeTitle: false, headerShadowVisible: false, headerShown: true, - headerStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined, + headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED + ? { backgroundColor: "transparent" } + : SHEET_BACKGROUND_COLOR !== undefined + ? { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string } + : undefined, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, - headerTransparent: Platform.OS === "ios", - scrollEdgeEffects: Platform.OS === "ios" ? HEADER_SCROLL_EDGE_EFFECTS : undefined, - unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined, + headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, + scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? HEADER_SCROLL_EDGE_EFFECTS : undefined, + unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, }; // SOLID: opaque sheet-colored header for surfaces whose content scrolls internally @@ -378,8 +383,7 @@ export const RootStack = createNativeStackNavigator({ ...GLASS_HEADER_OPTIONS, contentStyle: { backgroundColor: "transparent" }, headerBackVisible: false, - headerTitle: renderCompactBrandTitle, - title: "T3 Code", + title: "Threads", }, }), Thread: createNativeStackScreen({ diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx deleted file mode 100644 index 2ecf34aa40cd..000000000000 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { View } from "react-native"; - -import { AppText as Text } from "./AppText"; -import { T3Wordmark } from "./T3Wordmark"; -import { useThemeColor } from "../lib/useThemeColor"; - -/** - * Compact brand lockup sized for native navigation bars. - */ -export function CompactBrandTitle() { - const iconColor = useThemeColor("--color-icon"); - const mutedColor = useThemeColor("--color-foreground-muted"); - const subtleColor = useThemeColor("--color-subtle"); - - return ( - - - - Code - - - - Alpha - - - - ); -} - -export function renderCompactBrandTitle() { - return ; -} diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index b29121201f56..dd7a12711949 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -1,20 +1,14 @@ import type { ProjectEntry } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - ActivityIndicator, - FlatList, - Platform, - Pressable, - RefreshControl, - View, -} from "react-native"; +import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { cn } from "../../lib/cn"; import { useThemeColor } from "../../lib/useThemeColor"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { buildFileTree, defaultExpandedTreePaths, @@ -129,7 +123,7 @@ export function FileTreeBrowser(props: { const insets = useSafeAreaInsets(); // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the // observed adjustedContentInset bottom (~102) seen in the native trace. - const headerInset = Platform.OS === "ios" ? insets.top + 44 : 0; + const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 44 : 0; const iconColor = String(useThemeColor("--color-icon-muted")); const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props; const controlledSelectedPathRef = useRef(controlledSelectedPath); @@ -249,9 +243,11 @@ export function FileTreeBrowser(props: { className="flex-1" data={visibleNodes} keyExtractor={(item) => item.node.path} - contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} scrollIndicatorInsets={ - Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined + NATIVE_LIQUID_GLASS_SUPPORTED + ? { top: headerInset, left: 0, right: 0, bottom: 0 } + : undefined } keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index cd9467c774e8..49cf06d85ec7 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -4,7 +4,6 @@ import { useNavigation } from "@react-navigation/native"; import { useMemo, useState } from "react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { renderCompactBrandTitle } from "../../components/CompactBrandTitle"; import { useProjects, useThreadShells } from "../../state/entities"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -87,9 +86,7 @@ export function HomeRouteScreen() { > <> {/* Restore the compact title in case the split branch blanked it. */} - + @@ -399,11 +400,20 @@ export function HomeScreen(props: HomeScreenProps) { onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} variant="plain" /> - {emptyState.loading ? ( + {emptyState.loading && !shouldShowConnectionStatus ? ( ) : null} + {shouldShowConnectionStatus && Platform.OS === "ios" ? ( + + + + ) : null} {connectionStatus} @@ -460,8 +470,8 @@ export function HomeScreen(props: HomeScreenProps) { ListHeaderComponent={listHeader} ListEmptyComponent={listEmpty} style={{ flex: 1 }} - automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} - contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} + contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} showsVerticalScrollIndicator={false} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts b/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts index 767f0e3dd3f8..8c3c873cc9e8 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts +++ b/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts @@ -67,4 +67,21 @@ describe("workspace connection status", () => { expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini"); }); + + it("shows shell catch-up while cached threads remain visible", () => { + const state = workspaceState({ hasPendingShellSnapshot: true }); + + expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); + expect(workspaceConnectionStatusLabel(state)).toBe("Syncing threads..."); + }); + + it("distinguishes initial shell loading from cached catch-up", () => { + const state = workspaceState({ + hasLoadedShellSnapshot: false, + hasPendingShellSnapshot: true, + }); + + expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); + expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads..."); + }); }); diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx b/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx index 8acdacfbbb37..1e986ad1a506 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx @@ -12,7 +12,10 @@ export function WorkspaceConnectionStatus(props: { readonly variant?: "floating" | "sidebar"; }) { const iconColor = useThemeColor("--color-icon-muted"); - const isReconnecting = props.state.connectingEnvironments.length > 0; + const isSynchronizing = + props.state.networkStatus !== "offline" && + props.state.connectionError === null && + (props.state.connectingEnvironments.length > 0 || props.state.hasPendingShellSnapshot); const variant = props.variant ?? "floating"; return ( @@ -37,7 +40,7 @@ export function WorkspaceConnectionStatus(props: { : undefined } > - {isReconnecting ? ( + {isSynchronizing ? ( ) : ( diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index 7d2c6d840d49..d8eed4383b18 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -5,6 +5,7 @@ export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): bool state.networkStatus === "offline" || state.connectionError !== null || state.hasConnectingEnvironment || + state.hasPendingShellSnapshot || (state.hasLoadedShellSnapshot && !state.hasReadyEnvironment) ); } @@ -18,5 +19,8 @@ export function workspaceConnectionStatusLabel(state: WorkspaceState): string { return `Reconnecting ${state.connectingEnvironments.length} environments`; } if (state.connectionError !== null) return state.connectionError; + if (state.hasPendingShellSnapshot) { + return state.hasLoadedShellSnapshot ? "Syncing threads..." : "Loading threads..."; + } return "Not connected"; } diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 31ad56609833..32527b0b719b 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -28,6 +28,7 @@ import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import type { PendingApproval, PendingUserInput, @@ -197,7 +198,13 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { entering={FadeInDown.duration(200)} exiting={FadeOut.duration(140)} > - + diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 6e0e243aad66..f7ee4eaa74fb 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -17,6 +17,7 @@ import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -571,8 +572,10 @@ function ThreadNavigationSidebarPane( itemsAreEqual={homeListItemsAreEqual} keyExtractor={(item) => item.key} renderItem={renderListItem} - automaticallyAdjustsScrollIndicatorInsets - contentInsetAdjustmentBehavior="automatic" + automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} + contentInsetAdjustmentBehavior={ + NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never" + } contentContainerStyle={[ styles.threadListContent, { diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index b5b5140f232c..9808b573ad45 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -26,6 +26,7 @@ import { import { ControlPillMenu } from "../../components/ControlPill"; import { LoadingScreen } from "../../components/LoadingScreen"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { connectionTone } from "../connection/connectionTone"; import { @@ -287,7 +288,7 @@ function ThreadRouteContent( ); /* ─── Native header theming ──────────────────────────────────────── */ - const usesNativeHeaderGlass = Platform.OS === "ios"; + const usesNativeHeaderGlass = NATIVE_LIQUID_GLASS_SUPPORTED; const headerSubtitle = [ selectedThreadProject?.title ?? null, selectedEnvironmentConnection?.environmentLabel ?? null, diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx index a94c033fa4dc..f0e3f89c07dc 100644 --- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx +++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx @@ -11,7 +11,7 @@ import { import type { ReactNode } from "react"; import { Platform, useColorScheme } from "react-native"; -import { renderCompactBrandTitle } from "../../components/CompactBrandTitle"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -34,13 +34,12 @@ const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = { headerLargeTitle: false, headerShadowVisible: false, headerShown: true, - headerStyle: { backgroundColor: "transparent" }, - headerTitle: renderCompactBrandTitle, + headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, - headerTransparent: true, - scrollEdgeEffects: SCROLL_EDGE_EFFECTS, - title: "T3 Code", - unstable_navigationItemStyle: "editor", + headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, + scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? SCROLL_EDGE_EFFECTS : undefined, + title: "Threads", + unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, }; const SidebarStack = createNativeStackNavigator(); diff --git a/apps/mobile/src/lib/native-glass-capability.test.ts b/apps/mobile/src/lib/native-glass-capability.test.ts new file mode 100644 index 000000000000..43f865c77c30 --- /dev/null +++ b/apps/mobile/src/lib/native-glass-capability.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { supportsNativeLiquidGlass } from "./native-glass-capability"; + +describe("supportsNativeLiquidGlass", () => { + it("uses native liquid glass when iOS reports the capability", () => { + expect(supportsNativeLiquidGlass("ios", true)).toBe(true); + }); + + it("keeps pre-glass iOS on the solid fallback", () => { + expect(supportsNativeLiquidGlass("ios", false)).toBe(false); + }); + + it("does not enable iOS liquid-glass layout behavior on other platforms", () => { + expect(supportsNativeLiquidGlass("android", true)).toBe(false); + expect(supportsNativeLiquidGlass("web", true)).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/native-glass-capability.ts b/apps/mobile/src/lib/native-glass-capability.ts new file mode 100644 index 000000000000..61c8547b5073 --- /dev/null +++ b/apps/mobile/src/lib/native-glass-capability.ts @@ -0,0 +1,6 @@ +export function supportsNativeLiquidGlass( + platform: string, + nativeCapabilityAvailable: boolean, +): boolean { + return platform === "ios" && nativeCapabilityAvailable; +} diff --git a/apps/mobile/src/native/native-glass.ts b/apps/mobile/src/native/native-glass.ts new file mode 100644 index 000000000000..40b28076d360 --- /dev/null +++ b/apps/mobile/src/native/native-glass.ts @@ -0,0 +1,9 @@ +import { isLiquidGlassSupported } from "@callstack/liquid-glass"; +import { Platform } from "react-native"; + +import { supportsNativeLiquidGlass } from "../lib/native-glass-capability"; + +export const NATIVE_LIQUID_GLASS_SUPPORTED = supportsNativeLiquidGlass( + Platform.OS, + isLiquidGlassSupported, +); diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 743f3f510b7f..8e6af3c0f2d5 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -10,13 +10,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { AuthProjectAudienceAdminScope, AuthStandardClientScopes, + CommandId, EnvironmentOrchestrationHttpApi, ProjectId, + ProviderInstanceId, + ThreadId, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import { FetchHttpClient } from "effect/unstable/http"; @@ -32,7 +36,9 @@ import { Command } from "effect/unstable/cli"; import { cli, makeCli } from "./bin.ts"; import * as ServerConfig from "./config.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; +import { trustedSystemDispatchAuthority } from "./orchestration/commandAudienceGuard.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts"; @@ -506,6 +512,66 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }), ); + it.effect("force removes projects that still contain threads", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-projects-force-remove-test-"), + ); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-projects-force-remove-workspace-"), + ); + + yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", baseDir]); + const afterAdd = yield* readPersistedSnapshot(baseDir); + const project = afterAdd.projects.find( + (candidate) => candidate.workspaceRoot === workspaceRoot && candidate.deletedAt === null, + ); + assert.isTrue(project !== undefined); + + const config = yield* makeCliTestServerConfig(baseDir); + yield* Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + yield* engine.dispatch( + { + type: "thread.create", + commandId: CommandId.make("cmd-cli-force-remove-thread"), + threadId: ThreadId.make("thread-cli-force-remove"), + projectId: project!.id, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }, + trustedSystemDispatchAuthority("cli-test"), + ); + }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); + + yield* runCliWithRuntime([ + "project", + "remove", + project!.id, + "--force", + "--base-dir", + baseDir, + ]); + const afterRemove = yield* readPersistedSnapshot(baseDir); + assert.isTrue( + (afterRemove.projects.find((candidate) => candidate.id === project!.id)?.deletedAt ?? + null) !== null, + ); + assert.isTrue( + (afterRemove.threads.find((thread) => thread.id === "thread-cli-force-remove")?.deletedAt ?? + null) !== null, + ); + }), + ); + it.effect("routes project commands through a running server when runtime state is present", () => Effect.gen(function* () { const baseDir = NodeFS.mkdtempSync( diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index f1bb9bd0e038..c908d66dd062 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -580,6 +580,10 @@ const projectRemoveCommand = Command.make("remove", { project: Argument.string("project").pipe( Argument.withDescription("Project id or workspace root to remove."), ), + force: Flag.boolean("force").pipe( + Flag.withDescription("Delete the project and all of its threads."), + Flag.withDefault(false), + ), }).pipe( Command.withDescription("Remove a project."), Command.withHandler((flags) => @@ -602,6 +606,7 @@ const projectRemoveCommand = Command.make("remove", { type: "project.delete", commandId: CommandId.make(yield* projectCommandUuid), projectId: project.id, + force: flags.force, }); return `Removed project ${project.id} (${project.title}).`; }), diff --git a/apps/server/src/mcp/toolkits/subagent/handlers.test.ts b/apps/server/src/mcp/toolkits/subagent/handlers.test.ts index 3ae107dd0ad1..fed0be6c8a19 100644 --- a/apps/server/src/mcp/toolkits/subagent/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/subagent/handlers.test.ts @@ -779,6 +779,7 @@ const engineLayer = Layer.effect( return { sequence: engineCommands.length }; }), streamDomainEvents: unsupported(), + latestSequence: Effect.succeed(0), }; }), ); diff --git a/apps/server/src/orchestration/Layers/ChildThreadCoordinator.test.ts b/apps/server/src/orchestration/Layers/ChildThreadCoordinator.test.ts index 7e64d77b9c9d..ecb70109ba11 100644 --- a/apps/server/src/orchestration/Layers/ChildThreadCoordinator.test.ts +++ b/apps/server/src/orchestration/Layers/ChildThreadCoordinator.test.ts @@ -606,6 +606,7 @@ describe("ChildThreadCoordinator", () => { }), ), dispatch: (command, authority) => Effect.sync(() => recordDispatch(command, authority)), + latestSequence: Effect.succeed(0), }), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 0b0799ee3abe..8076280fa09d 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -250,6 +250,7 @@ describe("OrchestrationEngine", () => { const runtime = ManagedRuntime.make(layer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + expect(await runtime.runPromise(engine.latestSequence)).toBe(7); const result = await runtime.runPromise( engine.dispatch( { @@ -263,6 +264,7 @@ describe("OrchestrationEngine", () => { ); expect(result.sequence).toBe(8); + expect(await runtime.runPromise(engine.latestSequence)).toBe(8); expect(fullSnapshotReadCount).toBe(0); await runtime.dispose(); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 3289a15d2bfb..44ec625982db 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -388,6 +388,11 @@ const makeOrchestrationEngine = Effect.gen(function* () { get streamDomainEvents(): OrchestrationEngineShape["streamDomainEvents"] { return Stream.fromPubSub(eventPubSub); }, + // The command read model's snapshotSequence tracks the latest committed + // event sequence (updated on the worker fiber). A plain property read is a + // consistent, committed value — reassignment of `commandReadModel` is + // atomic on the single-threaded event loop. + latestSequence: Effect.sync(() => commandReadModel.snapshotSequence), } satisfies OrchestrationEngineShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 4767e7b0935e..e1df35df373e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -746,6 +746,7 @@ describe("ProviderCommandReactor", () => { } return { sequence: dispatchedCommands.length + 1 }; }), + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngineService["Service"]; const archivedTurnStartEvent: Extract< OrchestrationEvent, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts new file mode 100644 index 000000000000..05370781c0d0 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts @@ -0,0 +1,33 @@ +import { + EventId, + ProviderDriverKind, + RuntimeRequestId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { runtimeEventToActivities } from "./ProviderRuntimeIngestion.ts"; + +describe("runtimeEventToActivities approval details", () => { + it("preserves complete multiline command details", () => { + const detail = `bun run release -- ${"long-argument ".repeat(20)}\nsecond line`; + const event = { + type: "request.opened", + eventId: EventId.make("evt-request-opened"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-07-18T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + requestId: RuntimeRequestId.make("approval-1"), + payload: { + requestType: "command_execution_approval", + detail, + }, + } satisfies ProviderRuntimeEvent; + + const [activity] = runtimeEventToActivities(event); + + expect(activity?.kind).toBe("approval.requested"); + expect((activity?.payload as Record | undefined)?.detail).toBe(detail); + }); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index a240f87ae569..9de3b1295216 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1575,6 +1575,51 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBeNull(); }); + it("clears active turn when provider session becomes ready", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-session-ready"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-session-ready"), + }); + + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-session-ready", + 10_000, + ); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-session-state-ready-with-active-turn"), + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:01.000Z", + payload: { + state: "ready", + }, + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => + entry.session?.status === "ready" && + entry.session?.activeTurnId === null && + entry.session?.lastError === null, + 10_000, + ); + expect(thread.session?.status).toBe("ready"); + expect(thread.session?.activeTurnId).toBeNull(); + expect(thread.session?.lastError).toBeNull(); + }); + xit("does not clear active turn when session/thread started arrives mid-turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -1593,6 +1638,7 @@ describe("ProviderRuntimeIngestion", () => { (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-midturn-lifecycle", + 10_000, ); harness.emit({ @@ -1629,6 +1675,7 @@ describe("ProviderRuntimeIngestion", () => { await waitForThread( harness.readModel, (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, + 10_000, ); }); @@ -5170,6 +5217,196 @@ describe("ProviderRuntimeIngestion", () => { ).toBe("# Plan title"); }); + it("titles task activities with the task description, including on completion", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "task.started", + eventId: asEventId("evt-named-task-started"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-named-task"), + payload: { + taskId: "named-task-1", + description: "Typecheck mobile app", + taskType: "local_bash", + }, + }); + + harness.emit({ + type: "task.progress", + eventId: asEventId("evt-named-task-progress"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-named-task"), + payload: { + taskId: "named-task-1", + description: "Typecheck mobile app", + summary: "Running tsc across the mobile workspace.", + }, + }); + + harness.emit({ + type: "task.completed", + eventId: asEventId("evt-named-task-completed"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-named-task"), + payload: { + taskId: "named-task-1", + status: "completed", + summary: "Typecheck finished without errors.", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed", + ), + ); + + const progress = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-progress", + ); + const completed = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed", + ); + + const progressPayload = + progress?.payload && typeof progress.payload === "object" + ? (progress.payload as Record) + : undefined; + const completedPayload = + completed?.payload && typeof completed.payload === "object" + ? (completed.payload as Record) + : undefined; + + expect(progress?.summary).toBe("Typecheck mobile app"); + expect(progressPayload?.title).toBe("Typecheck mobile app"); + expect(completed?.summary).toBe("Task completed"); + expect(completedPayload?.title).toBe("Typecheck mobile app"); + expect(completedPayload?.summary).toBe("Typecheck finished without errors."); + expect(completedPayload?.detail).toBe("Typecheck finished without errors."); + }); + + it("titles task completion from task.started when no progress event carried the name", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "task.started", + eventId: asEventId("evt-fast-task-started"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-fast-task"), + payload: { + taskId: "fast-task-1", + description: "wait for codex review to finish", + taskType: "local_bash", + }, + }); + + harness.emit({ + type: "task.completed", + eventId: asEventId("evt-fast-task-completed"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-fast-task"), + payload: { + taskId: "fast-task-1", + status: "completed", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-fast-task-completed", + ), + ); + + const completed = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-fast-task-completed", + ); + const completedPayload = + completed?.payload && typeof completed.payload === "object" + ? (completed.payload as Record) + : undefined; + + expect(completedPayload?.title).toBe("wait for codex review to finish"); + }); + + it("titles task completion from persisted activities after the description cache is swept", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "task.progress", + eventId: asEventId("evt-swept-task-progress"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-swept-task"), + payload: { + taskId: "swept-task-1", + description: "Watch round-3 CI and bots", + summary: "Polling CI checks.", + }, + }); + + await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-progress", + ), + ); + + // session.exited sweeps the in-memory description cache; the completion + // that follows must recover the name from persisted activities. + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-swept-task-session-exited"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + payload: {}, + }); + + harness.emit({ + type: "task.completed", + eventId: asEventId("evt-swept-task-completed"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-swept-task"), + payload: { + taskId: "swept-task-1", + status: "completed", + summary: "CI is green.", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", + ), + ); + + const completed = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", + ); + const completedPayload = + completed?.payload && typeof completed.payload === "object" + ? (completed.payload as Record) + : undefined; + + expect(completedPayload?.title).toBe("Watch round-3 CI and bots"); + }); + xit("projects structured user input request and resolution as thread activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 25682e73e7d2..55dbf0f642f5 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -43,6 +43,42 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { threadAudienceSystemDispatchAuthority } from "../commandAudienceGuard.ts"; import { resolveProviderRuntimeEventBinding } from "../providerRuntimeEventBinding.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; +const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; + +// Fallback when the in-memory description cache no longer has the task name +// (server restart, session-exit sweep, TTL/capacity eviction): earlier +// task.started/task.progress activities for the task are persisted with it. +function findTaskTitleInActivities( + activities: ReadonlyArray | undefined, + taskId: string, +): string | undefined { + if (!activities) { + return undefined; + } + for (let index = activities.length - 1; index >= 0; index -= 1) { + const activity = activities[index]; + if (!activity || (activity.kind !== "task.started" && activity.kind !== "task.progress")) { + continue; + } + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as { taskId?: unknown; title?: unknown; detail?: unknown }) + : undefined; + if (payload?.taskId !== taskId) { + continue; + } + const title = + typeof payload.title === "string" + ? payload.title + : activity.kind === "task.started" && typeof payload.detail === "string" + ? payload.detail + : undefined; + if (title && title.trim().length > 0) { + return title; + } + } + return undefined; +} interface AssistantSegmentState { readonly segmentsByBaseKey: ReadonlyMap; @@ -63,6 +99,8 @@ const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); const BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY = 10_000; const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); +const TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY = 10_000; +const TASK_DESCRIPTION_BY_TASK_TTL = Duration.minutes(120); const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; @@ -253,6 +291,12 @@ function orchestrationSessionStatusFromRuntimeState( } } +function sessionStatusAllowsActiveTurn( + status: ReturnType, +): boolean { + return status === "starting" || status === "running"; +} + function requestKindFromCanonicalRequestType( requestType: string | undefined, ): "command" | "file-read" | "file-change" | undefined { @@ -270,8 +314,9 @@ function requestKindFromCanonicalRequestType( } } -function runtimeEventToActivities( +export function runtimeEventToActivities( event: ProviderRuntimeEvent, + taskTitle?: string, ): ReadonlyArray { const maybeSequence = (() => { const eventWithSequence = event as ProviderRuntimeEvent & { sessionSequence?: number }; @@ -303,7 +348,7 @@ function runtimeEventToActivities( requestId: toApprovalRequestId(event.requestId), ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, - ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.detail ? { detail: event.payload.detail } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -481,9 +526,15 @@ function runtimeEventToActivities( createdAt: event.createdAt, tone: "info", kind: "task.progress", - summary: "Reasoning update", + summary: + event.payload.description.trim().length > 0 + ? truncateDetail(event.payload.description, 120) + : "Reasoning update", payload: { taskId: event.payload.taskId, + ...(event.payload.description.trim().length > 0 + ? { title: truncateDetail(event.payload.description, 120) } + : {}), detail: truncateDetail(event.payload.summary ?? event.payload.description), ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), @@ -511,7 +562,15 @@ function runtimeEventToActivities( payload: { taskId: event.payload.taskId, status: event.payload.status, - ...(event.payload.summary ? { detail: truncateDetail(event.payload.summary) } : {}), + ...(taskTitle ? { title: truncateDetail(taskTitle, 120) } : {}), + // summary + detail mirror task.progress: clients label the row from + // summary and keep detail for the preview/expanded body. + ...(event.payload.summary + ? { + summary: truncateDetail(event.payload.summary), + detail: truncateDetail(event.payload.summary), + } + : {}), ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), }, turnId: toTurnId(event.turnId) ?? null, @@ -685,6 +744,27 @@ const make = Effect.gen(function* () { lookup: () => Effect.succeed({ text: "", createdAt: "" }), }); + // Task names arrive on task.started/task.progress but not on task.completed, + // so remember them per task to title the completion activity. + const taskDescriptionByTaskKey = yield* Cache.make({ + capacity: TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY, + timeToLive: TASK_DESCRIPTION_BY_TASK_TTL, + lookup: () => Effect.succeed(""), + }); + + const rememberTaskDescription = (threadId: ThreadId, taskId: string, description: string) => + Cache.set(taskDescriptionByTaskKey, providerTaskKey(threadId, taskId), description); + + // Entries are left in place after completion so replayed or duplicate + // terminal events stay titled; TTL, capacity, and the session-exit sweep + // bound the cache. + const lookupTaskDescription = (threadId: ThreadId, taskId: string) => + Cache.getOption(taskDescriptionByTaskKey, providerTaskKey(threadId, taskId)).pipe( + Effect.map((description) => + Option.filter(description, (value) => value.length > 0).pipe(Option.getOrUndefined), + ), + ); + const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery .getThreadDetailById(threadId) @@ -1249,6 +1329,7 @@ const make = Effect.gen(function* () { ); const assistantSegmentKeys = Array.from(yield* Cache.keys(assistantSegmentStateByTurnKey)); const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); + const taskDescriptionKeys = Array.from(yield* Cache.keys(taskDescriptionByTaskKey)); yield* Effect.forEach( turnKeys, (key) => @@ -1298,6 +1379,12 @@ const make = Effect.gen(function* () { : Effect.void, { concurrency: 1 }, ).pipe(Effect.asVoid); + yield* Effect.forEach( + taskDescriptionKeys, + (key) => + key.startsWith(prefix) ? Cache.invalidate(taskDescriptionByTaskKey, key) : Effect.void, + { concurrency: 1 }, + ).pipe(Effect.asVoid); }); const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fn( @@ -1478,12 +1565,6 @@ const make = Effect.gen(function* () { event.type === "turn.started" || event.type === "turn.completed" ) { - const nextActiveTurnId = - event.type === "turn.started" - ? (eventTurnId ?? null) - : event.type === "turn.completed" || event.type === "session.exited" - ? null - : activeTurnId; const status = (() => { switch (event.type) { case "session.state.changed": @@ -1503,6 +1584,14 @@ const make = Effect.gen(function* () { return activeTurnId !== null ? "running" : "ready"; } })(); + const nextActiveTurnId = + event.type === "turn.started" + ? (eventTurnId ?? null) + : event.type === "turn.completed" || event.type === "session.exited" + ? null + : event.type === "session.state.changed" && !sessionStatusAllowsActiveTurn(status) + ? null + : activeTurnId; const lastError = event.type === "session.state.changed" && event.payload.state === "error" ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") @@ -1964,7 +2053,22 @@ const make = Effect.gen(function* () { } } - const activities = runtimeEventToActivities(event); + if (event.type === "task.started" || event.type === "task.progress") { + const description = event.payload.description?.trim(); + if (description) { + yield* rememberTaskDescription(thread.id, event.payload.taskId, description); + } + } + let taskTitle: string | undefined; + if (event.type === "task.completed") { + taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); + if (!taskTitle) { + const threadDetail = yield* getLoadedThreadDetail(); + taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId); + } + } + + const activities = runtimeEventToActivities(event, taskTitle); yield* Effect.forEach(activities, (activity) => providerCommandId(event, "thread-activity-append").pipe( Effect.flatMap((commandId) => diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index 1e8136c686d5..fef68725e305 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -178,6 +178,7 @@ describe("ThreadDeletionReactor", () => { const engine = { readEvents: () => Stream.empty, streamDomainEvents: Stream.fromPubSub(domainEvents), + latestSequence: Effect.succeed(0), dispatch: (command: OrchestrationCommand) => Effect.gen(function* () { dispatchedCommands.push(command); @@ -345,6 +346,7 @@ describe("ThreadDeletionReactor", () => { const engine = { readEvents: () => Stream.fromIterable([archiveEvent]), streamDomainEvents: Stream.fromPubSub(domainEvents), + latestSequence: Effect.succeed(0), dispatch: (command: OrchestrationCommand) => Effect.sync(() => { dispatchedCommands.push(command); @@ -451,6 +453,7 @@ describe("ThreadDeletionReactor", () => { const engine = { readEvents: () => Stream.fromIterable([archiveEvent]), streamDomainEvents: Stream.fromPubSub(domainEvents), + latestSequence: Effect.succeed(0), dispatch: (command: OrchestrationCommand) => Effect.sync(() => { dispatchedCommands.push(command); @@ -568,6 +571,7 @@ describe("ThreadDeletionReactor", () => { const engine = { readEvents: () => Stream.empty, streamDomainEvents: Stream.fromPubSub(domainEvents), + latestSequence: Effect.succeed(0), dispatch: (command: OrchestrationCommand) => Effect.sync(() => { dispatchedCommands.push(command); @@ -665,6 +669,7 @@ describe("ThreadDeletionReactor", () => { const engine = { readEvents: () => Stream.empty, streamDomainEvents: Stream.fromPubSub(domainEvents), + latestSequence: Effect.succeed(0), dispatch: (command: OrchestrationCommand) => Effect.sync(() => { dispatchedCommands.push(command); @@ -761,6 +766,7 @@ describe("ThreadDeletionReactor", () => { const engine = { readEvents: () => Stream.empty, streamDomainEvents: Stream.fromPubSub(domainEvents), + latestSequence: Effect.succeed(0), dispatch: () => unsupported(), } satisfies OrchestrationEngineService["Service"]; diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts index 43e3bf12d6cf..e51fc08f2e3f 100644 --- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts @@ -67,6 +67,13 @@ export interface OrchestrationEngineShape { * This is a hot runtime stream (new events only), not a historical replay. */ readonly streamDomainEvents: Stream.Stream; + + /** + * The latest sequence reflected in the engine's authoritative command read + * model (0 if none). Used to gauge how far behind a resuming client is before + * choosing between an incremental replay and a fresh projected snapshot. + */ + readonly latestSequence: Effect.Effect; } /** diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts new file mode 100644 index 000000000000..020fc48a4656 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; + +import { ClaudeExecutableFileCheck, resolveClaudeSdkExecutablePath } from "./ClaudeExecutable.ts"; + +const NPM_DIR = "C:\\Users\\dev\\AppData\\Roaming\\npm"; +const NPM_SHIM = `${NPM_DIR}\\claude.cmd`; +const NPM_PACKAGE_EXE = `${NPM_DIR}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; +const NPM_PACKAGE_CLI = `${NPM_DIR}\\node_modules\\@anthropic-ai\\claude-code\\cli.js`; + +function withWindowsResolution(input: { + readonly resolvedCommand: string | undefined; + readonly existingFiles?: ReadonlyArray; +}) { + const existing = new Set(input.existingFiles ?? []); + return (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => input.resolvedCommand), + Effect.provideService(ClaudeExecutableFileCheck, (filePath) => existing.has(filePath)), + ); +} + +describe("resolveClaudeSdkExecutablePath", () => { + it.effect("returns the configured path unchanged on non-Windows platforms", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(SpawnExecutableResolution, () => { + throw new Error("must not resolve on non-Windows platforms"); + }), + ), + ).toBe("claude"); + }), + ); + + it.effect("returns the resolved absolute path for native Windows executables", () => + Effect.gen(function* () { + const nativeBinary = "C:\\Users\\dev\\.local\\bin\\claude.exe"; + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ resolvedCommand: nativeBinary }), + ), + ).toBe(nativeBinary); + }), + ); + + it.effect("follows an npm launcher shim to the packaged native binary", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: NPM_SHIM, + existingFiles: [NPM_PACKAGE_EXE, NPM_PACKAGE_CLI], + }), + ), + ).toBe(NPM_PACKAGE_EXE); + }), + ); + + it.effect("follows .bat and .ps1 launcher shims the same way", () => + Effect.gen(function* () { + for (const shim of [`${NPM_DIR}\\claude.bat`, `${NPM_DIR}\\claude.ps1`]) { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: shim, + existingFiles: [NPM_PACKAGE_EXE], + }), + ), + ).toBe(NPM_PACKAGE_EXE); + } + }), + ); + + it.effect("normalizes mixed-case shim extensions before matching", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: `${NPM_DIR}\\claude.CMD`, + existingFiles: [NPM_PACKAGE_EXE], + }), + ), + ).toBe(NPM_PACKAGE_EXE); + }), + ); + + it.effect("falls back to cli.js when the package ships no native binary", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: NPM_SHIM, + existingFiles: [NPM_PACKAGE_CLI], + }), + ), + ).toBe(NPM_PACKAGE_CLI); + }), + ); + + it.effect("returns the configured path when a shim has no known package entry", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ resolvedCommand: NPM_SHIM }), + ), + ).toBe("claude"); + }), + ); + + it.effect("returns the configured path when command resolution finds nothing", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ resolvedCommand: undefined }), + ), + ).toBe("claude"); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.ts new file mode 100644 index 000000000000..febfdb26f9e3 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.ts @@ -0,0 +1,90 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; + +/** + * Windows launcher-script extensions that Node cannot spawn without a shell + * (`spawn EINVAL` since Node 20.12) and that the Claude Agent SDK therefore + * cannot use as `pathToClaudeCodeExecutable`. + */ +const WINDOWS_SHIM_EXTENSIONS: ReadonlySet = new Set([".cmd", ".bat", ".ps1"]); + +/** + * Entry points of the npm `@anthropic-ai/claude-code` package relative to the + * global `node_modules` directory that sits next to the npm launcher shim. + * Newer package versions ship a native `bin/claude.exe`; older versions only + * ship `cli.js`, which the SDK runs with a JavaScript runtime. + */ +const NPM_PACKAGE_ENTRY_CANDIDATES = [ + ["node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"], + ["node_modules", "@anthropic-ai", "claude-code", "cli.js"], +] as const; + +export type ExecutableFileCheck = (filePath: string) => boolean; + +function isExistingFile(filePath: string): boolean { + try { + return NodeFS.statSync(filePath).isFile(); + } catch { + return false; + } +} + +/** Injectable file-existence check so tests can run against a fake filesystem. */ +export const ClaudeExecutableFileCheck = Context.Reference( + "server/provider/Drivers/ClaudeExecutableFileCheck", + { + defaultValue: () => isExistingFile, + }, +); + +/** + * Resolves the configured Claude binary path into a value the Claude Agent + * SDK can spawn directly via `pathToClaudeCodeExecutable`. + * + * The SDK spawns the given path without a shell and without Windows PATH / + * PATHEXT resolution, so a bare command name like `claude` fails with + * "native binary not found" and an npm `claude.cmd` shim fails with + * `spawn EINVAL`. CLI probes avoid this via `resolveSpawnCommand`, which can + * fall back to `shell: true`; the SDK offers no such escape hatch. + * + * On Windows this resolves the command against PATH/PATHEXT and, when the + * result is an npm launcher shim, follows it to the real package entry + * (`bin/claude.exe`, or `cli.js` for older package versions). On other + * platforms the configured value is returned unchanged. + */ +export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecutablePath")( + function* (binaryPath: string, environment: NodeJS.ProcessEnv): Effect.fn.Return { + const platform = yield* HostProcessPlatform; + if (platform !== "win32") { + return binaryPath; + } + + const resolveExecutable = yield* SpawnExecutableResolution; + const isFile = yield* ClaudeExecutableFileCheck; + const resolved = resolveExecutable(binaryPath, platform, environment) ?? binaryPath; + const extension = NodePath.win32.extname(resolved).toLowerCase(); + if (!WINDOWS_SHIM_EXTENSIONS.has(extension)) { + return resolved; + } + + const shimDirectory = NodePath.win32.dirname(resolved); + for (const entrySegments of NPM_PACKAGE_ENTRY_CANDIDATES) { + const candidate = NodePath.win32.join(shimDirectory, ...entrySegments); + if (isFile(candidate)) { + return candidate; + } + } + + yield* Effect.logWarning( + "Claude launcher shim resolved but no known package entry was found next to it; the Claude Agent SDK cannot spawn launcher scripts directly.", + { binaryPath, resolvedShimPath: resolved }, + ); + return binaryPath; + }, +); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 5bdfd2f5bfc0..16c45ca2203c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -73,6 +73,7 @@ import { ServerConfig } from "../../config.ts"; import { makeClaudeMcpServers } from "../../mcp/McpProviderInjection.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as WorkerProcessIsolation from "../../process/WorkerProcessIsolation.ts"; +import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { getClaudeModelCapabilities, @@ -1551,6 +1552,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( Effect.provideService(Path.Path, path), ); + const claudeSdkExecutablePath = yield* resolveClaudeSdkExecutablePath( + claudeSettings.binaryPath, + claudeEnvironment, + ); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -3677,7 +3682,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); - const claudeBinaryPath = claudeSettings.binaryPath; + const claudeBinaryPath = claudeSdkExecutablePath; const workerExecutable = input.detached === true ? yield* workerProcessIsolation diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index af8f5d6704b2..ff0d1992454b 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -2,7 +2,6 @@ import { type ClaudeSettings, type ModelCapabilities, type ModelSelection, - ProviderDriverKind, type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; @@ -37,13 +36,13 @@ import { spawnAndCollect, type ServerProviderDraft, } from "../providerSnapshot.ts"; +import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); -const PROVIDER = ProviderDriverKind.make("claudeAgent"); const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, @@ -479,9 +478,19 @@ function claudeAuthMetadata(input: { return undefined; } +function apiProviderAuthMetadata( + apiProvider: string | undefined, +): { readonly type: string; readonly label: string } | undefined { + return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined; +} + // ── SDK capability probe ──────────────────────────────────────────── -const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; +// Amazon Bedrock initializes far slower than first-party auth: the SDK boots the +// Bedrock backend and runs the `awsAuthRefresh` credential hook before returning +// account info. The previous 8s budget expired mid-init, so the probe returned +// `undefined` and left the provider unverified and unselectable in the picker. +const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; function nonEmptyProbeString(value: string): string | undefined { const candidate = value.trim(); @@ -492,6 +501,12 @@ type ClaudeCapabilitiesProbe = { readonly email: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; + /** + * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth + * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) + * the subscription/token fields are absent and auth is external AWS creds. + */ + readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; }; @@ -588,6 +603,10 @@ const probeClaudeCapabilities = ( const abort = new AbortController(); return Effect.gen(function* () { const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const executablePath = yield* resolveClaudeSdkExecutablePath( + claudeSettings.binaryPath, + claudeEnvironment, + ); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. @@ -598,7 +617,7 @@ const probeClaudeCapabilities = ( })(), options: { persistSession: false, - pathToClaudeCodeExecutable: claudeSettings.binaryPath, + pathToClaudeCodeExecutable: executablePath, abortController: abort, settingSources: ["user", "project", "local"], allowedTools: [], @@ -613,12 +632,14 @@ const probeClaudeCapabilities = ( readonly email?: string; readonly subscriptionType?: string; readonly tokenSource?: string; + readonly apiProvider?: string; } | undefined; return { email: account?.email, subscriptionType: account?.subscriptionType, tokenSource: account?.tokenSource, + apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), } satisfies ClaudeCapabilitiesProbe; }); @@ -668,7 +689,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const checkedAt = DateTime.formatIso(yield* DateTime.now); const allModels = providerModelsFromSettings( BUILT_IN_MODELS, - PROVIDER, claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); @@ -759,7 +779,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const models = providerModelsFromSettings( getBuiltInClaudeModelsForVersion(parsedVersion), - PROVIDER, claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); @@ -794,10 +813,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const authMetadata = claudeAuthMetadata({ - subscriptionType: capabilities.subscriptionType, - authMethod: capabilities.tokenSource, - }); + const authMetadata = + claudeAuthMetadata({ + subscriptionType: capabilities.subscriptionType, + authMethod: capabilities.tokenSource, + }) ?? apiProviderAuthMetadata(capabilities.apiProvider); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -827,7 +847,6 @@ export const makePendingClaudeProvider = ( const checkedAt = yield* nowIso; const models = providerModelsFromSettings( BUILT_IN_MODELS, - PROVIDER, claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 6ae87b5ef246..aacbea5b1e3c 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -333,6 +333,7 @@ validationLayer("CodexAdapterLive validation", (it) => { NodeAssert.deepStrictEqual(validationRuntimeFactory.factory.mock.calls[0]?.[0], { binaryPath: "codex", cwd: process.cwd(), + launchArgs: "", model: "gpt-5.3-codex", providerInstanceId: ProviderInstanceId.make("codex"), serviceTier: "priority", @@ -462,6 +463,69 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("passes configured launch args into the session runtime", () => { + const runtimeFactory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("sess-launch-args"), + runtimeMode: "full-access", + }); + + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.equal(runtime.options.launchArgs, "--strict-config --enable foo"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("uses T3CODE_CODEX_LAUNCH_ARGS for the session runtime", () => { + const runtimeFactory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({ launchArgs: "--enable settings-feature" }); + return yield* makeCodexAdapter(codexConfig, { + environment: { T3CODE_CODEX_LAUNCH_ARGS: " --strict-config --enable env-feature " }, + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("sess-launch-args-env"), + runtimeMode: "full-access", + }); + + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.equal(runtime.options.launchArgs, "--strict-config --enable env-feature"); + }).pipe(Effect.provide(layer)); + }); + it.effect("maps codex model options for the adapter's bound custom instance id", () => { const customInstanceId = ProviderInstanceId.make("codex_personal"); const customRuntimeFactory = makeRuntimeFactory(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 2f20eb8dc659..6d2cae086189 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -69,6 +69,7 @@ import { type CodexSessionRuntimeShape, } from "./CodexSessionRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError); const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError); const isCodexSessionRuntimeThreadIdMissingError = Schema.is( @@ -364,8 +365,14 @@ function itemTitle(itemType: CanonicalItemType, item?: CodexLifecycleItem): stri } } -function itemDetail(item: CodexLifecycleItem): string | undefined { +function itemDetail(itemType: CanonicalItemType, item: CodexLifecycleItem): string | undefined { + const itemRecord = item as Record; + const action = itemRecord.action as Record | undefined; + const actionQueries = Array.isArray(action?.queries) ? action.queries : []; const candidates = [ + ...(itemType === "web_search" + ? [itemRecord.query, action?.query, ...actionQueries, action?.pattern, action?.url] + : []), "command" in item ? item.command : undefined, "title" in item ? item.title : undefined, "summary" in item ? item.summary : undefined, @@ -373,6 +380,7 @@ function itemDetail(item: CodexLifecycleItem): string | undefined { "path" in item ? item.path : undefined, "prompt" in item ? item.prompt : undefined, ]; + for (const candidate of candidates) { const trimmed = typeof candidate === "string" ? trimText(candidate) : undefined; if (!trimmed) continue; @@ -562,7 +570,7 @@ function mapItemLifecycle( return undefined; } - const detail = itemDetail(item); + const detail = itemDetail(itemType, item); const status = lifecycle === "item.started" ? "inProgress" @@ -940,7 +948,7 @@ function mapToRuntimeEvents( } const itemType = toCanonicalItemType(item.type); if (itemType === "plan") { - const detail = itemDetail(item); + const detail = itemDetail(itemType, item); if (!detail) { return []; } @@ -1563,6 +1571,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( providerInstanceId: boundInstanceId, cwd: compatibility.cwd, binaryPath: codexConfig.binaryPath, + launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), ...(options?.environment ? { environment: options.environment } : {}), ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), ...(isCodexResumeCursorSchema(input.resumeCursor) diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index a2182cfb73c5..9306087a0bc2 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -26,6 +26,7 @@ import { ServerSettingsError } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, @@ -289,6 +290,7 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { readonly binaryPath: string; readonly homePath?: string; + readonly launchArgs?: string; readonly cwd: string; readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; @@ -303,10 +305,14 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun ...input.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; - const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { - env: environment, - extendEnv: true, - }); + const spawnCommand = yield* resolveSpawnCommand( + input.binaryPath, + codexAppServerArgs(input.launchArgs), + { + env: environment, + extendEnv: true, + }, + ); const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { @@ -465,6 +471,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu probe: (input: { readonly binaryPath: string; readonly homePath?: string; + readonly launchArgs?: string; readonly cwd: string; readonly customModels: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; @@ -503,6 +510,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const probeResult = yield* probe({ binaryPath: codexSettings.binaryPath, homePath: codexSettings.homePath, + launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), cwd: process.cwd(), customModels: codexSettings.customModels, environment: resolvedEnvironment, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 9faade3158f8..ac86f1d69681 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -13,6 +13,7 @@ import { CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, } from "../CodexDeveloperInstructions.ts"; +import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, hasConfiguredMcpServer, @@ -289,6 +290,33 @@ describe("hasConfiguredMcpServer", () => { }); }); +describe("codexSessionAppServerArgs", () => { + it("keeps the app-server subcommand when explicit args are provided", () => { + NodeAssert.deepStrictEqual(codexSessionAppServerArgs(["-c", "model=gpt-5"], undefined), [ + "app-server", + "-c", + "model=gpt-5", + ]); + }); + + it("keeps launch args when explicit app-server args are provided", () => { + NodeAssert.deepStrictEqual( + codexSessionAppServerArgs( + ["-c", "mcp_servers.t3-code.url=http://127.0.0.1/mcp"], + "--strict-config --enable foo", + ), + [ + "app-server", + "--strict-config", + "--enable", + "foo", + "-c", + "mcp_servers.t3-code.url=http://127.0.0.1/mcp", + ], + ); + }); +}); + describe("isRecoverableThreadResumeError", () => { it("matches missing thread errors", () => { NodeAssert.equal( diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index a67563efa0da..7504f43236ce 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -36,6 +36,7 @@ import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexInitializeParams } from "./CodexProvider.ts"; +import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); @@ -97,6 +98,7 @@ export interface CodexSessionRuntimeOptions { readonly providerInstanceId?: ProviderInstanceId; readonly binaryPath: string; readonly homePath?: string; + readonly launchArgs?: string; readonly environment?: NodeJS.ProcessEnv; readonly cwd: string; readonly runtimeMode: RuntimeMode; @@ -718,11 +720,11 @@ export const makeCodexSessionRuntime = ( ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; const extendEnv = options.environment === undefined; - const spawnCommand = yield* resolveSpawnCommand( - options.binaryPath, - ["app-server", ...(options.appServerArgs ?? [])], - { env, extendEnv }, - ); + const appServerArgs = codexSessionAppServerArgs(options.appServerArgs, options.launchArgs); + const spawnCommand = yield* resolveSpawnCommand(options.binaryPath, appServerArgs, { + env, + extendEnv, + }); const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 063f2c1bf2ba..fee4306c4c5c 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -8,7 +8,6 @@ import type { ServerProviderModel, ServerProviderState, } from "@t3tools/contracts"; -import { ProviderDriverKind } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as Crypto from "effect/Crypto"; @@ -51,7 +50,6 @@ import { CursorListAvailableModelsResponse } from "../acp/CursorAcpExtension.ts" const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect( CursorListAvailableModelsResponse, ); -const PROVIDER = ProviderDriverKind.make("cursor"); const CURSOR_PRESENTATION = { displayName: "Cursor", badgeLabel: "Early Access", @@ -576,7 +574,7 @@ export const discoverCursorModelsViaAcp = ( export function getCursorFallbackModels( cursorSettings: Pick, ): ReadonlyArray { - return providerModelsFromSettings([], PROVIDER, cursorSettings.customModels, EMPTY_CAPABILITIES); + return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); } /** Timeout for `agent about` — it's slower than a simple `--version` probe. */ @@ -638,7 +636,6 @@ export function buildCursorProviderSnapshot(input: { checkedAt: input.checkedAt, models: providerModelsFromSettings( input.discoveredModels ?? [], - PROVIDER, input.cursorSettings.customModels, EMPTY_CAPABILITIES, ), diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 33f61ad97f6d..934eecdb5ae6 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -1,7 +1,6 @@ import { type GrokSettings, type ModelCapabilities, - ProviderDriverKind, type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; @@ -38,7 +37,6 @@ const GROK_PRESENTATION = { showInteractionModeToggle: false, requiresNewThreadForModelChange: true, } as const; -const PROVIDER = ProviderDriverKind.make("grok"); const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); @@ -98,12 +96,7 @@ function grokModelsFromSettings( customModels: ReadonlyArray | undefined, builtInModels: ReadonlyArray = GROK_BUILT_IN_MODELS, ): ReadonlyArray { - return providerModelsFromSettings( - builtInModels, - PROVIDER, - customModels ?? [], - EMPTY_CAPABILITIES, - ); + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); } function buildGrokDiscoveredModelsFromSessionModelState( diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 007922946844..573bfaaef913 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -5,8 +5,10 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -31,6 +33,8 @@ import { } from "../opencodeRuntime.ts"; import { appendOpenCodeAssistantTextDelta, + isOpenCodeNotFound, + isSameOpenCodeDirectory, makeOpenCodeAdapter, mergeOpenCodeAssistantText, } from "./OpenCodeAdapter.ts"; @@ -54,6 +58,7 @@ const runtimeMock = { state: { startCalls: [] as string[], sessionCreateUrls: [] as string[], + sessionCreateInputs: [] as Array>, authHeaders: [] as Array, abortCalls: [] as string[], closeCalls: [] as string[], @@ -64,10 +69,17 @@ const runtimeMock = { eventStreamGate: null as Promise | null, messages: [] as MessageEntry[], subscribedEvents: [] as unknown[], + sessionGetIds: [] as string[], + missingSessionIds: new Set(), + transientErrorSessionIds: new Set(), + sessionDirectoryById: new Map(), + sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, + forkCalls: [] as Array<{ sessionID: string; directory?: string }>, }, reset() { this.state.startCalls.length = 0; this.state.sessionCreateUrls.length = 0; + this.state.sessionCreateInputs.length = 0; this.state.authHeaders.length = 0; this.state.abortCalls.length = 0; this.state.closeCalls.length = 0; @@ -78,6 +90,12 @@ const runtimeMock = { this.state.eventStreamGate = null; this.state.messages = []; this.state.subscribedEvents = []; + this.state.sessionGetIds.length = 0; + this.state.missingSessionIds.clear(); + this.state.transientErrorSessionIds.clear(); + this.state.sessionDirectoryById.clear(); + this.state.sessionUpdateCalls.length = 0; + this.state.forkCalls.length = 0; }, }; @@ -102,10 +120,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { connectToOpenCodeServer: ({ serverUrl }) => Effect.gen(function* () { const url = serverUrl ?? "http://127.0.0.1:4301"; - // Unconditionally register a scope finalizer for test observability — - // preserves the `closeCalls` / `closeError` probes that the existing - // suites rely on. Production code never attaches a finalizer to an - // external server (it simply returns `Effect.succeed(...)`). + // Always register a finalizer so the closeCalls/closeError probes fire; + // production attaches none for external servers. yield* Effect.addFinalizer(() => Effect.sync(() => { runtimeMock.state.closeCalls.push(url); @@ -124,13 +140,42 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ session: { - create: async () => { + create: async (input: Record) => { runtimeMock.state.sessionCreateUrls.push(baseUrl); + runtimeMock.state.sessionCreateInputs.push(input); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); return { data: { id: `${baseUrl}/session` } }; }, + get: async ({ sessionID }: { sessionID: string }) => { + runtimeMock.state.sessionGetIds.push(sessionID); + // The real client is `throwOnError: true`: non-2xx rejects rather + // than resolving, so missing → 404 throw, transient → 500 throw. + if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) { + throw new Error("opencode server error", { cause: { status: 500 } }); + } + if (runtimeMock.state.missingSessionIds.has(sessionID)) { + throw new Error(`Session not found: ${sessionID}`, { + cause: { status: 404, body: { name: "NotFoundError" } }, + }); + } + const directory = runtimeMock.state.sessionDirectoryById.get(sessionID); + return { data: { id: sessionID, ...(directory ? { directory } : {}) } }; + }, + update: async ({ sessionID, permission }: { sessionID: string; permission: unknown }) => { + runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission }); + return { data: { id: sessionID } }; + }, + fork: async ({ sessionID, directory }: { sessionID: string; directory?: string }) => { + // Fork clones history into a new session bound to the directory. + const forkedId = `${sessionID}_fork`; + runtimeMock.state.forkCalls.push({ sessionID, ...(directory ? { directory } : {}) }); + if (directory) { + runtimeMock.state.sessionDirectoryById.set(forkedId, directory); + } + return { data: { id: forkedId, ...(directory ? { directory } : {}) } }; + }, abort: async ({ sessionID }: { sessionID: string }) => { runtimeMock.state.abortCalls.push(sessionID); }, @@ -181,6 +226,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: null, }), ), + loadInventoryFromCli: () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", + cause: null, + }), + ), }; const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { @@ -253,6 +306,256 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("returns a durable resume cursor for a freshly created session", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-cursor"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + // Without a persisted cursor, a session is created and its id is + // surfaced as a resume cursor so the upper layer can persist it. + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, []); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "http://127.0.0.1:9999/session", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("resumes the persisted OpenCode session instead of creating a new one", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-resume"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_persisted" }, + }); + + // The adapter validates the persisted id with session.get and re-adopts + // it — no new session is minted (issue #3604). + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_persisted"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_persisted", + }); + // Resume re-asserts the permission ruleset for the current runtimeMode. + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_persisted"); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.permission != null, true); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("sends follow-up turns to the resumed session id", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-resume-turn"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_persisted" }, + }); + + const result = yield* adapter.sendTurn({ + threadId, + input: "continue where we left off", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "anthropic/sonnet", + ), + }); + + // The prompt targets the resumed id, and the turn re-surfaces the cursor. + NodeAssert.deepEqual( + (runtimeMock.state.promptCalls[0] as { sessionID: string }).sessionID, + "ses_persisted", + ); + NodeAssert.deepEqual(result.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_persisted", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("falls back to a fresh session when the persisted session is gone", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-stale"); + runtimeMock.state.missingSessionIds.add("ses_stale"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_stale" }, + }); + + // get probed the stale id, found nothing, then created a new session and + // emitted a fresh cursor rather than wedging the thread. + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_stale"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "http://127.0.0.1:9999/session", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores a malformed or wrong-version resume cursor", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-badcursor"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 99, sessionId: "ses_persisted" }, + }); + + // A foreign/stale-shaped cursor is treated as "no resume": never probed, + // a fresh session is created. + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, []); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "http://127.0.0.1:9999/session", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("surfaces a non-not-found resume probe error instead of silently starting fresh", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-transient"); + // session.get returns a 500 (not a 404) for this id. + runtimeMock.state.transientErrorSessionIds.add("ses_transient"); + + const exit = yield* Effect.exit( + adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_transient" }, + }), + ); + + // A transient/transport/auth failure must propagate — NOT be masked as a + // brand-new empty session (the #3604 class of silent context loss). + NodeAssert.equal(Exit.isFailure(exit), true); + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_transient"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + }), + ); + + it.effect("re-applies the current runtimeMode permissions when resuming", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-perms"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + // A different runtimeMode than the original create — resume must not + // leave the upstream session on stale permissions. + runtimeMode: "approval-required", + threadId, + resumeCursor: { schemaVersion: 1, sessionId: "ses_perms" }, + }); + + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_perms"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_perms"); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.permission != null, true); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "forks the resumed session into the requested directory instead of losing context", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-cwd"); + // The persisted session still exists but was created in another working dir + // (e.g. the thread moved from the project root into a git worktree). + runtimeMock.state.sessionDirectoryById.set("ses_otherdir", "/some/other/worktree"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_otherdir" }, + }); + + // A cwd change must not mint an empty session: the adapter forks the + // persisted session into the requested cwd, carrying history forward. + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_otherdir"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.equal(runtimeMock.state.forkCalls.length, 1); + NodeAssert.equal(runtimeMock.state.forkCalls[0]?.sessionID, "ses_otherdir"); + NodeAssert.equal(typeof runtimeMock.state.forkCalls[0]?.directory, "string"); + // Permission ruleset re-asserted on the fork for the current runtimeMode. + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_otherdir_fork"); + // Durable cursor now points at the history-complete fork in the new directory. + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_otherdir_fork", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reuses the resumed session when the stored directory differs only lexically", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-samedir"); + // Same working tree, different spelling (trailing slash) — must reuse, + // not fork. + runtimeMock.state.sessionDirectoryById.set("ses_samedir", `${process.cwd()}/`); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_samedir" }, + }); + + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_samedir"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.deepEqual(runtimeMock.state.forkCalls, []); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_samedir", + }); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("fails sendTurn for missing sessions through the typed error channel", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -738,6 +1041,88 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("classifies a confirmed not-found across the shapes the SDK/runtime can produce", () => + Effect.sync(() => { + // The real production shape: runOpenCodeSdk wraps the thrown Error + // (cause = { body, status }) under OpenCodeRuntimeError. + const wrappedError = new Error("Session not found: ses_x", { + cause: { body: { name: "NotFoundError" }, status: 404 }, + }); + NodeAssert.equal( + isOpenCodeNotFound({ + _tag: "OpenCodeRuntimeError", + operation: "session.get", + detail: "Session not found: ses_x", + cause: wrappedError, + }), + true, + ); + + // 404 expressed only via response.status (the bot's flagged shape). + NodeAssert.equal(isOpenCodeNotFound({ cause: { response: { status: 404 } } }), true); + // 404 via a bare numeric status / statusCode. + NodeAssert.equal(isOpenCodeNotFound(new Error("x", { cause: { status: 404 } })), true); + NodeAssert.equal(isOpenCodeNotFound({ statusCode: 404 }), true); + // OpenCode NotFoundError body name with no status. + NodeAssert.equal(isOpenCodeNotFound({ body: { name: "NotFoundError" } }), true); + + // NOT a miss: only structured signals count, never free text. A non-404 + // error whose message/detail merely contains "not found" must propagate, + // not be misread as a missing session and silently start fresh. + NodeAssert.equal( + isOpenCodeNotFound(new Error("upstream provider not found", { cause: { status: 500 } })), + false, + ); + NodeAssert.equal(isOpenCodeNotFound({ detail: "status=500 body={...not found...}" }), false); + // An explicit non-404 status seals its subtree: a 500 whose serialized + // body echoes a NotFoundError name — or that is itself named + // *NotFound* — is a real failure, never a miss. + NodeAssert.equal(isOpenCodeNotFound({ status: 500, body: { name: "NotFoundError" } }), false); + NodeAssert.equal(isOpenCodeNotFound({ name: "UpstreamNotFoundError", status: 500 }), false); + // A "NotFound"-flavored name that isn't OpenCode's exact `NotFoundError` + // is not a confirmed miss even without a sealing status. + NodeAssert.equal(isOpenCodeNotFound({ name: "UpstreamNotFoundError" }), false); + NodeAssert.equal(isOpenCodeNotFound({ cause: { name: "ProviderNotFoundError" } }), false); + NodeAssert.equal( + isOpenCodeNotFound( + new Error("x", { cause: { status: 502, body: { name: "NotFoundError" } } }), + ), + false, + ); + // Other transient/auth/network failures must propagate too. + NodeAssert.equal(isOpenCodeNotFound(new Error("boom", { cause: { status: 500 } })), false); + NodeAssert.equal(isOpenCodeNotFound({ cause: { response: { status: 401 } } }), false); + NodeAssert.equal(isOpenCodeNotFound(new Error("network error (no response)")), false); + NodeAssert.equal(isOpenCodeNotFound(undefined), false); + }), + ); + + it.effect("treats lexically or physically identical directories as the same", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sameDirectory = (left: string, right: string) => + isSameOpenCodeDirectory(fileSystem, path, left, right); + + // Lexical-only differences (trailing slash, dot segments) short-circuit + // without touching the filesystem — the paths need not exist. + NodeAssert.equal(yield* sameDirectory("/repo/project/", "/repo/project"), true); + NodeAssert.equal(yield* sameDirectory("/repo/nested/../project", "/repo/project"), true); + // Nonexistent paths degrade to the lexical comparison instead of failing. + NodeAssert.equal(yield* sameDirectory("/repo/project", "/repo/other"), false); + + // A symlinked cwd (the macOS `/tmp` → `/private/tmp` shape) resolves to + // the directory it points at, so the two spellings compare equal. + const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode-dir-" }); + const real = path.join(base, "real"); + const link = path.join(base, "link"); + yield* fileSystem.makeDirectory(real); + yield* fileSystem.symlink(real, link); + NodeAssert.equal(yield* sameDirectory(link, real), true); + NodeAssert.equal(yield* sameDirectory(link, path.join(base, "other")), false); + }).pipe(Effect.scoped), + ); + it.effect("appends raw assistant text deltas and reconciles part update snapshots", () => Effect.sync(() => { const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); @@ -833,6 +1218,47 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("lets OpenCode own session title generation and emits title metadata updates", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-title-sync"); + runtimeMock.state.subscribedEvents = [ + { + type: "session.updated", + properties: { + info: { + id: "http://127.0.0.1:9999/session", + title: "Investigate OpenCode title sync", + }, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.equal(runtimeMock.state.sessionCreateInputs.length, 1); + NodeAssert.equal("title" in (runtimeMock.state.sessionCreateInputs[0] ?? {}), false); + + const metadataUpdated = events.find((event) => event.type === "thread.metadata.updated"); + NodeAssert.ok(metadataUpdated); + if (metadataUpdated.type === "thread.metadata.updated") { + NodeAssert.equal(metadataUpdated.payload.name, "Investigate OpenCode title sync"); + } + }), + ); + it.effect("writes provider-native observability records using the session thread id", () => Effect.gen(function* () { const nativeEvents: Array<{ diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8d3f5529e587..cbc55a0d594f 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -17,6 +17,8 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; @@ -75,6 +77,114 @@ function divergentOpenCodeEffectiveModel( : undefined; } +/** + * Version tag stamped into the OpenCode resume cursor. Bump if the cursor + * shape changes so stale-shaped cursors written by older builds are ignored + * rather than misread (mirrors GROK_RESUME_VERSION / CURSOR_RESUME_VERSION). + */ +const OPENCODE_RESUME_VERSION = 1 as const; + +/** + * Decode a persisted resume cursor into the upstream `ses_…` id. Anything + * that isn't a current-version cursor with a non-empty id means "no resume" + * rather than an error. Re-adopting the session id IS the resume mechanism — + * OpenCode scopes a conversation's history by session id. + */ +function parseOpenCodeResume(raw: unknown): { readonly sessionId: string } | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return undefined; + } + const record = raw as Record; + if (record.schemaVersion !== OPENCODE_RESUME_VERSION) { + return undefined; + } + if (typeof record.sessionId !== "string" || record.sessionId.trim().length === 0) { + return undefined; + } + return { sessionId: record.sessionId.trim() }; +} + +/** + * Whether an error definitively reports a missing session. Only a confirmed + * miss may silently start a fresh session; any other failure (the SDK client + * is `throwOnError: true`, so `session.get` rejects on every non-2xx) must + * propagate, or a transient blip resets a live thread to an empty one — the + * #3604 silent context loss. Decides on structured signals only, never free + * text: a numeric 404 or the exact `NotFoundError` name, found via a bounded walk + * over `cause`/`body`/`error`/`data`. An explicit non-404 status seals its + * subtree so a wrapped "NotFound" name can't reclassify a real failure. + * Exported for unit testing. + */ +export function isOpenCodeNotFound(cause: unknown): boolean { + const seen = new Set(); + const queue: Array = [cause]; + for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) { + const node = queue.shift(); + if (node === null || typeof node !== "object" || seen.has(node)) { + continue; + } + seen.add(node); + const record = node as Record; + + const response = record.response; + const statuses = [ + record.status, + record.statusCode, + response !== null && typeof response === "object" + ? (response as { readonly status?: unknown }).status + : undefined, + ].filter((status): status is number => typeof status === "number"); + if (statuses.includes(404)) { + return true; + } + if (statuses.length > 0) { + continue; + } + + const name = record.name; + if (typeof name === "string" && name.toLowerCase() === "notfounderror") { + return true; + } + + for (const key of ["cause", "body", "error", "data"] as const) { + if (record[key] !== undefined) { + queue.push(record[key]); + } + } + } + return false; +} + +/** + * Whether two directory spellings name the same location. Raw string + * equality misreads a trailing slash, `.`/`..` segment, or symlinked cwd + * (macOS `/tmp` → `/private/tmp`) as a cwd change, needlessly forking the + * session on every resume. Lexically equal paths short-circuit; otherwise + * both sides go through `realPath`, each falling back to its lexical form + * on failure (deleted directory, external-server path) — so the probe can + * only widen matches, never split them. Takes the services as arguments so + * adapter methods stay service-free. Exported for unit testing. + */ +export function isSameOpenCodeDirectory( + fileSystem: FileSystem.FileSystem, + path: Path.Path, + left: string, + right: string, +): Effect.Effect { + const lexicalLeft = path.resolve(left); + const lexicalRight = path.resolve(right); + if (lexicalLeft === lexicalRight) { + return Effect.succeed(true); + } + const canonicalize = (lexical: string) => + fileSystem.realPath(lexical).pipe(Effect.orElseSucceed(() => lexical)); + return Effect.zipWith( + canonicalize(lexicalLeft), + canonicalize(lexicalRight), + (canonicalLeft, canonicalRight) => canonicalLeft === canonicalRight, + ); +} + interface OpenCodeTurnSnapshot { readonly id: TurnId; readonly items: Array; @@ -87,6 +197,35 @@ type OpenCodeSubscribedEvent = ? TEvent : never; +function trimText(value: string | undefined | null): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function openCodeEventSessionId(event: OpenCodeSubscribedEvent): string | undefined { + const properties = "properties" in event ? event.properties : undefined; + if (!properties || typeof properties !== "object") { + return undefined; + } + + const sessionID = (properties as { readonly sessionID?: unknown }).sessionID; + const sessionIDFromProperties = typeof sessionID === "string" ? sessionID : undefined; + if (sessionIDFromProperties) { + return sessionIDFromProperties; + } + + const info = (properties as { readonly info?: { readonly id?: unknown } }).info; + return info && typeof info.id === "string" ? info.id : undefined; +} + +function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | undefined { + if (event.type !== "session.updated") { + return undefined; + } + + return trimText(event.properties.info.title); +} + interface OpenCodeSessionContext { session: ProviderSession; readonly client: OpencodeClient; @@ -455,6 +594,10 @@ export function makeOpenCodeAdapter( const serverConfig = yield* ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime; const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sameDirectory = (left: string, right: string) => + isSameOpenCodeDirectory(fileSystem, path, left, right); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -671,8 +814,7 @@ export function makeOpenCodeAdapter( context: OpenCodeSessionContext, event: OpenCodeSubscribedEvent, ) { - const payloadSessionId = - "properties" in event ? (event.properties as { sessionID?: unknown }).sessionID : undefined; + const payloadSessionId = openCodeEventSessionId(event); if (payloadSessionId !== context.openCodeSessionId) { return; } @@ -691,6 +833,26 @@ export function makeOpenCodeAdapter( }); switch (event.type) { + case "session.updated": { + const title = openCodeEventSessionTitle(event); + if (title) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + raw: event, + })), + type: "thread.metadata.updated", + payload: { + name: title, + metadata: { + sessionID: context.openCodeSessionId, + }, + }, + }); + } + break; + } + case "message.updated": { context.messageRoleById.set(event.properties.info.id, event.properties.info.role); if (event.properties.info.role === "assistant") { @@ -1082,6 +1244,7 @@ export function makeOpenCodeAdapter( const serverPassword = openCodeSettings.serverPassword; const directory = input.cwd ?? serverConfig.cwd; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId; const existing = sessions.get(input.threadId); if (existing) { yield* stopOpenCodeContext(existing); @@ -1120,23 +1283,96 @@ export function makeOpenCodeAdapter( }), ); } - const openCodeSession = yield* runOpenCodeSdk("session.create", () => - client.session.create({ - title: `T3 Code ${input.threadId}`, - permission: buildOpenCodePermissionRules(input.runtimeMode), - }), - ); - if (!openCodeSession.data) { - return yield* new OpenCodeRuntimeError({ - operation: "session.create", - detail: "OpenCode session.create returned no session payload.", - }); - } + // Resume: re-adopt the session named by the durable cursor — + // OpenCode scopes history by session id. The probe recovers only + // a confirmed not-found (start fresh); transport/auth/server + // errors propagate instead of masking as a new empty session. + const resolved = yield* Effect.gen(function* () { + const adopted = resumeSessionId + ? yield* runOpenCodeSdk("session.get", () => + client.session.get({ sessionID: resumeSessionId }), + ).pipe( + Effect.map((response) => response.data), + Effect.catchIf( + (cause) => isOpenCodeNotFound(cause), + () => Effect.void, + ), + ) + : undefined; + + // Reuse in place only when the session still matches the + // requested cwd; on a cwd change it is forked below instead. + const reusable = + adopted && + (!adopted.directory || (yield* sameDirectory(adopted.directory, directory))) + ? adopted + : undefined; + + if (reusable) { + // Resume skips `session.create`, so re-assert the ruleset — + // a runtime-mode change would otherwise leave the session on + // its original permissions. + yield* runOpenCodeSdk("session.update", () => + client.session.update({ + sessionID: reusable.id, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + ); + return { openCodeSession: reusable, created: false }; + } + + // The session lives under a different cwd (e.g. the thread + // moved into a git worktree). Fork it into the requested + // directory instead of minting an empty one — the fork carries + // the full history, so the follow-up keeps its context (#3604). + if (adopted) { + yield* Effect.logInfo( + `OpenCode session '${adopted.id}' was created under a different working directory; forking into '${directory}' to preserve conversation history.`, + ); + const forkedSession = yield* runOpenCodeSdk("session.fork", () => + client.session.fork({ sessionID: adopted.id, directory }), + ); + const forked = forkedSession.data; + if (!forked) { + return yield* new OpenCodeRuntimeError({ + operation: "session.fork", + detail: "OpenCode session.fork returned no session payload.", + }); + } + yield* runOpenCodeSdk("session.update", () => + client.session.update({ + sessionID: forked.id, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + ); + return { openCodeSession: forked, created: true }; + } + + if (resumeSessionId) { + yield* Effect.logWarning( + `OpenCode session '${resumeSessionId}' no longer exists; starting a fresh session.`, + ); + } + const createdSession = yield* runOpenCodeSdk("session.create", () => + client.session.create({ + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + ); + if (!createdSession.data) { + return yield* new OpenCodeRuntimeError({ + operation: "session.create", + detail: "OpenCode session.create returned no session payload.", + }); + } + return { openCodeSession: createdSession.data, created: true }; + }); + return { sessionScope, server, client, - openCodeSession: openCodeSession.data, + openCodeSession: resolved.openCodeSession, + created: resolved.created, }; }).pipe(Effect.provideService(Scope.Scope, sessionScope)), ); @@ -1151,13 +1387,16 @@ export function makeOpenCodeAdapter( // and already inserted a session while we were awaiting async work. const raceWinner = sessions.get(input.threadId); if (raceWinner) { - // Another call won the race – clean up the session we just created - // (including the remote SDK session) and return the existing one. - yield* runOpenCodeSdk("session.abort", () => - started.client.session.abort({ - sessionID: started.openCodeSession.id, - }), - ).pipe(Effect.ignore); + // Another call won the race — clean up. Only abort the remote + // session if we created it here; a resumed one is shared upstream + // state the winner is now using. + if (started.created) { + yield* runOpenCodeSdk("session.abort", () => + started.client.session.abort({ + sessionID: started.openCodeSession.id, + }), + ).pipe(Effect.ignore); + } yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); return raceWinner.session; } @@ -1171,6 +1410,13 @@ export function makeOpenCodeAdapter( cwd: directory, ...(input.modelSelection ? { model: input.modelSelection.model } : {}), threadId: input.threadId, + // ProviderService persists this cursor and feeds it back into + // `startSession` after the in-memory session is lost (reaper / + // restart), so follow-ups continue the same conversation (#3604). + resumeCursor: { + schemaVersion: OPENCODE_RESUME_VERSION, + sessionId: started.openCodeSession.id, + }, createdAt, updatedAt: createdAt, }; @@ -1348,6 +1594,11 @@ export function makeOpenCodeAdapter( return { threadId: input.threadId, turnId, + // Re-surface the durable cursor on every turn so the persisted binding + // is refreshed alongside last-seen/runtime state (mirrors Grok/Codex). + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), }; }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index b0e785512dc4..05160517bfeb 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -95,6 +95,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }), ) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), + loadInventoryFromCli: () => + runtimeMock.state.inventoryError + ? Effect.succeed({ + providerList: { all: [], default: {}, connected: [] as string[] }, + agents: [], + } as OpenCodeInventory) + : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), }; beforeEach(() => { @@ -197,11 +204,22 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); - it.effect("closes the local OpenCode server scope after provider refresh", () => + it.effect("does not spawn a local server for health check (uses CLI instead)", () => Effect.gen(function* () { yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - NodeAssert.equal(runtimeMock.state.closeCalls, 1); + NodeAssert.equal(runtimeMock.state.closeCalls, 0); + }), + ); + + it.effect("degrades gracefully on CLI failure for local installs", () => + Effect.gen(function* () { + runtimeMock.state.inventoryError = new Error("opencode models failed"); + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + + NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.models.length, 0); }), ); }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index a8285e960fc1..21014e33f08b 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -1,5 +1,4 @@ import { - ProviderDriverKind, type ModelCapabilities, type OpenCodeSettings, type ServerProviderModel, @@ -25,7 +24,6 @@ import { } from "../opencodeRuntime.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; -const PROVIDER = ProviderDriverKind.make("opencode"); const OPENCODE_PRESENTATION = { displayName: "OpenCode", showInteractionModeToggle: false, @@ -259,7 +257,6 @@ export const makePendingOpenCodeProvider = ( const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); const models = providerModelsFromSettings( [], - PROVIDER, openCodeSettings.customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); @@ -319,12 +316,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, checkedAt, - models: providerModelsFromSettings( - [], - PROVIDER, - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), probe: { installed: failure.installed, version, @@ -340,12 +332,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu presentation: OPENCODE_PRESENTATION, enabled: false, checkedAt, - models: providerModelsFromSettings( - [], - PROVIDER, - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), probe: { installed: false, version: null, @@ -391,12 +378,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, checkedAt, - models: providerModelsFromSettings( - [], - PROVIDER, - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), probe: { installed: true, version, @@ -409,26 +391,32 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu } const inventoryExit = yield* Effect.exit( - Effect.scoped( - Effect.gen(function* () { - const server = yield* openCodeRuntime.connectToOpenCodeServer({ + (isExternalServer + ? Effect.scoped( + Effect.gen(function* () { + const server = yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath: openCodeSettings.binaryPath, + serverUrl: openCodeSettings.serverUrl, + environment: resolvedEnvironment, + }); + return yield* openCodeRuntime.loadOpenCodeInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }), + ); + }), + ) + : openCodeRuntime.loadInventoryFromCli({ binaryPath: openCodeSettings.binaryPath, - serverUrl: openCodeSettings.serverUrl, environment: resolvedEnvironment, - }); - return yield* openCodeRuntime.loadOpenCodeInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(isExternalServer && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }), - ); - }).pipe( - Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), + }) + ).pipe( + Effect.mapError( + (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), ), ), ); @@ -438,7 +426,6 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu const models = providerModelsFromSettings( flattenOpenCodeModels(inventoryExit.value), - PROVIDER, customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 73390450efab..384de852f9b5 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -61,6 +61,7 @@ const makeCodexConfig = (overrides: Partial): CodexSettings => ({ binaryPath: "codex", homePath: "", shadowHomePath: "", + launchArgs: "", customModels: [], ...overrides, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5456a90bdf51..159d853121c3 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -55,6 +55,7 @@ const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTING const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); +const decodeCodexSettings = Schema.decodeSync(CodexSettings); const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ enabled: false, }); @@ -100,6 +101,7 @@ type TestClaudeCapabilities = { readonly email: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; + readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; }; @@ -109,6 +111,7 @@ function claudeCapabilities(overrides: Partial = {}) { email: undefined, subscriptionType: undefined, tokenSource: undefined, + apiProvider: undefined, slashCommands: [], ...overrides, }); @@ -346,6 +349,21 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("passes configured launch args to the Codex provider probe", () => + Effect.gen(function* () { + let observedLaunchArgs: string | undefined; + const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); + + const status = yield* checkCodexProviderStatus(settings, (input) => { + observedLaunchArgs = input.launchArgs; + return Effect.succeed(makeCodexProbeSnapshot()); + }); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(observedLaunchArgs, "--strict-config --enable foo"); + }), + ); + it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => @@ -1492,6 +1510,30 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => + Effect.gen(function* () { + // Bedrock authenticates via external AWS credentials, so the SDK init + // reports only `apiProvider` with no subscription or token. + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ apiProvider: "bedrock" }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "bedrock"); + assert.strictEqual(status.auth.label, "Amazon Bedrock"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\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( diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index e1b28dd968bf..4fbd611e37ce 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -307,6 +307,7 @@ describe("ProviderSessionReaper", () => { readEvents: () => Stream.empty, dispatch, streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), }), ), Layer.provideMerge( diff --git a/apps/server/src/provider/Layers/codexLaunchArgs.test.ts b/apps/server/src/provider/Layers/codexLaunchArgs.test.ts new file mode 100644 index 000000000000..115ac28eaf94 --- /dev/null +++ b/apps/server/src/provider/Layers/codexLaunchArgs.test.ts @@ -0,0 +1,59 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { + codexAppServerArgs, + codexExecLaunchArgs, + resolveCodexLaunchArgs, +} from "./codexLaunchArgs.ts"; + +describe("resolveCodexLaunchArgs", () => { + it("uses T3CODE_CODEX_LAUNCH_ARGS before configured settings", () => { + NodeAssert.equal( + resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: "--enable foo" }), + "--enable foo", + ); + }); + + it("uses configured settings when T3CODE_CODEX_LAUNCH_ARGS is empty", () => { + NodeAssert.equal( + resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: " " }), + "--strict-config", + ); + }); + + it("ignores whitespace-only environment values", () => { + NodeAssert.equal(resolveCodexLaunchArgs("", { T3CODE_CODEX_LAUNCH_ARGS: " " }), ""); + }); +}); + +describe("codexAppServerArgs", () => { + it("returns the app-server command for empty launch args", () => { + NodeAssert.deepStrictEqual(codexAppServerArgs(""), ["app-server"]); + }); + + it("appends parsed launch args after app-server", () => { + NodeAssert.deepStrictEqual(codexAppServerArgs("--strict-config --enable foo"), [ + "app-server", + "--strict-config", + "--enable", + "foo", + ]); + }); +}); + +describe("codexExecLaunchArgs", () => { + it("keeps shared codex flags and omits app-server-only flags", () => { + NodeAssert.deepStrictEqual( + codexExecLaunchArgs('--strict-config --enable foo --listen off --config model="gpt 5"'), + ["--strict-config", "--enable", "foo", "--config", "model=gpt 5"], + ); + }); + + it("does not pair value-taking flags with adjacent flags", () => { + NodeAssert.deepStrictEqual(codexExecLaunchArgs("--config --strict-config --enable --disable"), [ + "--strict-config", + ]); + }); +}); diff --git a/apps/server/src/provider/Layers/codexLaunchArgs.ts b/apps/server/src/provider/Layers/codexLaunchArgs.ts new file mode 100644 index 000000000000..771a4f0b6ed6 --- /dev/null +++ b/apps/server/src/provider/Layers/codexLaunchArgs.ts @@ -0,0 +1,48 @@ +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; + +export const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS"; + +export const resolveCodexLaunchArgs = ( + launchArgs?: string, + environment: NodeJS.ProcessEnv = process.env, +) => environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || ""; + +export const codexLaunchArgv = (launchArgs?: string): ReadonlyArray => + tokenizeCliArgs(launchArgs); + +export const codexAppServerArgs = (launchArgs?: string) => [ + "app-server", + ...codexLaunchArgv(launchArgs), +]; + +export const codexExecLaunchArgs = (launchArgs?: string) => { + const args = codexLaunchArgv(launchArgs); + const execArgs: Array = []; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === undefined) continue; + + if (arg === "--strict-config" || arg.startsWith("--config=") || arg.startsWith("-c=")) { + execArgs.push(arg); + } else if (arg === "--config" || arg === "-c" || arg === "--enable" || arg === "--disable") { + const value = args[index + 1]; + if (value !== undefined && !value.startsWith("-")) { + execArgs.push(arg, value); + index++; + } + } else if (arg.startsWith("--enable=") || arg.startsWith("--disable=")) { + execArgs.push(arg); + } + } + + return execArgs; +}; + +export const codexSessionAppServerArgs = ( + appServerArgs: ReadonlyArray | undefined, + launchArgs: string | undefined, +) => { + const launchAppServerArgs = codexAppServerArgs(launchArgs); + return appServerArgs ? [...launchAppServerArgs, ...appServerArgs] : launchAppServerArgs; +}; diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts new file mode 100644 index 000000000000..6208f04507e7 --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -0,0 +1,229 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { parseModelsCliOutput, parseAgentListCliOutput } from "./opencodeRuntime.ts"; + +describe("parseModelsCliOutput", () => { + it("parses a single model from a single provider", () => { + const stdout = [ + "anthropic/claude-sonnet-4-5", + JSON.stringify({ + id: "claude-sonnet-4-5", + providerID: "anthropic", + name: "Claude Sonnet 4.5", + capabilities: { temperature: true, reasoning: true, toolcall: true }, + cost: { input: 3, output: 15 }, + limit: { context: 200000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + release_date: "2025-01-01", + }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + NodeAssert.equal(result.connected.length, 1); + NodeAssert.equal(result.connected[0], "anthropic"); + + const provider = result.providers.get("anthropic")!; + NodeAssert.ok(provider); + NodeAssert.equal(provider.id, "anthropic"); + NodeAssert.equal(provider.name, "anthropic"); + NodeAssert.equal(Object.keys(provider.models).length, 1); + + const model = provider.models["claude-sonnet-4-5"]!; + NodeAssert.ok(model); + NodeAssert.equal(model.id, "claude-sonnet-4-5"); + NodeAssert.equal(model.providerID, "anthropic"); + NodeAssert.equal(model.name, "Claude Sonnet 4.5"); + }); + + it("parses multiple models from multiple providers", () => { + const stdout = [ + "anthropic/claude-sonnet-4-5", + JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet 4.5" }), + "anthropic/claude-haiku-4-5", + JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), + "openai/gpt-4o", + JSON.stringify({ id: "gpt-4o", providerID: "openai", name: "GPT-4o" }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 2); + NodeAssert.equal(result.connected.length, 2); + NodeAssert.equal([...result.connected].sort().join(","), "anthropic,openai"); + NodeAssert.equal(Object.keys(result.providers.get("anthropic")!.models).length, 2); + NodeAssert.equal(Object.keys(result.providers.get("openai")!.models).length, 1); + }); + + it("handles empty input", () => { + const result = parseModelsCliOutput(""); + NodeAssert.equal(result.providers.size, 0); + NodeAssert.equal(result.connected.length, 0); + }); + + it("skips unparseable JSON blocks", () => { + const stdout = [ + "anthropic/claude-sonnet-4-5", + "this is not valid json {{{", + "anthropic/claude-haiku-4-5", + JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + const provider = result.providers.get("anthropic")!; + NodeAssert.equal(Object.keys(provider.models).length, 1); + NodeAssert.ok(provider.models["claude-haiku-4-5"]); + }); + + it("handles Windows-style CRLF line endings", () => { + const stdout = + "anthropic/claude-sonnet-4-5\r\n" + + JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }) + + "\r\n"; + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]); + }); + + it("handles model JSON with variants and nested fields", () => { + const stdout = [ + "opencode/gpt-5.4", + JSON.stringify({ + id: "gpt-5.4", + providerID: "opencode", + name: "GPT-5.4", + family: "gpt", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200000, input: 160000, output: 32000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2025-01-01", + variants: { none: {}, low: {}, medium: {}, high: {} }, + }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + const model = result.providers.get("opencode")!.models["gpt-5.4"]!; + NodeAssert.ok(model); + NodeAssert.ok(model.capabilities); + NodeAssert.equal(model.capabilities!.reasoning, true); + NodeAssert.ok(model.variants); + NodeAssert.equal(model.variants!["medium"] !== undefined, true); + }); +}); + +describe("parseAgentListCliOutput", () => { + it("parses a single agent", () => { + const stdout = [ + "build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.name, "build"); + NodeAssert.equal(result[0]!.mode, "primary"); + NodeAssert.equal(result[0]!.permission.length, 1); + }); + + it("parses multiple agents", () => { + const stdout = [ + "build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + "explore (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + "plan (primary)", + " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.md" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 3); + NodeAssert.equal(result[0]!.name, "build"); + NodeAssert.equal(result[0]!.mode, "primary"); + NodeAssert.equal(result[1]!.name, "explore"); + NodeAssert.equal(result[1]!.mode, "subagent"); + NodeAssert.equal(result[2]!.name, "plan"); + NodeAssert.equal(result[2]!.mode, "primary"); + }); + + it("handles empty input", () => { + const result = parseAgentListCliOutput(""); + NodeAssert.equal(result.length, 0); + }); + + it("skips agents with unparseable permission JSON", () => { + const stdout = [ + "build (primary)", + " not valid json {", + "explore (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.name, "explore"); + }); + + it("handles real-world permission blocks with nested paths", () => { + const permissions = [ + { permission: "*", action: "allow", pattern: "*" }, + { + permission: "external_directory", + pattern: "C:\\Users\\test\\.local\\*", + action: "allow", + }, + { permission: "read", pattern: "*.env", action: "ask" }, + ]; + const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.permission.length, 3); + NodeAssert.equal(result[0]!.permission[0]!.action, "allow"); + NodeAssert.equal(result[0]!.permission[2]!.action, "ask"); + }); + + it("handles agent names with spaces", () => { + const stdout = [ + "code reviewer (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + "my custom agent (primary)", + " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.ts" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 2); + NodeAssert.equal(result[0]!.name, "code reviewer"); + NodeAssert.equal(result[0]!.mode, "subagent"); + NodeAssert.equal(result[1]!.name, "my custom agent"); + NodeAssert.equal(result[1]!.mode, "primary"); + }); + + it("marks known hidden agents", () => { + const stdout = [ + "compaction (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + "build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result[0]!.hidden, true); + NodeAssert.equal(result[1]!.hidden, false); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 46d0b3019bd6..b853662b037d 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -5,6 +5,7 @@ import { createOpencodeClient, type Agent, type FilePartInput, + type Model, type OpencodeClient, type PermissionRuleset, type ProviderListResponse, @@ -147,6 +148,10 @@ export interface OpenCodeRuntimeShape { readonly loadOpenCodeInventory: ( client: OpencodeClient, ) => Effect.Effect; + readonly loadInventoryFromCli: (input: { + readonly binaryPath: string; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect; } function parseServerUrlFromOutput(output: string): string | null { @@ -160,6 +165,113 @@ function parseServerUrlFromOutput(output: string): string | null { return null; } +const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; +const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; + +// Agents that are always hidden in OpenCode but the CLI "agent list" command +// does not expose the hidden flag. Keep in sync with OpenCode agent +// definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). +const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); + +/** @internal */ +export function parseModelsCliOutput(stdout: string): { + readonly providers: ReadonlyMap< + string, + { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } + >; + readonly connected: ReadonlyArray; +} { + const providers = new Map< + string, + { id: string; name: string; models: { [key: string]: Model } } + >(); + const lines = stdout.split("\n"); + let currentSlug: string | null = null; + const jsonLines: Array = []; + + const flushModel = () => { + if (currentSlug !== null && jsonLines.length > 0) { + const jsonStr = jsonLines.join("\n").trim(); + if (jsonStr.length > 0) { + try { + const model = JSON.parse(jsonStr) as Model; + const separator = currentSlug.indexOf("/"); + if (separator > 0) { + const providerID = currentSlug.slice(0, separator); + const modelID = currentSlug.slice(separator + 1); + let provider = providers.get(providerID); + if (!provider) { + provider = { id: providerID, name: providerID, models: {} }; + providers.set(providerID, provider); + } + provider.models[modelID] = model; + } + } catch { + // Skip unparseable model JSON + } + } + } + currentSlug = null; + jsonLines.length = 0; + }; + + for (const line of lines) { + const slugMatch = SLUG_LINE_RE.exec(line); + if (slugMatch) { + flushModel(); + currentSlug = slugMatch[1]!; + } else if (currentSlug !== null) { + jsonLines.push(line); + } + } + flushModel(); + + return { providers, connected: [...providers.keys()] }; +} + +/** @internal */ +export function parseAgentListCliOutput(stdout: string): ReadonlyArray { + const agents: Array = []; + const lines = stdout.split("\n"); + let currentHeader: { name: string; mode: string } | null = null; + const blockLines: Array = []; + + const flushAgent = () => { + if (currentHeader !== null) { + const jsonStr = blockLines.join("\n").trim(); + if (jsonStr.length > 0) { + try { + const permission = JSON.parse(jsonStr); + agents.push({ + name: currentHeader.name, + mode: currentHeader.mode as Agent["mode"], + hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name), + permission, + options: {}, + }); + } catch { + // Skip unparseable agent + } + } + } + currentHeader = null; + blockLines.length = 0; + }; + + for (const line of lines) { + const match = AGENT_HEADER_RE.exec(line); + if (match) { + flushAgent(); + currentHeader = { name: match[1]!, mode: match[2]! }; + } else if (currentHeader !== null) { + blockLines.push(line); + } + } + flushAgent(); + + return agents; +} + export function parseOpenCodeModelSlug( slug: string | null | undefined, ): ParsedOpenCodeModelSlug | null { @@ -542,12 +654,76 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.map(([providerList, agents]) => ({ providerList, agents })), ); + const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => + Effect.gen(function* () { + const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); + + const runModelsCli = () => + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["models", "--verbose"], + ...env, + }).pipe(Effect.exit); + const runAgentsCli = () => + runOpenCodeCommand({ binaryPath: input.binaryPath, args: ["agent", "list"], ...env }).pipe( + Effect.exit, + ); + + // First attempt — run both in parallel + let [modelsResult, agentsResult] = yield* Effect.all([runModelsCli(), runAgentsCli()], { + concurrency: "unbounded", + }); + + // Retry once after 1s on transient failures (e.g. SQLite "database is locked") + const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; + const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; + if (needsModelsRetry || needsAgentsRetry) { + yield* Effect.sleep("1 second"); + const [m2, a2] = yield* Effect.all( + [ + needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), + needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), + ], + { concurrency: "unbounded" }, + ); + modelsResult = m2; + agentsResult = a2; + } + + // Degrade gracefully on failure — return empty inventory (warning status, not error) + let connected: string[] = []; + let allProviders: ProviderListResponse["all"] = []; + if (modelsResult._tag === "Success" && modelsResult.value.code === 0) { + const parsed = parseModelsCliOutput(modelsResult.value.stdout); + connected = [...parsed.connected]; + allProviders = [...parsed.providers.values()].map((p) => ({ + id: p.id, + name: p.name, + source: "config" as const, + env: [], + options: {}, + models: p.models, + })); + } + + let agents: ReadonlyArray = []; + if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { + agents = parseAgentListCliOutput(agentsResult.value.stdout); + } + + return { + providerList: { all: allProviders, default: {}, connected }, + agents, + }; + }); + return { startOpenCodeServerProcess, connectToOpenCodeServer, runOpenCodeCommand, createOpenCodeSdkClient, loadOpenCodeInventory, + loadInventoryFromCli, } satisfies OpenCodeRuntimeShape; }); diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index abe138fdfb94..011572780666 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; +import type { ModelCapabilities } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; @@ -38,7 +38,6 @@ describe("providerModelsFromSettings", () => { it("applies the provided capabilities to custom models", () => { const models = providerModelsFromSettings( [], - ProviderDriverKind.make("opencode"), ["openai/gpt-5"], OPENCODE_CUSTOM_MODEL_CAPABILITIES, ); @@ -52,6 +51,25 @@ describe("providerModelsFromSettings", () => { }, ]); }); + + it("preserves a custom slug that collides with a provider alias", () => { + const capabilities = createModelCapabilities({ optionDescriptors: [] }); + const models = providerModelsFromSettings( + [ + { + slug: "claude-opus-4-8", + name: "Claude Opus 4.8", + isCustom: false, + capabilities, + }, + ], + [" opus "], + capabilities, + ); + + expect(models.map((model) => model.slug)).toEqual(["claude-opus-4-8", "opus"]); + expect(models[1]?.isCustom).toBe(true); + }); }); describe("ProviderCommandNotFoundError", () => { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index dfe31ffdc442..e741a7a2c1d0 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -13,7 +13,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { normalizeCustomModelSlug } from "@t3tools/shared/model"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; @@ -140,7 +140,6 @@ export function parseGenericCliVersion(output: string): string | null { export function providerModelsFromSettings( builtInModels: ReadonlyArray, - provider: ProviderDriverKind, customModels: ReadonlyArray, customModelCapabilities: ModelCapabilities, ): ReadonlyArray { @@ -149,7 +148,7 @@ export function providerModelsFromSettings( const customEntries: ServerProviderModel[] = []; for (const candidate of customModels) { - const normalized = normalizeModelSlug(candidate, provider); + const normalized = normalizeCustomModelSlug(candidate); if (!normalized || seen.has(normalized)) { continue; } diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 6a5280fe8d0e..4d997cd207b9 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -475,6 +475,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 1 }), streamDomainEvents: Stream.fromQueue(events), + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngineShape; const snapshotQuery = { @@ -667,6 +668,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 1 }), streamDomainEvents: Stream.fromQueue(events), + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngineShape), Layer.succeed(ProjectionSnapshotQuery, { getShellSnapshot: () => diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 029c2ee67536..3282b7fe89f6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -819,6 +819,7 @@ const buildAppUnderTest = (options?: { dispatchCoordinated: options?.layers?.orchestrationEngine?.dispatchCoordinated ?? orchestrationDispatch, streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), ...options?.layers?.orchestrationEngine, }); const worktreeLifecycleCoordinatorLayer = options?.layers?.worktreeLifecycleCoordinator @@ -5310,6 +5311,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); assert.equal(response.auth.policy, "desktop-managed-local"); + assert.equal(response.shellResumeCompletionMarker, true); + assert.equal(response.threadResumeCompletionMarker, true); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -8725,33 +8728,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("serves a fresh shell snapshot instead of replaying an ancient cursor", () => + it.effect("marks an empty shell catch-up replay as synchronized when requested", () => Effect.gen(function* () { - const snapshot = { - snapshotSequence: 2_500, - projects: [], - threads: [ - makeDefaultOrchestrationThreadShell({ - id: ThreadId.make("thread-fresh-shell"), - title: "Fresh shell thread", - }), - ], - updatedAt: "2026-07-08T16:00:00.000Z", - }; - let readEventsCalled = false; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + readEvents: () => Stream.empty, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const firstItem = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 0, + requestCompletionMarker: true, + }).pipe(Stream.runHead), + ), + ); + assert.deepEqual(Option.getOrThrow(firstItem), { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("marks a socket thread snapshot as synchronized when requested", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; yield* buildAppUnderTest({ layers: { projectionSnapshotQuery: { - getSnapshotSequence: () => - Effect.succeed({ snapshotSequence: snapshot.snapshotSequence }), - getShellSnapshot: () => Effect.succeed(snapshot), - }, - orchestrationEngine: { - readEvents: () => { - readEventsCalled = true; - return Stream.empty; - }, + getThreadDetailSnapshot: () => + Effect.succeed(Option.some({ snapshotSequence: 1, thread })), }, }, }); @@ -8759,38 +8767,111 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const items = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 1 }).pipe( - Stream.take(1), - Stream.runCollect, - ), + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + requestCompletionMarker: true, + }).pipe(Stream.take(2), Stream.runCollect), ), ); - assert.deepEqual(Array.from(items), [{ kind: "snapshot", snapshot }]); - assert.equal(readEventsCalled, false); + assert.equal(items[0]?.kind, "snapshot"); + assert.deepEqual(items[1], { kind: "synchronized" }); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("acknowledges an up-to-date shell replay with no emitted events", () => + it.effect("buffers shell events published while the fallback snapshot loads", () => Effect.gen(function* () { - let shellSnapshotCalled = false; + const liveEvents = yield* PubSub.unbounded(); + const deletedEvent = { + sequence: 2, + eventId: EventId.make("event-shell-thread-deleted"), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.deleted", + payload: { + threadId: defaultThreadId, + deletedAt: "2026-01-01T00:00:01.000Z", + }, + } satisfies Extract; yield* buildAppUnderTest({ layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, projectionSnapshotQuery: { - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 2_500 }), - getShellSnapshot: () => { - shellSnapshotCalled = true; - return Effect.succeed({ - snapshotSequence: 2_500, - projects: [], - threads: [], - updatedAt: "2026-07-08T16:00:00.000Z", - }); - }, + getShellSnapshot: () => + Effect.gen(function* () { + yield* PubSub.publish(liveEvents, deletedEvent); + return { + snapshotSequence: 1, + projects: [], + threads: [makeDefaultOrchestrationThreadShell()], + updatedAt: "2026-01-01T00:00:00.000Z", + }; + }), }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + requestCompletionMarker: true, + }).pipe(Stream.take(3), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.equal(items[1]?.kind, "thread-removed"); + assert.deepEqual(items[2], { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("buffers thread events published while the initial snapshot loads", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + const messageEvent = { + sequence: 2, + eventId: EventId.make("event-message"), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId: defaultThreadId, + messageId: MessageId.make("message-1"), + role: "user", + text: "First message", + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + }, + } satisfies Extract; + + yield* buildAppUnderTest({ + layers: { orchestrationEngine: { - readEvents: () => Stream.empty, + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publish(liveEvents, messageEvent); + return Option.some({ snapshotSequence: 1, thread }); + }), }, }, }); @@ -8798,45 +8879,55 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const items = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 2_499 }).pipe( - Stream.take(1), - Stream.runCollect, - ), + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.take(2), Stream.runCollect), ), - ); + ).pipe(Effect.timeout("2 seconds")); - assert.deepEqual(Array.from(items), [{ kind: "caught-up", sequence: 2_500 }]); - assert.equal(shellSnapshotCalled, false); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + assert.equal(items[0]?.kind, "snapshot"); + assert.equal(items[1]?.kind, "event"); + assert.equal(items[1]?.kind === "event" ? items[1].event.sequence : null, 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); - it.effect("serves an authoritative shell snapshot when the client cursor is ahead", () => + it.effect("subscribeShell sends a fresh snapshot instead of replaying a large gap", () => Effect.gen(function* () { - const snapshot = { - snapshotSequence: 25, - projects: [], - threads: [ - makeDefaultOrchestrationThreadShell({ - id: ThreadId.make("thread-reset-shell"), - title: "Reset shell thread", - }), - ], - updatedAt: "2026-07-08T16:00:00.000Z", - }; - let readEventsCalled = false; + let readEventsCalls = 0; + const snapshotThreadId = ThreadId.make("thread-from-snapshot"); + const now = "2026-01-01T00:00:00.000Z"; yield* buildAppUnderTest({ layers: { - projectionSnapshotQuery: { - getSnapshotSequence: () => - Effect.succeed({ snapshotSequence: snapshot.snapshotSequence }), - getShellSnapshot: () => Effect.succeed(snapshot), - }, orchestrationEngine: { - readEvents: () => { - readEventsCalled = true; - return Stream.empty; - }, + // Head is far ahead of the client's afterSequence (gap > 1000). + latestSequence: Effect.succeed(100_000), + readEvents: () => + Stream.sync(() => { + readEventsCalls += 1; + return { + sequence: 1, + eventId: EventId.make("event-should-not-be-read"), + aggregateKind: "thread", + aggregateId: snapshotThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.created", + payload: {} as never, + } satisfies OrchestrationEvent; + }), + }, + projectionSnapshotQuery: { + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 100_000, + projects: [], + threads: [makeDefaultOrchestrationThreadShell({ id: snapshotThreadId })], + updatedAt: now, + }), }, }, }); @@ -8844,173 +8935,349 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const items = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 250 }).pipe( - Stream.take(1), - Stream.runCollect, - ), + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 5, + requestCompletionMarker: true, + }).pipe(Stream.take(2), Stream.runCollect), ), ); - assert.deepEqual(Array.from(items), [{ kind: "snapshot", snapshot, force: true }]); - assert.equal(readEventsCalled, false); + const [first, second] = Array.from(items); + // Large gap => fresh snapshot, and the unbounded replay is never started. + assert.equal(first?.kind, "snapshot"); + if (first?.kind === "snapshot") { + assert.equal(first.snapshot.threads[0]?.id, snapshotThreadId); + } + assert.equal(second?.kind, "synchronized"); + assert.equal(readEventsCalls, 0); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("fails shell replay instead of acknowledging dropped projection lookups", () => + it.effect("subscribeShell replaces a cursor ahead of the authoritative head", () => Effect.gen(function* () { - const projectionError = new PersistenceSqlError({ - operation: "ProjectionSnapshotQuery.getProjectShellById:test", - detail: "failed to read replayed project shell", - }); + let readEventsCalls = 0; yield* buildAppUnderTest({ layers: { - projectionSnapshotQuery: { - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), - getProjectShellById: () => Effect.fail(projectionError), - }, orchestrationEngine: { + latestSequence: Effect.succeed(5), readEvents: () => - Stream.make({ - sequence: 1, - eventId: EventId.make("event-shell-projection-failure"), - aggregateKind: "project", - aggregateId: defaultProjectId, - occurredAt: "2026-04-05T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "project.created", - payload: { - projectId: defaultProjectId, - title: "Default Project", - workspaceRoot: "/tmp/default-project", - dataAudience: "private", - defaultModelSelection, - scripts: [], - createdAt: "2026-04-05T00:00:00.000Z", - updatedAt: "2026-04-05T00:00:00.000Z", - }, - } satisfies Extract), + Stream.sync(() => { + readEventsCalls += 1; + return {} as OrchestrationEvent; + }), + }, + projectionSnapshotQuery: { + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 5, + projects: [], + threads: [], + updatedAt: "2026-01-01T00:00:00.000Z", + }), }, }, }); const wsUrl = yield* getWsServerUrl("/ws"); - const result = yield* Effect.scoped( + const first = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 0 }).pipe( - Stream.runCollect, + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 10 }).pipe( + Stream.runHead, ), - ).pipe(Effect.result), + ), ); - assertTrue(result._tag === "Failure"); - assertTrue(result.failure._tag === "OrchestrationGetSnapshotError"); - assert.include(result.failure.message, "Failed to load orchestration project shell"); + assert.equal(Option.getOrThrow(first).kind, "snapshot"); + assert.equal(readEventsCalls, 0); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("propagates live shell projection failures from the replay buffer", () => + it.effect("subscribeShell coalesces a per-thread burst without stalling other threads", () => Effect.gen(function* () { - const liveEvents = yield* Queue.unbounded(); - const projectionError = new PersistenceSqlError({ - operation: "ProjectionSnapshotQuery.getProjectShellById:live", - detail: "failed to read live project shell", + const busyThreadId = ThreadId.make("thread-busy"); + const newThreadId = ThreadId.make("thread-new"); + const now = "2026-01-01T00:00:00.000Z"; + const shellFetches: Array = []; + let replayLimit: number | undefined; + + const messageEvent = (sequence: number): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: busyThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: {} as never, + }) satisfies OrchestrationEvent; + + const createdEvent: OrchestrationEvent = { + sequence: 50, + eventId: EventId.make("event-created"), + aggregateKind: "thread", + aggregateId: newThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.created", + payload: {} as never, + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(50), + // A burst of message-sent deltas for the busy thread, plus one + // thread.created for a different thread, all within one batch. + readEvents: (_afterSequence, limit) => { + replayLimit = limit; + return Stream.fromIterable([ + ...Array.from({ length: 20 }, (_unused, index) => messageEvent(index + 1)), + createdEvent, + ]); + }, + }, + projectionSnapshotQuery: { + getThreadShellById: (threadId) => + Effect.sync(() => { + shellFetches.push(threadId); + return Option.some(makeDefaultOrchestrationThreadShell({ id: threadId })); + }), + }, + }, }); + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 0, + requestCompletionMarker: true, + }).pipe(Stream.take(3), Stream.runCollect), + ), + ); + + const collected = Array.from(items); + const upsertedIds = collected.flatMap((item) => + item.kind === "thread-upserted" ? [item.thread.id] : [], + ); + // Both threads surface, and the busy thread's 20-event burst collapses to + // a single shell refetch (not 20). The new thread is not stuck behind it. + assert.include(upsertedIds, busyThreadId); + assert.include(upsertedIds, newThreadId); + assert.equal(collected[2]?.kind, "synchronized"); + assert.equal(shellFetches.filter((id) => id === busyThreadId).length, 1); + assert.equal(replayLimit, 50); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell coalesces live bursts after the synchronization marker", () => + Effect.gen(function* () { + const busyThreadId = ThreadId.make("thread-live-busy"); + const newThreadId = ThreadId.make("thread-live-new"); + const now = "2026-01-01T00:00:00.000Z"; + const liveEvents = yield* PubSub.unbounded(); + const synchronized = yield* Deferred.make(); + const shellFetches: Array = []; + const observedLiveThreadIds = new Set(); + + const messageEvent = (sequence: number): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-live-${sequence}`), + aggregateKind: "thread", + aggregateId: busyThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: {} as never, + }) satisfies OrchestrationEvent; + + const createdEvent: OrchestrationEvent = { + sequence: 50, + eventId: EventId.make("event-live-created"), + aggregateKind: "thread", + aggregateId: newThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.created", + payload: {} as never, + }; + yield* buildAppUnderTest({ layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, projectionSnapshotQuery: { - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), - getProjectShellById: () => Effect.fail(projectionError), + getThreadShellById: (threadId) => + Effect.sync(() => { + shellFetches.push(threadId); + return Option.some(makeDefaultOrchestrationThreadShell({ id: threadId })); + }), }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + Effect.gen(function* () { + const itemsFiber = yield* withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + requestCompletionMarker: true, + }).pipe( + Stream.tap((item) => + item.kind === "synchronized" + ? Deferred.succeed(synchronized, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Stream.takeUntil((item) => { + if (item.kind === "thread-upserted") { + observedLiveThreadIds.add(item.thread.id); + } + return ( + observedLiveThreadIds.has(busyThreadId) && observedLiveThreadIds.has(newThreadId) + ); + }), + Stream.runCollect, + ), + ).pipe(Effect.forkScoped); + + yield* Deferred.await(synchronized); + for (const event of [ + ...Array.from({ length: 20 }, (_unused, index) => messageEvent(index + 1)), + createdEvent, + ]) { + yield* PubSub.publish(liveEvents, event); + } + + return yield* Fiber.join(itemsFiber); + }), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.equal(items[1]?.kind, "synchronized"); + const liveUpsertedIds = Array.from(items) + .slice(2) + .flatMap((item) => (item.kind === "thread-upserted" ? [item.thread.id] : [])); + assert.include(liveUpsertedIds, busyThreadId); + assert.include(liveUpsertedIds, newThreadId); + assert.isBelow(shellFetches.filter((id) => id === busyThreadId).length, 20); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("subscribeShell coalescing still emits a removal for a deleted thread", () => + Effect.gen(function* () { + const goneThreadId = ThreadId.make("thread-gone"); + const now = "2026-01-01T00:00:00.000Z"; + + const makeThreadEvent = ( + sequence: number, + type: "thread.deleted" | "thread.message-sent", + ): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: goneThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type, + payload: type === "thread.deleted" ? { threadId: goneThreadId, deletedAt: now } : {}, + }) as OrchestrationEvent; + + yield* buildAppUnderTest({ + layers: { orchestrationEngine: { - readEvents: () => Stream.empty, - streamDomainEvents: Stream.fromQueue(liveEvents), + latestSequence: Effect.succeed(2), + // A thread.deleted followed, within the same coalescing window, by a + // later refetchable event for the same thread. The later event wins + // coalescing; its shell refetch returns none (the row is gone), which + // must still surface a removal rather than be swallowed. + readEvents: () => + Stream.fromIterable([ + makeThreadEvent(1, "thread.deleted"), + makeThreadEvent(2, "thread.message-sent"), + ]), + }, + projectionSnapshotQuery: { + getThreadShellById: () => Effect.succeed(Option.none()), }, }, }); const wsUrl = yield* getWsServerUrl("/ws"); - const result = yield* Effect.scoped( + const items = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - Effect.gen(function* () { - const fiber = yield* client[ORCHESTRATION_WS_METHODS.subscribeShell]({ - afterSequence: 1, - }).pipe(Stream.runCollect, Effect.result, Effect.forkScoped); - yield* Queue.offer(liveEvents, { - sequence: 2, - eventId: EventId.make("event-live-shell-projection-failure"), - aggregateKind: "project", - aggregateId: defaultProjectId, - occurredAt: "2026-04-05T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "project.created", - payload: { - projectId: defaultProjectId, - title: "Default Project", - workspaceRoot: "/tmp/default-project", - dataAudience: "private", - defaultModelSelection, - scripts: [], - createdAt: "2026-04-05T00:00:00.000Z", - updatedAt: "2026-04-05T00:00:00.000Z", - }, - } satisfies Extract); - return yield* Fiber.join(fiber); - }), + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 0 }).pipe( + Stream.take(1), + Stream.runCollect, + ), ), ); - assertTrue(result._tag === "Failure"); - assertTrue(result.failure._tag === "OrchestrationGetSnapshotError"); - assert.include(result.failure.message, "Failed to load orchestration project shell"); + const [first] = Array.from(items); + assert.equal(first?.kind, "thread-removed"); + assert.equal(first?.kind === "thread-removed" ? first.threadId : null, goneThreadId); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("buffers thread events published while the initial snapshot loads", () => + it.effect("subscribeShell retries a transient shell projection refetch failure", () => Effect.gen(function* () { - const thread = makeDefaultOrchestrationReadModel().threads[0]!; - const liveEvents = yield* PubSub.unbounded(); - const messageEvent = { - sequence: 2, - eventId: EventId.make("event-message"), + const threadId = ThreadId.make("thread-transient-refetch"); + const now = "2026-01-01T00:00:00.000Z"; + let attempts = 0; + + const event: OrchestrationEvent = { + sequence: 1, + eventId: EventId.make("event-transient-refetch"), aggregateKind: "thread", - aggregateId: defaultThreadId, - occurredAt: "2026-01-01T00:00:01.000Z", + aggregateId: threadId, + occurredAt: now, commandId: null, causationEventId: null, correlationId: null, metadata: {}, type: "thread.message-sent", - payload: { - threadId: defaultThreadId, - messageId: MessageId.make("message-1"), - role: "user", - text: "First message", - turnId: null, - streaming: false, - createdAt: "2026-01-01T00:00:01.000Z", - updatedAt: "2026-01-01T00:00:01.000Z", - }, - } satisfies Extract; + payload: {} as never, + }; yield* buildAppUnderTest({ layers: { orchestrationEngine: { - streamDomainEvents: Stream.fromPubSub(liveEvents), + latestSequence: Effect.succeed(1), + readEvents: () => Stream.make(event), }, projectionSnapshotQuery: { - getThreadDetailSnapshot: () => - Effect.gen(function* () { - yield* Effect.sleep("25 millis"); - yield* PubSub.publish(liveEvents, messageEvent); - return Option.some({ snapshotSequence: 1, thread }); + getThreadShellById: () => + Effect.suspend(() => { + attempts += 1; + return attempts === 1 + ? Effect.fail( + new PersistenceSqlError({ + operation: "test.shell-refetch", + detail: "transient failure", + }), + ) + : Effect.succeed( + Option.some(makeDefaultOrchestrationThreadShell({ id: threadId })), + ); }), }, }, @@ -9019,16 +9286,76 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const items = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.subscribeThread]({ - threadId: defaultThreadId, - }).pipe(Stream.take(2), Stream.runCollect), + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 0 }).pipe( + Stream.take(1), + Stream.runCollect, + ), ), - ).pipe(Effect.timeout("2 seconds")); + ); - assert.equal(items[0]?.kind, "snapshot"); - assert.equal(items[1]?.kind, "event"); - assert.equal(items[1]?.kind === "event" ? items[1].event.sequence : null, 2); - }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + const [first] = Array.from(items); + assert.equal(first?.kind, "thread-upserted"); + assert.equal(first?.kind === "thread-upserted" ? first.thread.id : null, threadId); + assert.equal(attempts, 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell coalescing still removes a project after a trailing update", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project-gone"); + const now = "2026-01-01T00:00:00.000Z"; + + const makeProjectEvent = ( + sequence: number, + type: "project.deleted" | "project.meta-updated", + ): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-project-${sequence}`), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type, + payload: + type === "project.deleted" + ? { projectId, deletedAt: now } + : { projectId, title: "Still deleted", updatedAt: now }, + }) as OrchestrationEvent; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(2), + readEvents: () => + Stream.fromIterable([ + makeProjectEvent(1, "project.deleted"), + makeProjectEvent(2, "project.meta-updated"), + ]), + }, + projectionSnapshotQuery: { + getProjectShellById: () => Effect.succeed(Option.none()), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 0 }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const [first] = Array.from(items); + assert.equal(first?.kind, "project-removed"); + assert.equal(first?.kind === "project-removed" ? first.projectId : null, projectId); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); it.effect("enriches replayed project events with repository identity metadata", () => diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 76244563efaf..b4d407c5c1e5 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -334,6 +334,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provide(NodeServices.layer), ); @@ -379,6 +380,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provide(NodeServices.layer), ); @@ -430,6 +432,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provideService(Crypto.Crypto, { ...crypto, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 504d99e18def..487ae9b45b8a 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -179,6 +179,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/codex", homePath: "/Users/julius/.codex", shadowHomePath: "", + launchArgs: "", customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { @@ -420,6 +421,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/codex", homePath: "", shadowHomePath: "", + launchArgs: "", customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 24054a958701..657118fff51c 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -35,6 +35,8 @@ function makeFakeCodexBinary( requireServiceTier?: string; requireReasoningEffort?: string; forbidReasoningEffort?: boolean; + requireArg?: string; + forbidArg?: string; stdinMustContain?: string; stdinMustNotContain?: string; }, @@ -50,6 +52,7 @@ function makeFakeCodexBinary( codexPath, [ "#!/bin/sh", + 'original_args="$*"', 'output_path=""', 'seen_image="0"', 'seen_service_tier=""', @@ -87,6 +90,22 @@ function makeFakeCodexBinary( " shift", "done", 'stdin_content="$(cat)"', + ...(input.requireArg !== undefined + ? [ + `case " $original_args " in *" ${input.requireArg} "*) ;; *)`, + ` printf "%s\\n" "missing arg: ${input.requireArg}" >&2`, + ` exit 8`, + "esac", + ] + : []), + ...(input.forbidArg !== undefined + ? [ + `case " $original_args " in *" ${input.forbidArg} "*)`, + ` printf "%s\\n" "forbidden arg: ${input.forbidArg}" >&2`, + ` exit 9`, + "esac", + ] + : []), ...(input.requireImage ? [ 'if [ "$seen_image" != "1" ]; then', @@ -166,8 +185,12 @@ function withFakeCodexEnv( requireServiceTier?: string; requireReasoningEffort?: string; forbidReasoningEffort?: boolean; + requireArg?: string; + forbidArg?: string; stdinMustContain?: string; stdinMustNotContain?: string; + launchArgs?: string; + environment?: NodeJS.ProcessEnv; }, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { @@ -175,8 +198,8 @@ function withFakeCodexEnv( const fs = yield* FileSystem.FileSystem; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); const codexPath = yield* makeFakeCodexBinary(tempDir, input); - const config = decodeCodexSettings({ binaryPath: codexPath }); - const textGeneration = yield* makeCodexTextGeneration(config); + const config = decodeCodexSettings({ binaryPath: codexPath, launchArgs: input.launchArgs }); + const textGeneration = yield* makeCodexTextGeneration(config, input.environment); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } @@ -237,6 +260,51 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { ), ); + it.effect("passes exec-safe launch args into codex exec", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add important change", + body: "", + }), + launchArgs: "--strict-config --listen off", + requireArg: "--strict-config", + forbidArg: "--listen", + }, + (textGeneration) => + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }), + ), + ); + + it.effect("uses T3CODE_CODEX_LAUNCH_ARGS for codex exec over settings", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add important change", + body: "", + }), + launchArgs: "--enable settings-feature", + environment: { T3CODE_CODEX_LAUNCH_ARGS: " --strict-config --listen off " }, + requireArg: "--strict-config", + forbidArg: "settings-feature", + }, + (textGeneration) => + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }), + ), + ); + it.effect("defaults git text generation codex effort to low", () => withFakeCodexEnv( { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 0e68994fd3dd..6a5c0df43c7f 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -14,6 +14,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; +import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/Layers/codexLaunchArgs.ts"; import { TextGenerationError } from "@t3tools/contracts"; import * as TextGeneration from "./TextGeneration.ts"; import { @@ -174,6 +175,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const outputPath = yield* writeTempFile(operation, "codex-output", ""); const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { + const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment); const reasoningEffort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; @@ -182,6 +184,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func codexConfig.binaryPath || "codex", [ "exec", + ...codexExecLaunchArgs(launchArgs), "--ephemeral", "--skip-git-repo-check", "-s", diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 558a8663b64d..1fcf9bc4c73a 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -107,6 +107,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { cause: null, }), ), + loadInventoryFromCli: () => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", + cause: null, + }), + ), }; const DEFAULT_TEST_MODEL_SELECTION = { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b13dbe99317f..5130b91a8f2e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -27,6 +27,7 @@ import { NonNegativeInt, OrchestrationDispatchCommandError, type OrchestrationEvent, + type OrchestrationShellStreamEvent, type OrchestrationShellStreamItem, type OrchestrationThreadStreamItem, OrchestrationGetFullThreadDiffError, @@ -34,6 +35,7 @@ import { OrchestrationGetTurnDiffError, OrchestrationScheduledTaskMutationError, ORCHESTRATION_WS_METHODS, + type ProjectId, type ProjectEntriesFailure, type ProjectFileFailure, type ProjectFileOperation, @@ -276,6 +278,13 @@ export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract const PROVIDER_STATUS_DEBOUNCE_MS = 200; +// When a resuming client's cursor is more than this many events behind the +// current head, skip the per-event catch-up replay and send a fresh shell +// snapshot instead. Replaying each intervening event costs a shell refetch; +// past this gap a single O(active-threads) snapshot is cheaper and bounded. +// Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT). +const SHELL_RESUME_MAX_GAP = 1_000; + const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], @@ -634,64 +643,28 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => const toShellStreamEvent = ( event: OrchestrationEvent, - ): Effect.Effect< - Option.Option, - OrchestrationGetSnapshotError, - never - > => { + ): Effect.Effect, never, never> => { switch (event.type) { case "project.created": case "project.meta-updated": - return projectionSnapshotQuery.getProjectShellById(event.payload.projectId).pipe( - Effect.map((project) => - Option.map(project, (nextProject) => ({ - kind: "project-upserted" as const, - sequence: event.sequence, - project: nextProject, - })), - ), - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to load orchestration project shell", - cause, - }), - ), - ); + return projectUpsertOrRemove(event.payload.projectId, event.sequence); case "project.data-audience-set": if (currentSession.audienceCeiling === "private") { - return projectionSnapshotQuery.getProjectShellById(event.payload.projectId).pipe( - Effect.map((project) => - Option.map(project, (nextProject) => ({ - kind: "project-upserted" as const, - sequence: event.sequence, - project: nextProject, - })), - ), - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to load orchestration project shell", - cause, - }), - ), - ); + return projectUpsertOrRemove(event.payload.projectId, event.sequence); } + // A non-private audience change can reveal or hide aggregates beyond + // the project row itself, so force a fresh authoritative shell. If + // that read fails, fall back to re-emitting just the project so the + // coalescing stream never fails (matching the retry-backed helpers). return projectionSnapshotQuery.getShellSnapshot().pipe( Effect.map((snapshot) => - Option.some({ + Option.some({ kind: "snapshot" as const, snapshot, force: true, }), ), - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to refresh orchestration shell after audience promotion", - cause, - }), - ), + Effect.catch(() => projectUpsertOrRemove(event.payload.projectId, event.sequence)), ); case "project.deleted": return Effect.succeed( @@ -711,47 +684,192 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => }), ); case "thread.unarchived": - return projectionSnapshotQuery.getThreadShellById(event.payload.threadId).pipe( - Effect.map((thread) => - Option.map(thread, (nextThread) => ({ - kind: "thread-upserted" as const, - sequence: event.sequence, - thread: nextThread, - })), - ), - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to load orchestration thread shell", - cause, - }), - ), - ); + return threadUpsertOrRemove(event.payload.threadId, event.sequence); default: if (event.aggregateKind !== "thread") { return Effect.succeed(Option.none()); } - return projectionSnapshotQuery - .getThreadShellById(ThreadId.make(event.aggregateId)) - .pipe( - Effect.map((thread) => - Option.map(thread, (nextThread) => ({ - kind: "thread-upserted" as const, - sequence: event.sequence, - thread: nextThread, - })), - ), - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to load orchestration thread shell", - cause, - }), - ), - ); + return threadUpsertOrRemove(ThreadId.make(event.aggregateId), event.sequence); } }; + // Coalescing makes each projection read represent every event for that + // aggregate in the current window. Retry a typed persistence failure once + // so a brief read failure cannot strand the shell at its previous state. + // If both attempts fail, log and drop the stream item; treating an error as + // a missing row would incorrectly remove a still-active aggregate. + const retryShellProjectionRead = ( + aggregateKind: "project" | "thread", + aggregateId: string, + read: Effect.Effect, + ): Effect.Effect, never, never> => + read.pipe( + Effect.retry({ times: 1 }), + Effect.map(Option.some), + Effect.tapError((error) => + Effect.logWarning("orchestration shell projection refetch failed", { + aggregateKind, + aggregateId, + error, + }), + ), + Effect.orElseSucceed(() => Option.none()), + ); + + const projectUpsertOrRemove = ( + projectId: ProjectId, + sequence: number, + ): Effect.Effect, never, never> => + retryShellProjectionRead( + "project", + projectId, + projectionSnapshotQuery.getProjectShellById(projectId), + ).pipe( + Effect.map( + Option.flatMap((project) => + Option.match(project, { + onNone: () => + Option.some({ + kind: "project-removed" as const, + sequence, + projectId, + }), + onSome: (nextProject) => + Option.some({ + kind: "project-upserted" as const, + sequence, + project: nextProject, + }), + }), + ), + ), + ); + + // Refetch a thread's shell and emit an upsert if it is still active, or a + // `thread-removed` if the projection has no active row for it. Emitting a + // removal on a `none` (rather than dropping the event) is what keeps + // coalescing correct: when a burst collapses a `thread.deleted`/`archived` + // into a later refetchable event for the same thread, the refetch returns + // `none` for the now-inactive row and this still tells the sidebar to drop + // it. A `thread-removed` the client does not have is a harmless no-op. The + // projection commits in the same transaction before the event publishes, + // so a `none` reliably means the thread is deleted or archived, not + // not-yet-persisted. + const threadUpsertOrRemove = ( + threadId: ThreadId, + sequence: number, + ): Effect.Effect, never, never> => + retryShellProjectionRead( + "thread", + threadId, + projectionSnapshotQuery.getThreadShellById(threadId), + ).pipe( + Effect.map( + Option.flatMap((thread) => + Option.match(thread, { + onNone: () => + Option.some({ + kind: "thread-removed" as const, + sequence, + threadId, + }), + onSome: (nextThread) => + Option.some({ + kind: "thread-upserted" as const, + sequence, + thread: nextThread, + }), + }), + ), + ), + ); + + // Turn a batch of domain events into shell stream items, coalescing by + // aggregate first. `toShellStreamEvent` re-reads the *current* projected + // shell for an aggregate, so within a batch only the latest event per + // aggregate matters: a burst of streaming `thread.message-sent` deltas for + // one thread collapses into a single shell refetch, and an unrelated + // `thread.created` in the same batch is never stuck behind those DB reads. + // + // Input events arrive in ascending sequence; we keep the last (highest + // sequence) event per aggregate, then re-sort ascending before emitting so + // the client — which applies shell items strictly by increasing sequence + // and drops any `sequence <= snapshotSequence` — never skips a coalesced + // item. The refetch runs with bounded concurrency (order-preserving). + const SHELL_REFETCH_CONCURRENCY = 8; + const coalesceShellEvents = ( + events: ReadonlyArray, + ): Effect.Effect, never, never> => + Effect.gen(function* () { + if (events.length === 0) { + return []; + } + const latestByAggregate = new Map(); + for (const event of events) { + latestByAggregate.set(`${event.aggregateKind}:${event.aggregateId}`, event); + } + const survivors = Array.from(latestByAggregate.values()).sort( + (left, right) => left.sequence - right.sequence, + ); + const shellEvents = yield* Effect.forEach(survivors, toShellStreamEvent, { + concurrency: SHELL_REFETCH_CONCURRENCY, + }); + return shellEvents.flatMap((option) => (Option.isSome(option) ? [option.value] : [])); + }); + + // Small time/size window over which to coalesce shell events. The window + // bounds the worst-case added latency for a brand-new thread to appear in + // the sidebar (imperceptible), while collapsing high-frequency streaming + // traffic so it can't serialize the shell stream behind per-event DB reads. + const SHELL_COALESCE_WINDOW = Duration.millis(50); + const SHELL_COALESCE_MAX_CHUNK = 512; + const coalesceShellStream = ( + stream: Stream.Stream, + ): Stream.Stream => + stream.pipe( + Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW), + Stream.mapEffect(coalesceShellEvents), + Stream.flatMap((items) => Stream.fromIterable(items)), + ); + + type ShellLiveInput = + | { readonly kind: "event"; readonly event: OrchestrationEvent } + | { readonly kind: "synchronized" }; + + // A completion marker is queued alongside raw live events so it cannot + // overtake an event still waiting in the coalescing window. Split each + // batch at markers and coalesce only the event segments on either side. + const coalesceShellLiveInputs = ( + inputs: ReadonlyArray, + ): Effect.Effect, never, never> => + Effect.gen(function* () { + const output: Array = []; + let pendingEvents: Array = []; + + for (const input of inputs) { + if (input.kind === "event") { + pendingEvents.push(input.event); + continue; + } + + output.push(...(yield* coalesceShellEvents(pendingEvents))); + pendingEvents = []; + output.push({ kind: "synchronized" }); + } + + output.push(...(yield* coalesceShellEvents(pendingEvents))); + return output; + }); + + const coalesceShellLiveStream = ( + stream: Stream.Stream, + ): Stream.Stream => + stream.pipe( + Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW), + Stream.mapEffect(coalesceShellLiveInputs), + Stream.flatMap((items) => Stream.fromIterable(items)), + ); + const dispatchBootstrapTurnStart = ( command: Extract, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => @@ -818,6 +936,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, }, settings, + shellResumeCompletionMarker: true, + threadResumeCompletionMarker: true, }; }); @@ -934,135 +1054,29 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => yield* requireAudienceOpaqueEventCursor( (message) => new OrchestrationGetSnapshotError({ message }), ); - const liveStream: Stream.Stream< - OrchestrationShellStreamItem, - OrchestrationGetSnapshotError - > = visibleOrchestrationEvents(orchestrationEngine.streamDomainEvents).pipe( - Stream.mapEffect(toShellStreamEvent), - Stream.flatMap((event) => - Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, + // Coalesce the live shell stream per aggregate over a small window + // so bursts of high-frequency events (streaming message deltas, + // activity appends) collapse into a single shell refetch and never + // serialize a brand-new thread's `thread.created` behind hundreds + // of per-event DB reads. See coalesceShellStream. + // Attach live delivery into a scope-bound buffer BEFORE loading any + // snapshot or draining catch-up, otherwise an event published while + // the snapshot query is in flight is lost (it is past the snapshot's + // sequence but the live subscription is not attached yet). Every + // path below emits from this same buffered live tail. Overlapping + // events are deduped by sequence on the client. + const liveBuffer = yield* Queue.unbounded(); + yield* Effect.forkScoped( + orchestrationEngine.streamDomainEvents.pipe( + Stream.runForEach((event) => + Queue.offer(liveBuffer, { kind: "event" as const, event }), + ), ), + { startImmediately: true }, ); + const bufferedLiveStream = coalesceShellLiveStream(Stream.fromQueue(liveBuffer)); - // When the client already holds a shell snapshot (cached, or loaded - // over HTTP) it passes that snapshot's sequence, and we resume by - // replaying shell events after it instead of re-sending the whole - // projects/threads list over the socket. As in the thread path, the - // live subscription is attached (into a scope-bound buffer) before - // draining the catch-up replay so no event published during the - // replay window is lost; overlapping events are deduped by sequence - // on the client. The full range is read (not the store's default - // page limit) since the shell filter runs after reading. - if (input.afterSequence !== undefined) { - const afterSequence = input.afterSequence; - return Stream.unwrap( - Effect.gen(function* () { - const liveBuffer = yield* Queue.unbounded< - | { - readonly _tag: "item"; - readonly item: OrchestrationShellStreamItem; - } - | { - readonly _tag: "error"; - readonly error: OrchestrationGetSnapshotError; - } - >(); - const liveBufferStream = Stream.fromQueue(liveBuffer).pipe( - Stream.mapEffect((entry) => - entry._tag === "item" - ? Effect.succeed(entry.item) - : Effect.fail(entry.error), - ), - ); - yield* Effect.forkScoped( - liveStream.pipe( - Stream.runForEach((item) => - Queue.offer(liveBuffer, { - _tag: "item" as const, - item, - }), - ), - Effect.catch((error) => - Queue.offer(liveBuffer, { - _tag: "error" as const, - error, - }), - ), - ), - ); - yield* Effect.yieldNow; - const currentSequence = yield* projectionSnapshotQuery - .getSnapshotSequence() - .pipe( - Effect.tapError((cause) => - Effect.logError("orchestration shell sequence load failed", { cause }), - ), - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to load orchestration shell sequence", - cause, - }), - ), - ); - const isClientCursorAhead = afterSequence > currentSequence.snapshotSequence; - if ( - isClientCursorAhead || - currentSequence.snapshotSequence - afterSequence > - SHELL_REPLAY_SNAPSHOT_GAP_THRESHOLD - ) { - const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( - Effect.tapError((cause) => - Effect.logError("orchestration shell snapshot load failed", { cause }), - ), - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to load orchestration shell snapshot", - cause, - }), - ), - ); - return Stream.concat( - Stream.make({ - kind: "snapshot" as const, - snapshot, - ...(isClientCursorAhead ? { force: true } : {}), - }), - liveBufferStream, - ); - } - const catchUpStream = visibleOrchestrationEvents( - orchestrationEngine.readEvents(afterSequence, Number.MAX_SAFE_INTEGER), - ).pipe( - Stream.mapEffect(toShellStreamEvent), - Stream.flatMap((event) => - Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, - ), - Stream.mapError((cause) => - isOrchestrationGetSnapshotError(cause) - ? cause - : new OrchestrationGetSnapshotError({ - message: "Failed to replay orchestration shell events", - cause, - }), - ), - ); - return Stream.concat( - catchUpStream, - Stream.concat( - Stream.make({ - kind: "caught-up" as const, - sequence: currentSequence.snapshotSequence, - }), - liveBufferStream, - ), - ); - }), - ); - } - - const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( + const loadSnapshot = projectionSnapshotQuery.getShellSnapshot().pipe( Effect.tapError((cause) => Effect.logError("orchestration shell snapshot load failed", { cause }), ), @@ -1075,12 +1089,70 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ), ); + // Offer the completion marker into the same queue as live events. + // Anything buffered while snapshot/replay work was in flight is + // therefore delivered before the client is told it is synchronized. + const synchronizedThenLive = + input.requestCompletionMarker === true + ? Stream.concat( + Stream.fromEffect( + Queue.offer(liveBuffer, { kind: "synchronized" as const }).pipe( + Effect.andThen(Queue.takeAll(liveBuffer)), + Effect.flatMap(coalesceShellLiveInputs), + ), + ).pipe(Stream.flatMap((items) => Stream.fromIterable(items))), + bufferedLiveStream, + ) + : bufferedLiveStream; + + // When the client already holds a shell snapshot (cached, or loaded + // over HTTP) it passes that snapshot's sequence, and we resume by + // replaying shell events after it instead of re-sending the whole + // projects/threads list over the socket. If the client is too far + // behind, we fall back to a fresh snapshot instead of an unbounded + // replay (see below). + if (input.afterSequence !== undefined) { + const afterSequence = input.afterSequence; + const headSequence = yield* orchestrationEngine.latestSequence; + const replayGap = headSequence - afterSequence; + // Gap too large: replaying every intervening event (each a shell + // refetch) is far more expensive than a single O(active-threads) + // snapshot. A cursor ahead of this engine's authoritative state + // is also invalid, so reset it with a snapshot. Send the snapshot + // followed by the buffered live tail, exactly as the + // no-afterSequence path does. + if (replayGap < 0 || replayGap > SHELL_RESUME_MAX_GAP) { + const snapshot = yield* loadSnapshot; + return Stream.concat( + Stream.make({ kind: "snapshot" as const, snapshot }), + synchronizedThenLive, + ); + } + const catchUpStream = coalesceShellStream( + // Replay only through the head captured above. Newer events + // are already covered by the live subscription, so this bound + // cannot chase a moving event-store head or grow the live + // buffer indefinitely while waiting for an empty page. + orchestrationEngine.readEvents(afterSequence, replayGap), + ).pipe( + Stream.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to replay orchestration shell events", + cause, + }), + ), + ); + return Stream.concat(catchUpStream, synchronizedThenLive); + } + + const snapshot = yield* loadSnapshot; return Stream.concat( Stream.make({ kind: "snapshot" as const, snapshot, }), - liveStream, + synchronizedThenLive, ); }), { "rpc.aggregate": "orchestration" }, @@ -1185,6 +1257,19 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ), ); const liveBufferStream = Stream.fromQueue(liveBuffer); + // Emit upstream's "synchronized" completion marker (when the + // client requested one) after replay, ordered at the current + // tail of the live buffer so a completion-aware client can + // leave the synchronizing state. + const withCompletionMarker = + input.requestCompletionMarker === true + ? Stream.concat( + Stream.fromEffect( + Queue.offer(liveBuffer, { kind: "synchronized" as const }), + ).pipe(Stream.drain), + liveBufferStream, + ) + : liveBufferStream; const observedIdentityMatches = input.observedRevision === latestRevision.latestSequence && (input.observedRevision === 0 @@ -1225,7 +1310,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => }), ), ); - return Stream.concat(catchUpStream, liveBufferStream); + return Stream.concat(catchUpStream, withCompletionMarker); } const snapshot = yield* projectionSnapshotQuery @@ -1311,7 +1396,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ...coveredThreadRevision(snapshot.value.snapshotSequence, latestRevision), }, }), - liveBufferStream, + withCompletionMarker, ); }), ); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 673b093e333e..701af3a79fc9 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -9,7 +9,15 @@ import { usePreparedConnection } from "~/state/session"; export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; -export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { +export type AssetUrlState = + | { readonly _tag: "Loading" } + | { readonly _tag: "Failure" } + | { readonly _tag: "Success"; readonly url: string }; + +export function useAssetUrlState( + environmentId: EnvironmentId, + resource: AssetResource, +): AssetUrlState { const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( assetEnvironment.createUrl({ @@ -17,10 +25,22 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc input: { resource }, }), ); + if (result._tag === "Failure") { + return { _tag: "Failure" }; + } if (preparedConnection._tag === "None" || result._tag !== "Success") { + return { _tag: "Loading" }; + } + const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; +} + +export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { + const result = useAssetUrlState(environmentId, resource); + if (result._tag !== "Success") { return null; } - return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + return result.url; } export function useAssetUrls( diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 587ec65cbc98..6c692dc3de8b 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,13 +1,22 @@ import { useAtomValue } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; +import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import ThreadSidebar from "./Sidebar"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; +import { + resolveInitialThreadSidebarWidth, + resolveThreadSidebarMaximumWidth, + THREAD_MAIN_CONTENT_MIN_WIDTH, + THREAD_SIDEBAR_MIN_WIDTH, + THREAD_SIDEBAR_WIDTH_STORAGE_KEY, +} from "./threadSidebarWidth"; import { Sidebar, SidebarProvider, @@ -18,11 +27,20 @@ import { } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; -const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; -const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; +function readInitialThreadSidebarWidth(): number { + try { + return resolveInitialThreadSidebarWidth( + getLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY, Schema.Finite), + window.innerWidth, + ); + } catch (error) { + console.error("Could not read persisted thread sidebar width.", error); + return resolveInitialThreadSidebarWidth(null, window.innerWidth); + } +} + function SidebarControl() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar } = useSidebar(); @@ -33,6 +51,12 @@ function SidebarControl() { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; + if ( + event.target instanceof HTMLElement && + event.target.closest("[data-keybinding-capture]") + ) { + return; + } if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return; event.preventDefault(); @@ -40,8 +64,9 @@ function SidebarControl() { toggleSidebar(); }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); + // Capture before focused editors consume commands such as Mod+B for rich-text formatting. + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); }, [keybindings, toggleSidebar]); return ( @@ -75,16 +100,20 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); + const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); + const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; return isMacosDesktop && typeof getWindowFullscreenState === "function" ? getWindowFullscreenState() : false; }); - const macosWindowControlsStyle = - isMacosDesktop && !isWindowFullscreen - ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) - : undefined; + const sidebarProviderStyle = { + "--sidebar-width": `${sidebarWidth}px`, + ...(isMacosDesktop && !isWindowFullscreen + ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } + : {}), + } as CSSProperties; useEffect(() => { if (!isMacosDesktop) return; @@ -124,16 +153,19 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate, pathname]); return ( - + + shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) => + nextWidth <= currentWidth || wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, + onResize: setSidebarWidth, }} > diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 94a3909a9614..8291e5e006e8 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -12,6 +12,7 @@ import { resolveBranchToolbarValue, resolveLockedWorkspaceLabel, shouldIncludeBranchPickerItem, + shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -119,6 +120,44 @@ describe("resolveEnvironmentOptionLabel", () => { }); }); +describe("shouldShowEnvironmentIndicator", () => { + it("shows the indicator whenever multiple environments are pickable", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: { isPrimary: true }, + canPickEnvironment: true, + }), + ).toBe(true); + }); + + it("shows a sole remote environment so the user knows where the project runs", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: { isPrimary: false }, + canPickEnvironment: false, + }), + ).toBe(true); + }); + + it("hides a sole primary (this-device) environment", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: { isPrimary: true }, + canPickEnvironment: false, + }), + ).toBe(false); + }); + + it("hides the indicator when the active environment is unknown", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: null, + canPickEnvironment: false, + }), + ).toBe(false); + }); +}); + describe("resolveEffectiveEnvMode", () => { it("treats draft threads already attached to a worktree as current-checkout mode", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 65388962c080..b16e1f590a93 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -42,6 +42,17 @@ export function resolveEnvironmentOptionLabel(input: { return runtimeLabel ?? savedLabel ?? input.environmentId; } +// A remote (non-primary) environment is always surfaced, even when it is the +// only environment available: with a single connected machine there is nothing +// to pick, but the user still needs to see where the project runs. +export function shouldShowEnvironmentIndicator(input: { + activeEnvironment: Pick | null; + canPickEnvironment: boolean; +}): boolean { + if (input.canPickEnvironment) return true; + return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; +} + export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 958cb3743c70..0354c2e0cd78 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -20,6 +20,7 @@ import { resolveEnvModeLabel, resolveEffectiveEnvMode, resolveLockedWorkspaceLabel, + shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; @@ -60,6 +61,7 @@ interface MobileRunContextSelectorProps { environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[] | undefined; showEnvironmentPicker: boolean; + showEnvironmentIndicator: boolean; onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; @@ -72,6 +74,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ environmentId, availableEnvironments, showEnvironmentPicker, + showEnvironmentIndicator, onEnvironmentChange, effectiveEnvMode, activeWorktreePath, @@ -94,7 +97,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ : resolveCurrentWorkspaceLabel(activeWorktreePath); const isLocked = envLocked || envModeLocked; const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; - const icon = showEnvironmentPicker ? ( + const icon = showEnvironmentIndicator ? ( // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. @@ -108,7 +111,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ <> {icon} - {showEnvironmentPicker ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} + {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} ); @@ -234,6 +237,12 @@ export const BranchToolbar = memo(function BranchToolbar({ const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, ); + const activeEnvironmentOption = + availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null; + const showEnvironmentIndicator = shouldShowEnvironmentIndicator({ + activeEnvironment: activeEnvironmentOption, + canPickEnvironment: showEnvironmentPicker, + }); const isMobile = useIsMobile(); if (!hasActiveThread || !activeProject) return null; @@ -247,6 +256,7 @@ export const BranchToolbar = memo(function BranchToolbar({ environmentId={environmentId} availableEnvironments={availableEnvironments} showEnvironmentPicker={showEnvironmentPicker} + showEnvironmentIndicator={showEnvironmentIndicator} onEnvironmentChange={onEnvironmentChange} effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} @@ -254,13 +264,13 @@ export const BranchToolbar = memo(function BranchToolbar({ /> ) : (
- {showEnvironmentPicker && availableEnvironments && onEnvironmentChange && ( + {showEnvironmentIndicator && availableEnvironments && ( <> diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 35fc8b6a1904..dbc742bea5a7 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -17,7 +17,9 @@ interface BranchToolbarEnvironmentSelectorProps { envLocked: boolean; environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; - onEnvironmentChange: (environmentId: EnvironmentId) => void; + // Absent when there is only one environment to show: the indicator still + // renders (as a static label) so remote projects are always identifiable. + onEnvironmentChange?: (environmentId: EnvironmentId) => void; } export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ @@ -39,7 +41,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir [availableEnvironments], ); - if (envLocked) { + if (envLocked || onEnvironmentChange === undefined) { return ( {activeEnvironment?.isPrimary ? ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9cd91264e7de..fe7fbf611935 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -149,6 +149,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { + buildProjectScript, commandForProjectScript, nextProjectScriptId, projectScriptIdFromCommand, @@ -3714,13 +3715,7 @@ function ChatViewContent(props: ChatViewProps) { input.name, activeProject.scripts.map((script) => script.id), ); - const nextScript: ProjectScript = { - id: nextId, - name: input.name, - command: input.command, - icon: input.icon, - runOnWorktreeCreate: input.runOnWorktreeCreate, - }; + const nextScript = buildProjectScript(nextId, input); const nextScripts = input.runOnWorktreeCreate ? [ ...activeProject.scripts.map((script) => @@ -3754,13 +3749,7 @@ function ChatViewContent(props: ChatViewProps) { return AsyncResult.failure(Cause.fail(new Error("Script not found."))); } - const updatedScript: ProjectScript = { - ...existingScript, - name: input.name, - command: input.command, - icon: input.icon, - runOnWorktreeCreate: input.runOnWorktreeCreate, - }; + const updatedScript = buildProjectScript(existingScript.id, input); const nextScripts = activeProject.scripts.map((script) => script.id === scriptId ? updatedScript diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 585d46150a80..169126788ae8 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -28,7 +28,10 @@ import { KEY_ENTER_COMMAND, KEY_TAB_COMMAND, COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, KEY_BACKSPACE_COMMAND, + BLUR_COMMAND, + FOCUS_COMMAND, $getRoot, HISTORY_MERGE_TAG, DecoratorNode, @@ -185,7 +188,7 @@ class ComposerMentionNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; return dom; } @@ -323,7 +326,7 @@ class ComposerSkillNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; return dom; } @@ -394,7 +397,7 @@ class ComposerTerminalContextNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; return dom; } @@ -1167,6 +1170,80 @@ function ComposerInlineTokenBackspacePlugin() { return null; } +/** + * Chips render as non-editable decorators, so the browser never paints the + * native text selection over them; without help, a selection spanning chips + * is only visible in the slivers between them. Mirror the selection onto the + * chips with a data attribute the stylesheet turns into a highlight overlay. + */ +function ComposerChipSelectionPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + let selectedKeys = new Set(); + // Lexical keeps the range selection on blur without emitting an update, + // so focus is tracked separately; while blurred the native highlight is + // gone and the mirrored one has to go with it. + let hasFocus = editor.getRootElement() === document.activeElement; + + const applyKeys = (nextKeys: Set) => { + for (const key of selectedKeys) { + if (!nextKeys.has(key)) { + editor.getElementByKey(key)?.removeAttribute("data-composer-chip-selected"); + } + } + for (const key of nextKeys) { + editor.getElementByKey(key)?.setAttribute("data-composer-chip-selected", "true"); + } + selectedKeys = nextKeys; + }; + + const readSelectedKeys = () => { + const nextKeys = new Set(); + editor.getEditorState().read(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection) && !selection.isCollapsed()) { + for (const node of selection.getNodes()) { + if (node instanceof DecoratorNode) { + nextKeys.add(node.getKey()); + } + } + } + }); + return nextKeys; + }; + + const unregisterUpdate = editor.registerUpdateListener(() => { + applyKeys(hasFocus ? readSelectedKeys() : new Set()); + }); + const unregisterFocus = editor.registerCommand( + FOCUS_COMMAND, + () => { + hasFocus = true; + applyKeys(readSelectedKeys()); + return false; + }, + COMMAND_PRIORITY_LOW, + ); + const unregisterBlur = editor.registerCommand( + BLUR_COMMAND, + () => { + hasFocus = false; + applyKeys(new Set()); + return false; + }, + COMMAND_PRIORITY_LOW, + ); + return () => { + unregisterUpdate(); + unregisterFocus(); + unregisterBlur(); + }; + }, [editor]); + + return null; +} + function ComposerInlineTokenPastePlugin() { const [editor] = useLexicalComposerContext(); @@ -1701,6 +1778,7 @@ function ComposerPromptEditorInner({ +
diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 9ce9d3baf7f9..521ec5b3bb83 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { + archiveSelectedThreadEntries, + buildMultiSelectThreadContextMenuItems, buildSidebarThreadTreeRows, createThreadJumpHintVisibilityController, filterSidebarThreadTreeRowsByExpansion, @@ -45,6 +47,73 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("archiveSelectedThreadEntries", () => { + const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; + const success = { _tag: "Success" } as const; + const failure = { _tag: "Failure" } as const; + + it("records every entry after full success", async () => { + const outcome = await archiveSelectedThreadEntries({ + entries, + archive: async (_entry, onArchived) => { + onArchived(); + return success; + }, + }); + + expect(outcome).toEqual({ + archivedThreadKeys: ["one", "two", "three"], + mutationFailure: null, + followupFailures: [], + }); + }); + + it("stops at a mutation failure and retains prior successes", async () => { + const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { + if (entry.threadKey === "two") return failure; + onArchived(); + return success; + }); + const outcome = await archiveSelectedThreadEntries({ entries, archive }); + + expect(archive).toHaveBeenCalledTimes(2); + expect(outcome).toEqual({ + archivedThreadKeys: ["one"], + mutationFailure: failure, + followupFailures: [], + }); + }); + + it("continues after a post-archive failure", async () => { + const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { + onArchived(); + return entry.threadKey === "two" ? failure : success; + }); + const outcome = await archiveSelectedThreadEntries({ entries, archive }); + + expect(archive).toHaveBeenCalledTimes(3); + expect(outcome).toEqual({ + archivedThreadKeys: ["one", "two", "three"], + mutationFailure: null, + followupFailures: [failure], + }); + }); +}); + +describe("buildMultiSelectThreadContextMenuItems", () => { + it("offers bulk archive with the selected count", () => { + expect( + buildMultiSelectThreadContextMenuItems({ count: 3, hasRunningThread: false }), + ).toContainEqual({ id: "archive", label: "Archive (3)", disabled: false }); + }); + + it("disables bulk archive when a selected thread is running", () => { + expect( + buildMultiSelectThreadContextMenuItems({ count: 2, hasRunningThread: true }), + ).toContainEqual({ id: "archive", label: "Archive (2)", disabled: true }); + }); +}); + describe("resolveSidebarStageBadgeLabel", () => { it("returns Nightly for nightly primary server versions", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 17abc5713916..af5f1496de30 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { getThreadSortTimestamp, @@ -36,6 +37,55 @@ type ScopedSidebarThread = ThreadSortInput & { export type ThreadTraversalDirection = "previous" | "next"; +export async function archiveSelectedThreadEntries< + TEntry extends { readonly threadKey: string }, + TResult extends { readonly _tag: "Success" | "Failure" }, +>(input: { + entries: readonly TEntry[]; + archive: (entry: TEntry, onArchived: () => void) => Promise; +}): Promise<{ + archivedThreadKeys: readonly string[]; + mutationFailure: Extract | null; + followupFailures: readonly Extract[]; +}> { + const archivedThreadKeys: string[] = []; + const followupFailures: Extract[] = []; + + for (const entry of input.entries) { + let didArchive = false; + const result = await input.archive(entry, () => { + didArchive = true; + }); + if (didArchive || result._tag === "Success") { + archivedThreadKeys.push(entry.threadKey); + } + if (result._tag === "Success") continue; + const failure = result as Extract; + if (didArchive) { + followupFailures.push(failure); + continue; + } + return { archivedThreadKeys, mutationFailure: failure, followupFailures }; + } + + return { archivedThreadKeys, mutationFailure: null, followupFailures }; +} + +export function buildMultiSelectThreadContextMenuItems(input: { + count: number; + hasRunningThread: boolean; +}): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] { + return [ + { id: "mark-unread", label: `Mark unread (${input.count})` }, + { + id: "archive", + label: `Archive (${input.count})`, + disabled: input.hasRunningThread, + }, + { id: "delete", label: `Delete (${input.count})`, destructive: true }, + ]; +} + export interface ThreadStatusPill { label: | "Working" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 59b13f24b3c9..94f43c435c55 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -198,6 +198,8 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { + archiveSelectedThreadEntries, + buildMultiSelectThreadContextMenuItems, buildSidebarThreadTreeRows, filterSidebarThreadTreeRowsByExpansion, getSidebarThreadIdsToPrewarm, @@ -2017,24 +2019,69 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; if (threadKeys.length === 0) return; const count = threadKeys.length; + const selectedThreadEntries = threadKeys.flatMap((threadKey) => { + const threadRef = parseScopedThreadKey(threadKey); + const thread = threadRef ? readThreadShell(threadRef) : null; + return threadRef && thread ? [{ threadKey, threadRef, thread }] : []; + }); + const hasRunningThread = selectedThreadEntries.some( + ({ thread }) => thread.session?.status === "running" && thread.session.activeTurnId != null, + ); const clicked = await api.contextMenu.show( - [ - { id: "mark-unread", label: `Mark unread (${count})` }, - { id: "delete", label: `Delete (${count})`, destructive: true }, - ], + buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }), position, ); if (clicked === "mark-unread") { - for (const threadKey of threadKeys) { - const thread = sidebarThreadByKeyRef.current.get(threadKey); - markThreadUnread(threadKey, thread?.latestTurn?.completedAt); + for (const { threadKey, thread } of selectedThreadEntries) { + markThreadUnread(threadKey, thread.latestTurn?.completedAt); } clearSelection(); return; } + if (clicked === "archive") { + if (appSettingsConfirmThreadArchive) { + const confirmed = await api.dialogs.confirm( + `Archive ${count} thread${count === 1 ? "" : "s"}?`, + ); + if (!confirmed) return; + } + + const archiveOutcome = await archiveSelectedThreadEntries({ + entries: selectedThreadEntries, + archive: ({ threadRef }, onArchived) => archiveThread(threadRef, { onArchived }), + }); + for (const failure of archiveOutcome.followupFailures) { + if (isAtomCommandInterrupted(failure)) continue; + const error = squashAtomCommandFailure(failure); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread archived, but navigation failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + if (archiveOutcome.mutationFailure) { + removeFromSelection(archiveOutcome.archivedThreadKeys); + if (!isAtomCommandInterrupted(archiveOutcome.mutationFailure)) { + const error = squashAtomCommandFailure(archiveOutcome.mutationFailure); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to archive threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + removeFromSelection(threadKeys); + return; + } + if (clicked !== "delete") return; if (appSettingsConfirmThreadDelete) { @@ -2050,10 +2097,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const deletedThreadKeys = new Set(threadKeys); const completedThreadKeys: string[] = []; - for (const threadKey of threadKeys) { - const thread = sidebarThreadByKeyRef.current.get(threadKey); - if (!thread) continue; - const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { + for (const { threadKey, threadRef, thread } of selectedThreadEntries) { + const result = await deleteThread(threadRef, { deletedThreadKeys, ...(appSettingsConfirmThreadDelete ? { confirmedOwnedWorktreePath: ownedWorktreePathForThreadDelete(thread) } @@ -2082,7 +2127,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec removeFromSelection(completedThreadKeys); }, [ + appSettingsConfirmThreadArchive, appSettingsConfirmThreadDelete, + archiveThread, clearSelection, deleteThread, markThreadUnread, diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index c371acdb3620..120827b34b2a 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -2,7 +2,32 @@ import { TurnId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { ChangedFilesTree } from "./ChangedFilesTree"; +import { ChangedFilesCard, ChangedFilesTree } from "./ChangedFilesTree"; + +describe("ChangedFilesCard", () => { + it("keeps its compact header sticky while preserving singular labels", () => { + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + />, + ); + + expect(markup).toContain('class="sticky top-0 z-10'); + expect(markup).not.toContain("self-start"); + expect(markup).toContain("whitespace-nowrap"); + expect(markup).toContain("!size-[22px]"); + expect(markup).toContain("size-3"); + expect(markup).toContain('aria-label="Collapse all"'); + expect(markup).toContain('aria-label="View diff"'); + expect(markup).toContain("1 changed file"); + expect(markup).not.toContain("1 changed files"); + }); +}); describe("ChangedFilesTree", () => { it.each([ diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 3ea0e6315fdb..6fc1462c5e6a 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -6,11 +6,19 @@ import { summarizeTurnDiffStats, type TurnDiffTreeNode, } from "../../lib/turnDiffTree"; -import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; +import { + ChevronsDownUpIcon, + ChevronsUpDownIcon, + ChevronRightIcon, + FileDiffIcon, + FolderIcon, + FolderClosedIcon, +} from "lucide-react"; import { cn } from "~/lib/utils"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -33,10 +41,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); return ( -
-
-

- {files.length} changed files +

+
+

+ + {files.length} changed file{files.length === 1 ? "" : "s"} + {hasNonZeroStat(summaryStat) && (

- - + + + } + > + {allDirectoriesExpanded ? ( + + ) : ( + + )} + + + {allDirectoriesExpanded ? "Collapse all" : "Expand all"} + + + + onOpenTurnDiff(turnId, files[0]?.path)} + /> + } + > + + + View diff +
-
- -
+
); }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7d278c2ce48d..5fa86d958202 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -42,6 +42,10 @@ import { shouldSubmitComposerOnEnter, } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; +import { + dataTransferHasComposerMention, + makeComposerMentionDragHandlers, +} from "./composerMentionDrag"; import { type ComposerImageAttachment, type DraftId, @@ -1938,6 +1942,67 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerImages(files); focusComposer(); }; + + const insertComposerTextAtEnd = ( + text: string, + options?: { ensureLeadingBoundary?: boolean }, + ): boolean => { + if ( + text.length === 0 || + isConnecting || + isComposerApprovalState || + pendingUserInputs.length > 0 || + projectSelectionRequired || + (environmentUnavailable !== null && activePendingProgress === null) + ) { + return false; + } + const prompt = promptRef.current; + const needsLeadingSpace = + (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt); + return applyPromptReplacement( + prompt.length, + prompt.length, + needsLeadingSpace ? ` ${text}` : text, + ); + }; + + // File-tree drags land as mentions. Handled in the capture phase so the + // editor never sees the drop; the load-bearing rules (native stop, "move" + // effect, no eager focus) live in makeComposerMentionDragHandlers. + const composerMentionDragHandlers = makeComposerMentionDragHandlers({ + insertMentionAtEnd: (text) => insertComposerTextAtEnd(text, { ensureLeadingBoundary: true }), + setDragActive: setIsDragOverComposer, + onInsertRejected: () => { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "The composer is busy; try again once it is ready.", + }); + }, + }); + + const onComposerMentionDragLeaveCapture = (event: React.DragEvent) => { + if (!dataTransferHasComposerMention(event.dataTransfer.types)) return; + event.stopPropagation(); + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; + setIsDragOverComposer(false); + }; + + // A cancelled drag (Escape) can end without a dragleave on the hovered + // target, which would leave the drop highlight stuck. dragend always fires + // on the in-page drag source and bubbles to window, so it is the reset of + // last resort while the highlight is up. + useEffect(() => { + if (!isDragOverComposer) return; + const onWindowDragEnd = () => { + dragDepthRef.current = 0; + setIsDragOverComposer(false); + }; + window.addEventListener("dragend", onWindowDragEnd); + return () => window.removeEventListener("dragend", onWindowDragEnd); + }, [isDragOverComposer]); const handleInterruptPrimaryAction = useCallback(() => { void onInterrupt(); }, [onInterrupt]); @@ -2001,25 +2066,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, - insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => { - if ( - text.length === 0 || - isConnecting || - isComposerApprovalState || - pendingUserInputs.length > 0 || - projectSelectionRequired - ) { - return false; - } - const prompt = promptRef.current; - const needsLeadingSpace = - (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt); - return applyPromptReplacement( - prompt.length, - prompt.length, - needsLeadingSpace ? ` ${text}` : text, - ); - }, + insertTextAtEnd: insertComposerTextAtEnd, openModelPicker: () => { setIsComposerModelPickerOpen(true); }, @@ -2178,6 +2225,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onDragOver={onComposerDragOver} onDragLeave={onComposerDragLeave} onDrop={onComposerDrop} + onDragEnterCapture={composerMentionDragHandlers.onDragEnter} + onDragOverCapture={composerMentionDragHandlers.onDragOver} + onDragLeaveCapture={onComposerMentionDragLeaveCapture} + onDropCapture={composerMentionDragHandlers.onDrop} >
{ + it("renders complete multiline command details without hover or truncation", () => { + const detail = `bun run release -- ${"long-argument ".repeat(20)}\nsecond line`; + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-approval-detail="complete"'); + expect(markup).toContain('aria-label="Command"'); + expect(markup).toContain(detail); + expect(markup).not.toContain("truncate"); + expect(markup).not.toContain("line-clamp"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index 569fd108a4a8..dd966bf57952 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -16,6 +16,12 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova : approval.requestKind === "file-read" ? "File-read approval requested" : "File-change approval requested"; + const detailLabel = + approval.requestKind === "command" + ? "Command" + : approval.requestKind === "file-read" + ? "File to read" + : "File change"; return (
@@ -26,6 +32,18 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova 1/{pendingCount} ) : null}
+ {approval.detail ? ( +
+

{detailLabel}

+
+            {approval.detail}
+          
+
+ ) : null}
); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 249d4c8dc5a9..a5be24b1e228 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -64,6 +64,45 @@ export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number) return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER; } +export const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12; +export const TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH = 40; +export const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem"; + +/** + * The minimap overlays the viewport's left edge while the content column is + * centered, so the side gutter between them shrinks under browser zoom or a + * narrow pane. A fixed-width hover strip would then sit on top of the message + * text and swallow its pointer events. Cap the strip's width so it never + * extends past the gutter into the content column; 0 disables the strip. + */ +export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): number { + if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) { + return 0; + } + + const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH); + const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2); + return Math.max( + 0, + Math.min( + TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH, + Math.floor(sideGutter) - TIMELINE_MINIMAP_HIT_STRIP_LEFT, + ), + ); +} + +/** + * Once the preview is open, keep the full preview and the space leading to it + * interactive. The collapsed strip remains gutter-capped so it cannot block + * selecting message text. + */ +export function resolveTimelineMinimapInteractiveWidth( + collapsedWidth: number, + expanded: boolean, +): number | string { + return expanded ? TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH : collapsedWidth; +} + function computeElapsedMs(startIso: string, endIso: string): number | null { const start = Date.parse(startIso); const end = Date.parse(endIso); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 019df6356c18..81a3b4219f73 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,4 +1,4 @@ -import { EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; +import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; import { createRef, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; @@ -274,12 +274,66 @@ describe("MessagesTimeline", () => { expect(normalMarkup).not.toContain("answered by"); }); + it("keeps assistant changed-files headers sticky below the thread header", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const assistantMessageId = MessageId.make("message-assistant-with-files"); + const turnId = TurnId.make("turn-with-files"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('class="sticky top-2 z-10'); + expect(markup).not.toContain("self-start"); + expect(markup).toContain("whitespace-nowrap"); + expect(markup).toContain("!size-[22px]"); + expect(markup).toContain("size-3"); + expect(markup).toContain('aria-label="Collapse all"'); + expect(markup).toContain('aria-label="View diff"'); + expect(markup).toContain("1 changed file"); + }); + it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => { const { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, + resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, + resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, } = await import("./MessagesTimeline.logic"); @@ -309,6 +363,28 @@ describe("MessagesTimeline", () => { expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false); expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false); expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true); + + // No usable gutter (zoomed in / narrow pane): the strip must go inert + // instead of overlaying the centered content column. + expect(resolveTimelineMinimapHitStripWidth(768)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(792)).toBe(0); + // Partial gutter: strip shrinks to what fits between the viewport edge + // and the content column. + expect(resolveTimelineMinimapHitStripWidth(820)).toBe(14); + // Full gutter: unchanged 40px-wide strip. + expect(resolveTimelineMinimapHitStripWidth(872)).toBe(40); + expect(resolveTimelineMinimapHitStripWidth(1400)).toBe(40); + expect(resolveTimelineMinimapHitStripWidth(0)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(Number.NaN)).toBe(0); + + // The collapsed target stays narrow, but an open preview keeps its full + // 20rem width plus the 2rem offset from the minimap rail interactive. + expect(resolveTimelineMinimapInteractiveWidth(0, false)).toBe(0); + expect(resolveTimelineMinimapInteractiveWidth(14, false)).toBe(14); + expect(resolveTimelineMinimapInteractiveWidth(40, false)).toBe(40); + expect(resolveTimelineMinimapInteractiveWidth(0, true)).toBe("22rem"); + expect(resolveTimelineMinimapInteractiveWidth(14, true)).toBe("22rem"); + expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem"); }); it("anchors a sent attachment message using its measured height", async () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f2a07f1f1569..4a03afef8d84 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -44,8 +44,11 @@ import { CheckIcon, ChevronDownIcon, ChevronRightIcon, + ChevronsDownUpIcon, + ChevronsUpDownIcon, CircleAlertIcon, EyeIcon, + FileDiffIcon, GlobeIcon, HammerIcon, MessageCircleIcon, @@ -78,7 +81,9 @@ import { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, + resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, + resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, type StableMessagesTimelineRowsState, type MessagesTimelineRow, @@ -344,6 +349,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ null, ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); + const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -415,6 +421,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ setMinimapHasPersistentGutter((current) => current === nextHasPersistentGutter ? current : nextHasPersistentGutter, ); + setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth)); }; const frame = requestAnimationFrame(measure); @@ -533,6 +540,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ items={minimapItems} bottomInset={contentInsetEndAdjustment} hasPersistentGutter={minimapHasPersistentGutter} + hitStripWidth={minimapHitStripWidth} stripMap={minimapStripMap} onSelect={(item) => { onManualNavigation(); @@ -627,15 +635,21 @@ function resolveTimelineRowHeight(state: TimelinePositionState, rowIndex: number return typeof height === "number" && Number.isFinite(height) ? height : null; } +function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { + return target instanceof Element && target.closest("[data-minimap-preview]") !== null; +} + function TimelineMinimap({ bottomInset, hasPersistentGutter, + hitStripWidth, items, stripMap, onSelect, }: { bottomInset: number; hasPersistentGutter: boolean; + hitStripWidth: number; items: ReadonlyArray; stripMap: Map; onSelect: (item: TimelineMinimapItem) => void; @@ -698,7 +712,7 @@ function TimelineMinimap({ return (