| author | |
| committer | |
| log | 055897f0eafd8c2ba981f9d0ced1c21052035f5b |
| tree | df1ea9d2b05502e629d7745a23dff30f57aabc81 |
| parent | 40b3a800347462405b2d322ed5c0c60331f9e70d |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 177 insertions(+), 11 deletions(-)
apps/desktop/src/main.ts+12-10| ... | @@ -75,6 +75,7 @@ import { | ... | @@ -75,6 +75,7 @@ import { |
| 75 | } from "./updateMachine.ts"; | 75 | } from "./updateMachine.ts"; |
| 76 | import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; | 76 | import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; |
| 77 | import { resolveDesktopAppBranding } from "./appBranding.ts"; | 77 | import { resolveDesktopAppBranding } from "./appBranding.ts"; |
| 78 | import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; | ||
| 78 | 79 | ||
| 79 | syncShellEnvironment(); | 80 | syncShellEnvironment(); |
| 80 | 81 | ||
| ... | @@ -1998,16 +1999,17 @@ function createWindow(): BrowserWindow { | ... | @@ -1998,16 +1999,17 @@ function createWindow(): BrowserWindow { |
| 1998 | emitUpdateState(); | 1999 | emitUpdateState(); |
| 1999 | }); | 2000 | }); |
| 2000 | 2001 | ||
| 2001 | let initialRevealScheduled = false; | 2002 | // On Linux/Wayland with `show: false`, Electron's `ready-to-show` only |
| 2002 | const revealInitialWindow = () => { | 2003 | // fires after `show()` is called, deadlocking the standard "wait for |
| 2003 | if (initialRevealScheduled) { | 2004 | // ready, then show" pattern. Add `did-finish-load` as a Linux-only |
| 2004 | return; | 2005 | // fallback so the window still surfaces once the renderer has loaded |
| 2005 | } | 2006 | // the page. Other platforms keep the no-flash `ready-to-show` path, |
| 2006 | initialRevealScheduled = true; | 2007 | // since `did-finish-load` typically fires before the first paint there. |
| 2007 | revealWindow(window); | 2008 | const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; |
| 2008 | }; | 2009 | if (process.platform === "linux") { |
| 2009 | 2010 | revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); | |
| 2010 | window.once("ready-to-show", revealInitialWindow); | 2011 | } |
| 2012 | bindFirstRevealTrigger(revealSubscribers, () => revealWindow(window)); | ||
| 2011 | 2013 | ||
| 2012 | if (isDevelopment) { | 2014 | if (isDevelopment) { |
| 2013 | void window.loadURL(resolveDesktopDevServerUrl()); | 2015 | void window.loadURL(resolveDesktopDevServerUrl()); |
apps/desktop/src/windowReveal.test.ts created+74| ... | @@ -0,0 +1,74 @@ | ||
| 1 | import { EventEmitter } from "node:events"; | ||
| 2 | |||
| 3 | import { describe, expect, it, vi } from "vitest"; | ||
| 4 | |||
| 5 | import { bindFirstRevealTrigger } from "./windowReveal.ts"; | ||
| 6 | |||
| 7 | describe("bindFirstRevealTrigger", () => { | ||
| 8 | it("reveals when the first trigger fires", () => { | ||
| 9 | const window = new EventEmitter(); | ||
| 10 | const webContents = new EventEmitter(); | ||
| 11 | const reveal = vi.fn(); | ||
| 12 | |||
| 13 | bindFirstRevealTrigger( | ||
| 14 | [ | ||
| 15 | (fire) => window.once("ready-to-show", fire), | ||
| 16 | (fire) => webContents.once("did-finish-load", fire), | ||
| 17 | ], | ||
| 18 | reveal, | ||
| 19 | ); | ||
| 20 | |||
| 21 | window.emit("ready-to-show"); | ||
| 22 | |||
| 23 | expect(reveal).toHaveBeenCalledTimes(1); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("reveals when only the fallback trigger fires (Wayland deadlock case)", () => { | ||
| 27 | const window = new EventEmitter(); | ||
| 28 | const webContents = new EventEmitter(); | ||
| 29 | const reveal = vi.fn(); | ||
| 30 | |||
| 31 | bindFirstRevealTrigger( | ||
| 32 | [ | ||
| 33 | (fire) => window.once("ready-to-show", fire), | ||
| 34 | (fire) => webContents.once("did-finish-load", fire), | ||
| 35 | ], | ||
| 36 | reveal, | ||
| 37 | ); | ||
| 38 | |||
| 39 | webContents.emit("did-finish-load"); | ||
| 40 | |||
| 41 | expect(reveal).toHaveBeenCalledTimes(1); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("only reveals once when multiple triggers fire", () => { | ||
| 45 | const window = new EventEmitter(); | ||
| 46 | const webContents = new EventEmitter(); | ||
| 47 | const reveal = vi.fn(); | ||
| 48 | |||
| 49 | bindFirstRevealTrigger( | ||
| 50 | [ | ||
| 51 | (fire) => window.once("ready-to-show", fire), | ||
| 52 | (fire) => webContents.once("did-finish-load", fire), | ||
| 53 | ], | ||
| 54 | reveal, | ||
| 55 | ); | ||
| 56 | |||
| 57 | webContents.emit("did-finish-load"); | ||
| 58 | window.emit("ready-to-show"); | ||
| 59 | |||
| 60 | expect(reveal).toHaveBeenCalledTimes(1); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("subscribers using `once` ignore re-emitted events after reveal", () => { | ||
| 64 | const window = new EventEmitter(); | ||
| 65 | const reveal = vi.fn(); | ||
| 66 | |||
| 67 | bindFirstRevealTrigger([(fire) => window.once("ready-to-show", fire)], reveal); | ||
| 68 | |||
| 69 | window.emit("ready-to-show"); | ||
| 70 | window.emit("ready-to-show"); | ||
| 71 | |||
| 72 | expect(reveal).toHaveBeenCalledTimes(1); | ||
| 73 | }); | ||
| 74 | }); | ||
apps/desktop/src/windowReveal.ts created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | export type RevealSubscription = (listener: () => void) => void; | ||
| 2 | |||
| 3 | /** | ||
| 4 | * Wire a reveal callback to fire exactly once, on whichever of the provided | ||
| 5 | * event subscribers fires first. Each subscriber is responsible for binding | ||
| 6 | * its own event source. | ||
| 7 | * | ||
| 8 | * Used by the desktop main window's first-paint reveal logic. The standard | ||
| 9 | * Electron pattern is to wait for `ready-to-show` before calling `show()`, | ||
| 10 | * but on Linux/Wayland with `show: false`, `ready-to-show` only fires after | ||
| 11 | * `show()` is called, deadlocking that pattern. Subscribing to both | ||
| 12 | * `ready-to-show` and `did-finish-load` (or any other "renderer is alive" | ||
| 13 | * signal) lets the window surface reliably across platforms. | ||
| 14 | */ | ||
| 15 | export function bindFirstRevealTrigger( | ||
| 16 | subscribers: readonly RevealSubscription[], | ||
| 17 | reveal: () => void, | ||
| 18 | ): void { | ||
| 19 | let revealed = false; | ||
| 20 | const fire = () => { | ||
| 21 | if (revealed) return; | ||
| 22 | revealed = true; | ||
| 23 | reveal(); | ||
| 24 | }; | ||
| 25 | for (const subscribe of subscribers) { | ||
| 26 | subscribe(fire); | ||
| 27 | } | ||
| 28 | } | ||
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+32-1| ... | @@ -16,9 +16,12 @@ import { | ... | @@ -16,9 +16,12 @@ import { |
| 16 | import { OpenCodeProviderLive } from "./OpenCodeProvider.ts"; | 16 | import { OpenCodeProviderLive } from "./OpenCodeProvider.ts"; |
| 17 | import type { OpenCodeInventory } from "../opencodeRuntime.ts"; | 17 | import type { OpenCodeInventory } from "../opencodeRuntime.ts"; |
| 18 | 18 | ||
| 19 | const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; | ||
| 20 | |||
| 19 | const runtimeMock = { | 21 | const runtimeMock = { |
| 20 | state: { | 22 | state: { |
| 21 | runVersionError: null as Error | null, | 23 | runVersionError: null as Error | null, |
| 24 | versionStdout: DEFAULT_VERSION_STDOUT, | ||
| 22 | inventoryError: null as Error | null, | 25 | inventoryError: null as Error | null, |
| 23 | inventory: { | 26 | inventory: { |
| 24 | providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, | 27 | providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, |
| ... | @@ -27,6 +30,7 @@ const runtimeMock = { | ... | @@ -27,6 +30,7 @@ const runtimeMock = { |
| 27 | }, | 30 | }, |
| 28 | reset() { | 31 | reset() { |
| 29 | this.state.runVersionError = null; | 32 | this.state.runVersionError = null; |
| 33 | this.state.versionStdout = DEFAULT_VERSION_STDOUT; | ||
| 30 | this.state.inventoryError = null; | 34 | this.state.inventoryError = null; |
| 31 | this.state.inventory = { | 35 | this.state.inventory = { |
| 32 | providerList: { connected: [], all: [] as unknown[], default: {} }, | 36 | providerList: { connected: [], all: [] as unknown[], default: {} }, |
| ... | @@ -56,7 +60,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ... | @@ -56,7 +60,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 56 | cause: runtimeMock.state.runVersionError, | 60 | cause: runtimeMock.state.runVersionError, |
| 57 | }), | 61 | }), |
| 58 | ) | 62 | ) |
| 59 | : Effect.succeed({ stdout: "opencode 1.0.0\n", stderr: "", code: 0 }), | 63 | : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), |
| 60 | createOpenCodeSdkClient: () => | 64 | createOpenCodeSdkClient: () => |
| 61 | ({}) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>, | 65 | ({}) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>, |
| 62 | loadOpenCodeInventory: () => | 66 | loadOpenCodeInventory: () => |
| ... | @@ -108,6 +112,33 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { | ... | @@ -108,6 +112,33 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { |
| 108 | }), | 112 | }), |
| 109 | ); | 113 | ); |
| 110 | 114 | ||
| 115 | it.effect("refuses to probe when opencode is older than the required minimum", () => | ||
| 116 | Effect.gen(function* () { | ||
| 117 | runtimeMock.state.versionStdout = "opencode 1.4.7\n"; | ||
| 118 | const provider = yield* OpenCodeProvider; | ||
| 119 | const snapshot = yield* provider.refresh; | ||
| 120 | |||
| 121 | assert.equal(snapshot.status, "error"); | ||
| 122 | assert.equal(snapshot.installed, true); | ||
| 123 | assert.equal(snapshot.version, "1.4.7"); | ||
| 124 | assert.ok(snapshot.message?.includes("1.14.19")); | ||
| 125 | assert.ok(snapshot.message?.toLowerCase().includes("upgrade")); | ||
| 126 | }), | ||
| 127 | ); | ||
| 128 | |||
| 129 | it.effect("refuses to probe when opencode --version output is unparseable", () => | ||
| 130 | Effect.gen(function* () { | ||
| 131 | runtimeMock.state.versionStdout = "garbled binary output\n"; | ||
| 132 | const provider = yield* OpenCodeProvider; | ||
| 133 | const snapshot = yield* provider.refresh; | ||
| 134 | |||
| 135 | assert.equal(snapshot.status, "error"); | ||
| 136 | assert.equal(snapshot.installed, true); | ||
| 137 | assert.equal(snapshot.version, null); | ||
| 138 | assert.ok(snapshot.message?.includes("1.14.19")); | ||
| 139 | }), | ||
| 140 | ); | ||
| 141 | |||
| 111 | it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => | 142 | it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => |
| 112 | Effect.gen(function* () { | 143 | Effect.gen(function* () { |
| 113 | runtimeMock.state.inventory = { | 144 | runtimeMock.state.inventory = { |
apps/server/src/provider/Layers/OpenCodeProvider.ts+31| ... | @@ -15,6 +15,7 @@ import { | ... | @@ -15,6 +15,7 @@ import { |
| 15 | parseGenericCliVersion, | 15 | parseGenericCliVersion, |
| 16 | providerModelsFromSettings, | 16 | providerModelsFromSettings, |
| 17 | } from "../providerSnapshot.ts"; | 17 | } from "../providerSnapshot.ts"; |
| 18 | import { compareCliVersions } from "../cliVersion.ts"; | ||
| 18 | import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; | 19 | import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; |
| 19 | import { | 20 | import { |
| 20 | OpenCodeRuntime, | 21 | OpenCodeRuntime, |
| ... | @@ -24,6 +25,7 @@ import { | ... | @@ -24,6 +25,7 @@ import { |
| 24 | import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; | 25 | import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; |
| 25 | 26 | ||
| 26 | const PROVIDER = "opencode" as const; | 27 | const PROVIDER = "opencode" as const; |
| 28 | const MINIMUM_OPENCODE_VERSION = "1.14.19"; | ||
| 27 | 29 | ||
| 28 | class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ | 30 | class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ |
| 29 | readonly cause: unknown; | 31 | readonly cause: unknown; |
| ... | @@ -354,6 +356,35 @@ export const OpenCodeProviderLive = Layer.effect( | ... | @@ -354,6 +356,35 @@ export const OpenCodeProviderLive = Layer.effect( |
| 354 | return fallback(Cause.squash(versionExit.cause)); | 356 | return fallback(Cause.squash(versionExit.cause)); |
| 355 | } | 357 | } |
| 356 | version = parseGenericCliVersion(versionExit.value.stdout) ?? null; | 358 | version = parseGenericCliVersion(versionExit.value.stdout) ?? null; |
| 359 | |||
| 360 | if (!version) { | ||
| 361 | return fallback( | ||
| 362 | new Error( | ||
| 363 | `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, | ||
| 364 | ), | ||
| 365 | null, | ||
| 366 | ); | ||
| 367 | } | ||
| 368 | if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { | ||
| 369 | return buildServerProvider({ | ||
| 370 | provider: PROVIDER, | ||
| 371 | enabled: input.settings.enabled, | ||
| 372 | checkedAt, | ||
| 373 | models: providerModelsFromSettings( | ||
| 374 | [], | ||
| 375 | PROVIDER, | ||
| 376 | customModels, | ||
| 377 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | ||
| 378 | ), | ||
| 379 | probe: { | ||
| 380 | installed: true, | ||
| 381 | version, | ||
| 382 | status: "error", | ||
| 383 | auth: { status: "unknown" }, | ||
| 384 | message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, | ||
| 385 | }, | ||
| 386 | }); | ||
| 387 | } | ||
| 357 | } | 388 | } |
| 358 | 389 | ||
| 359 | const inventoryExit = yield* Effect.exit( | 390 | const inventoryExit = yield* Effect.exit( |