Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
} from "./updateMachine.ts";
import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
import { resolveDesktopAppBranding } from "./appBranding.ts";
import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts";

syncShellEnvironment();

Expand Down Expand Up @@ -1998,16 +1999,17 @@ function createWindow(): BrowserWindow {
emitUpdateState();
});

let initialRevealScheduled = false;
const revealInitialWindow = () => {
if (initialRevealScheduled) {
return;
}
initialRevealScheduled = true;
revealWindow(window);
};

window.once("ready-to-show", revealInitialWindow);
// On Linux/Wayland with `show: false`, Electron's `ready-to-show` only
// fires after `show()` is called, deadlocking the standard "wait for
// ready, then show" pattern. Add `did-finish-load` as a Linux-only
// fallback so the window still surfaces once the renderer has loaded
// the page. Other platforms keep the no-flash `ready-to-show` path,
// since `did-finish-load` typically fires before the first paint there.
const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)];
if (process.platform === "linux") {
revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire));
}
bindFirstRevealTrigger(revealSubscribers, () => revealWindow(window));

if (isDevelopment) {
void window.loadURL(resolveDesktopDevServerUrl());
Expand Down
74 changes: 74 additions & 0 deletions apps/desktop/src/windowReveal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { EventEmitter } from "node:events";

import { describe, expect, it, vi } from "vitest";

import { bindFirstRevealTrigger } from "./windowReveal.ts";

describe("bindFirstRevealTrigger", () => {
it("reveals when the first trigger fires", () => {
const window = new EventEmitter();
const webContents = new EventEmitter();
const reveal = vi.fn();

bindFirstRevealTrigger(
[
(fire) => window.once("ready-to-show", fire),
(fire) => webContents.once("did-finish-load", fire),
],
reveal,
);

window.emit("ready-to-show");

expect(reveal).toHaveBeenCalledTimes(1);
});

it("reveals when only the fallback trigger fires (Wayland deadlock case)", () => {
const window = new EventEmitter();
const webContents = new EventEmitter();
const reveal = vi.fn();

bindFirstRevealTrigger(
[
(fire) => window.once("ready-to-show", fire),
(fire) => webContents.once("did-finish-load", fire),
],
reveal,
);

webContents.emit("did-finish-load");

expect(reveal).toHaveBeenCalledTimes(1);
});

it("only reveals once when multiple triggers fire", () => {
const window = new EventEmitter();
const webContents = new EventEmitter();
const reveal = vi.fn();

bindFirstRevealTrigger(
[
(fire) => window.once("ready-to-show", fire),
(fire) => webContents.once("did-finish-load", fire),
],
reveal,
);

webContents.emit("did-finish-load");
window.emit("ready-to-show");

expect(reveal).toHaveBeenCalledTimes(1);
});

it("subscribers using `once` ignore re-emitted events after reveal", () => {
const window = new EventEmitter();
const reveal = vi.fn();

bindFirstRevealTrigger([(fire) => window.once("ready-to-show", fire)], reveal);

window.emit("ready-to-show");
window.emit("ready-to-show");

expect(reveal).toHaveBeenCalledTimes(1);
});
});
28 changes: 28 additions & 0 deletions apps/desktop/src/windowReveal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export type RevealSubscription = (listener: () => void) => void;

/**
* Wire a reveal callback to fire exactly once, on whichever of the provided
* event subscribers fires first. Each subscriber is responsible for binding
* its own event source.
*
* Used by the desktop main window's first-paint reveal logic. The standard
* Electron pattern is to wait for `ready-to-show` before calling `show()`,
* but on Linux/Wayland with `show: false`, `ready-to-show` only fires after
* `show()` is called, deadlocking that pattern. Subscribing to both
* `ready-to-show` and `did-finish-load` (or any other "renderer is alive"
* signal) lets the window surface reliably across platforms.
*/
export function bindFirstRevealTrigger(
subscribers: readonly RevealSubscription[],
reveal: () => void,
): void {
let revealed = false;
const fire = () => {
if (revealed) return;
revealed = true;
reveal();
};
for (const subscribe of subscribers) {
subscribe(fire);
}
}
33 changes: 32 additions & 1 deletion apps/server/src/provider/Layers/OpenCodeProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ import {
import { OpenCodeProviderLive } from "./OpenCodeProvider.ts";
import type { OpenCodeInventory } from "../opencodeRuntime.ts";

const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n";

const runtimeMock = {
state: {
runVersionError: null as Error | null,
versionStdout: DEFAULT_VERSION_STDOUT,
inventoryError: null as Error | null,
inventory: {
providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
Expand All @@ -27,6 +30,7 @@ const runtimeMock = {
},
reset() {
this.state.runVersionError = null;
this.state.versionStdout = DEFAULT_VERSION_STDOUT;
this.state.inventoryError = null;
this.state.inventory = {
providerList: { connected: [], all: [] as unknown[], default: {} },
Expand Down Expand Up @@ -56,7 +60,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
cause: runtimeMock.state.runVersionError,
}),
)
: Effect.succeed({ stdout: "opencode 1.0.0\n", stderr: "", code: 0 }),
: Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),
createOpenCodeSdkClient: () =>
({}) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
loadOpenCodeInventory: () =>
Expand Down Expand Up @@ -108,6 +112,33 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {
}),
);

it.effect("refuses to probe when opencode is older than the required minimum", () =>
Effect.gen(function* () {
runtimeMock.state.versionStdout = "opencode 1.4.7\n";
const provider = yield* OpenCodeProvider;
const snapshot = yield* provider.refresh;

assert.equal(snapshot.status, "error");
assert.equal(snapshot.installed, true);
assert.equal(snapshot.version, "1.4.7");
assert.ok(snapshot.message?.includes("1.14.19"));
assert.ok(snapshot.message?.toLowerCase().includes("upgrade"));
}),
);

it.effect("refuses to probe when opencode --version output is unparseable", () =>
Effect.gen(function* () {
runtimeMock.state.versionStdout = "garbled binary output\n";
const provider = yield* OpenCodeProvider;
const snapshot = yield* provider.refresh;

assert.equal(snapshot.status, "error");
assert.equal(snapshot.installed, true);
assert.equal(snapshot.version, null);
assert.ok(snapshot.message?.includes("1.14.19"));
}),
);

it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>
Effect.gen(function* () {
runtimeMock.state.inventory = {
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/provider/Layers/OpenCodeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
parseGenericCliVersion,
providerModelsFromSettings,
} from "../providerSnapshot.ts";
import { compareCliVersions } from "../cliVersion.ts";
import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
import {
OpenCodeRuntime,
Expand All @@ -24,6 +25,7 @@ import {
import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";

const PROVIDER = "opencode" as const;
const MINIMUM_OPENCODE_VERSION = "1.14.19";

class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{
readonly cause: unknown;
Expand Down Expand Up @@ -354,6 +356,35 @@ export const OpenCodeProviderLive = Layer.effect(
return fallback(Cause.squash(versionExit.cause));
}
version = parseGenericCliVersion(versionExit.value.stdout) ?? null;

if (!version) {
return fallback(
new Error(
`Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`,
),
null,
);
}
if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) {
return buildServerProvider({
provider: PROVIDER,
enabled: input.settings.enabled,
checkedAt,
models: providerModelsFromSettings(
[],
PROVIDER,
customModels,
DEFAULT_OPENCODE_MODEL_CAPABILITIES,
),
probe: {
installed: true,
version,
status: "error",
auth: { status: "unknown" },
message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`,
},
});
}
}

const inventoryExit = yield* Effect.exit(
Expand Down
Loading