diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 3dc218d8252..58870bbab1d 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -31,6 +31,12 @@ const TestLayer = ElectronMenu.layer.pipe( Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), ); +const makeWindow = (zoomFactor = 1): Electron.BrowserWindow => + ({ + id: 7, + webContents: { getZoomFactor: () => zoomFactor }, + }) as unknown as Electron.BrowserWindow; + describe("ElectronMenu", () => { beforeEach(() => { buildFromTemplateMock.mockReset(); @@ -70,7 +76,7 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as Electron.BrowserWindow, + window: makeWindow(), items: [{ id: "copy", label: "Copy" }], position: Option.none(), }); @@ -81,20 +87,24 @@ describe("ElectronMenu", () => { it.effect("resolves with none when the menu closes without a click", () => Effect.gen(function* () { + let popupOptions: Electron.PopupOptions | undefined; buildFromTemplateMock.mockImplementation(() => ({ popup: (options: Electron.PopupOptions) => { + popupOptions = options; options.callback?.(); }, })); const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as Electron.BrowserWindow, + window: makeWindow(2), items: [{ id: "copy", label: "Copy" }], position: Option.some({ x: 10.8, y: 20.2 }), }); assert.isTrue(Option.isNone(selectedItemId)); + assert.equal(popupOptions?.x, 21); + assert.equal(popupOptions?.y, 40); assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], { label: "Copy", enabled: true, diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 09fb5d1807d..4d3e5a1c241 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM return normalizedItems; } +// Renderer positions arrive in CSS pixels; popup() expects window points, so +// page zoom must be factored in or menus drift proportionally to their +// distance from the window origin. const normalizePosition = ( position: Option.Option, + zoomFactor: number, ): Option.Option => Option.filter( position, - ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, - ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); + ({ x, y }) => + Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor), + ).pipe( + Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })), + ); export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; @@ -214,7 +221,10 @@ export const make = Effect.gen(function* () { try { const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); - const popupPosition = normalizePosition(input.position); + const popupPosition = normalizePosition( + input.position, + input.window.webContents.getZoomFactor(), + ); const popupOptions = Option.match(popupPosition, { onNone: (): Electron.PopupOptions => ({ window: input.window, diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index cdb3fc78286..895d246e368 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -16,6 +16,7 @@ import { formatNodePtyProbeFailureReason, formatWslShellTransportFailureReason, parseNodePath, + parseNodeVersion, parseResolvedPath, parseToolchainReport, probeWslDistros, @@ -173,6 +174,29 @@ describe("parseNodePath", () => { }); }); +describe("parseNodeVersion", () => { + it("extracts the node version from a nodeVersion: line", () => { + expect(parseNodeVersion("nodeVersion:24.10.0")).toBe("24.10.0"); + }); + + it("returns null when the version value is empty", () => { + expect(parseNodeVersion("nodeVersion:")).toBeNull(); + }); + + it("returns null when there is no nodeVersion line at all", () => { + expect(parseNodeVersion("nodePath:/usr/bin/node\nresolvedPath:/usr/bin")).toBeNull(); + }); + + it("ignores surrounding noise and trims whitespace", () => { + const stdout = [ + "some preamble noise", + " nodeVersion:22.16.0 ", + "nodePath:/usr/bin/node", + ].join("\n"); + expect(parseNodeVersion(stdout)).toBe("22.16.0"); + }); +}); + describe("parseResolvedPath", () => { it("preserves spaces and apostrophes in the resolved login-shell PATH", () => { const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin"; diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index b38675f0e1f..c6c274d8500 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -32,7 +32,11 @@ export interface EnsureWslNodePtyOptions { } export type EnsureWslNodePtyResult = - | { readonly ok: true; readonly nodePath: string; readonly resolvedPath: string } + | { + readonly ok: true; + readonly nodePath: string; + readonly resolvedPath: string; + } | { readonly ok: false; readonly reason: string; @@ -222,6 +226,7 @@ export const formatNodePtyProbeFailureReason = (exitCode: number): string | null const NODE_PTY_PROBE_SCRIPT = ( linuxServerDir: string, ) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)" +printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)" printf 'resolvedPath:%s\\n' "$PATH" cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 // The server bundle externalizes its deps to node_modules, and the WSL Node @@ -318,6 +323,16 @@ export const parseNodePath = (stdout: string): string | null => { return path ?? null; }; +export const parseNodeVersion = (stdout: string): string | null => { + const version = stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("nodeVersion:")) + .map((line) => line.slice("nodeVersion:".length).trim()) + .find((value) => value.length > 0); + return version ?? null; +}; + // Captures the login-shell PATH after the shared resolver has loaded version // managers. Preserve the value byte-for-byte apart from a Windows-style CR so // paths containing spaces or apostrophes can be forwarded as one env argv. @@ -404,7 +419,11 @@ const ensureNodePtyImpl = ( const transportFailureReason = formatWslShellTransportFailureReason(probe.transportFailure); if (transportFailureReason !== null) { - return { ok: false, reason: transportFailureReason, fatal: false } as const; + return { + ok: false, + reason: transportFailureReason, + fatal: false, + } as const; } // No node at all, even after the shared resolver repaired PATH. Surface @@ -457,12 +476,31 @@ const ensureNodePtyImpl = ( } as const; } - if (probe.exitCode === 0) return { ok: true, nodePath, resolvedPath } as const; + if (probe.exitCode === 0) { + const rawVersion = parseNodeVersion(probe.stdout); + if ( + rawVersion !== null && + options.nodeEngineRange && + !satisfiesSemverRange(rawVersion, options.nodeEngineRange.trim()) + ) { + const range = options.nodeEngineRange.trim(); + return { + ok: false, + reason: `WSL Node.js ${rawVersion} does not satisfy the server's required engine range (${range}). Install a compatible version, and restart the desktop app.`, + fatal: true, + } as const; + } + return { ok: true, nodePath, resolvedPath } as const; + } if (options.allowBuild !== true) { const packagedProbeFailure = formatNodePtyProbeFailureReason(probe.exitCode); if (packagedProbeFailure !== null) { - return { ok: false, reason: packagedProbeFailure, fatal: true } as const; + return { + ok: false, + reason: packagedProbeFailure, + fatal: true, + } as const; } } diff --git a/apps/marketing/public/harnesses/grok-dark.svg b/apps/marketing/public/harnesses/grok-dark.svg new file mode 100644 index 00000000000..d094fcc6f85 --- /dev/null +++ b/apps/marketing/public/harnesses/grok-dark.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 477daeb3cb0..9d1cfbdd125 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -33,6 +33,9 @@ const mobileEndorsementRows = [ + + +
@@ -41,7 +44,7 @@ const mobileEndorsementRows = [

- Orchestrate Claude Code, Codex, OpenCode and Cursor from one surface. + Orchestrate Claude Code, Codex, OpenCode, Cursor, and Grok from one surface. Bring your own subscription. Fork the whole thing.

@@ -173,7 +176,7 @@ const mobileEndorsementRows = [

Bring your own sub

T3 Code doesn't resell tokens. Plug in Claude Code, Codex, OpenCode, - or Cursor with the credentials you already have — we orchestrate + Cursor, or Grok with the credentials you already have — we orchestrate them, you keep your plan.

@@ -207,6 +210,13 @@ const mobileEndorsementRows = [
cursor-agent
+
+
+
+
Grok CLI
+
grok login
+
+
- {/* Input bar */} + {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */}
-