Skip to content

Commit 013d098

Browse files
authored
Merge branch 'main' into perf/lazy-orchestration-startup
2 parents 492027e + b8305af commit 013d098

33 files changed

Lines changed: 7396 additions & 3221 deletions

apps/desktop/src/main.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
} from "./updateMachine.ts";
7676
import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
7777
import { resolveDesktopAppBranding } from "./appBranding.ts";
78+
import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts";
7879

7980
syncShellEnvironment();
8081

@@ -1998,16 +1999,17 @@ function createWindow(): BrowserWindow {
19981999
emitUpdateState();
19992000
});
20002001

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

20122014
if (isDevelopment) {
20132015
void window.loadURL(resolveDesktopDevServerUrl());
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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/ClaudeProvider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919

2020
import {
2121
buildServerProvider,
22+
AUTH_PROBE_TIMEOUT_MS,
2223
DEFAULT_TIMEOUT_MS,
2324
detailFromResult,
2425
extractAuthBoolean,
@@ -674,7 +675,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
674675
// ── Auth check + subscription detection ────────────────────────────
675676

676677
const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe(
677-
Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
678+
Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS),
678679
Effect.result,
679680
);
680681

apps/server/src/provider/Layers/CodexProvider.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun
6262
case "plus":
6363
return "ChatGPT Plus Subscription";
6464
case "pro":
65-
return "ChatGPT Pro Subscription";
65+
return "ChatGPT Pro 20x Subscription";
66+
case "prolite":
67+
return "ChatGPT Pro 5x Subscription";
6668
case "team":
6769
return "ChatGPT Team Subscription";
6870
case "self_serve_business_usage_based":

apps/server/src/provider/Layers/CodexSessionRuntime.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,7 @@ function readNotificationThreadId(notification: CodexServerNotification): string
468468
case "item/commandExecution/outputDelta":
469469
case "item/commandExecution/terminalInteraction":
470470
case "item/fileChange/outputDelta":
471+
case "item/fileChange/patchUpdated":
471472
case "serverRequest/resolved":
472473
case "item/mcpToolCall/progress":
473474
case "item/reasoning/summaryTextDelta":
@@ -476,7 +477,8 @@ function readNotificationThreadId(notification: CodexServerNotification): string
476477
case "thread/compacted":
477478
case "thread/realtime/started":
478479
case "thread/realtime/itemAdded":
479-
case "thread/realtime/transcriptUpdated":
480+
case "thread/realtime/transcript/delta":
481+
case "thread/realtime/transcript/done":
480482
case "thread/realtime/outputAudio/delta":
481483
case "thread/realtime/sdp":
482484
case "thread/realtime/error":
@@ -530,6 +532,7 @@ function readRouteFields(notification: CodexServerNotification): {
530532
case "item/commandExecution/outputDelta":
531533
case "item/commandExecution/terminalInteraction":
532534
case "item/fileChange/outputDelta":
535+
case "item/fileChange/patchUpdated":
533536
case "item/reasoning/summaryTextDelta":
534537
case "item/reasoning/summaryPartAdded":
535538
case "item/reasoning/textDelta":

apps/server/src/provider/Layers/OpenCodeProvider.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ import {
1616
import { OpenCodeProviderLive } from "./OpenCodeProvider.ts";
1717
import type { OpenCodeInventory } from "../opencodeRuntime.ts";
1818

19+
const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n";
20+
1921
const runtimeMock = {
2022
state: {
2123
runVersionError: null as Error | null,
24+
versionStdout: DEFAULT_VERSION_STDOUT,
2225
inventoryError: null as Error | null,
2326
inventory: {
2427
providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
@@ -27,6 +30,7 @@ const runtimeMock = {
2730
},
2831
reset() {
2932
this.state.runVersionError = null;
33+
this.state.versionStdout = DEFAULT_VERSION_STDOUT;
3034
this.state.inventoryError = null;
3135
this.state.inventory = {
3236
providerList: { connected: [], all: [] as unknown[], default: {} },
@@ -56,7 +60,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
5660
cause: runtimeMock.state.runVersionError,
5761
}),
5862
)
59-
: Effect.succeed({ stdout: "opencode 1.0.0\n", stderr: "", code: 0 }),
63+
: Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),
6064
createOpenCodeSdkClient: () =>
6165
({}) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
6266
loadOpenCodeInventory: () =>
@@ -108,6 +112,33 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {
108112
}),
109113
);
110114

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+
111142
it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>
112143
Effect.gen(function* () {
113144
runtimeMock.state.inventory = {

apps/server/src/provider/Layers/OpenCodeProvider.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
parseGenericCliVersion,
1616
providerModelsFromSettings,
1717
} from "../providerSnapshot.ts";
18+
import { compareCliVersions } from "../cliVersion.ts";
1819
import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
1920
import {
2021
OpenCodeRuntime,
@@ -24,6 +25,7 @@ import {
2425
import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
2526

2627
const PROVIDER = "opencode" as const;
28+
const MINIMUM_OPENCODE_VERSION = "1.14.19";
2729

2830
class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{
2931
readonly cause: unknown;
@@ -354,6 +356,35 @@ export const OpenCodeProviderLive = Layer.effect(
354356
return fallback(Cause.squash(versionExit.cause));
355357
}
356358
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+
}
357388
}
358389

359390
const inventoryExit = yield* Effect.exit(

apps/server/src/provider/Layers/ProviderRegistry.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
179179
assert.strictEqual(status.version, "1.0.0");
180180
assert.strictEqual(status.auth.status, "authenticated");
181181
assert.strictEqual(status.auth.type, "chatgpt");
182-
assert.strictEqual(status.auth.label, "ChatGPT Pro Subscription");
182+
assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription");
183183
assert.deepStrictEqual(status.models, [
184184
{
185185
slug: "gpt-live-codex",

apps/server/src/provider/providerSnapshot.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { normalizeModelSlug } from "@t3tools/shared/model";
1313
import { isWindowsCommandNotFound } from "../processRunner.ts";
1414

1515
export const DEFAULT_TIMEOUT_MS = 4_000;
16+
// Auth status checks involve disk/network lookups and can be slow on first run (especially Windows)
17+
export const AUTH_PROBE_TIMEOUT_MS = 10_000;
1618

1719
export interface CommandResult {
1820
readonly stdout: string;

0 commit comments

Comments
 (0)