Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6328c84
feat(mcp): preview_open goes straight to a dev-server port (#28027)
maria-rcks Nov 1, 2026
ce9e016
fix(server): interrupted scheduled task runs no longer stay busy (#28…
t3dotgg Nov 1, 2026
37d7615
fix(desktop): preserve host focus on preview pointer presses (#28549)
Yash-Singh1 Nov 1, 2026
b50d8a1
fix(web): add spacing above PR detail metadata (#28573)
Yash-Singh1 Nov 1, 2026
0bc95fc
feat(dev): add startup profiling for cold-start diagnosis (#28566)
Yash-Singh1 Nov 1, 2026
70f475f
feat(clients): surface child approvals in parent threads (#28516)
Yash-Singh1 Nov 1, 2026
460b88b
feat(release): update notes lead with up to 7 highlights (#28593)
t3dotgg Nov 1, 2026
1280ccd
test(server): pin clock in saved-usage window test (#28603)
t3dotgg Nov 1, 2026
da21606
fix(web): sidebar project filter no longer refocuses after a pick (#2…
t3dotgg Nov 1, 2026
58a407c
feat(desktop): prepare server bytecode cache in native builds (#28572)
Yash-Singh1 Nov 1, 2026
8b4de61
fix(desktop): discover shell environment without blocking startup (#2…
Yash-Singh1 Nov 1, 2026
8760d06
fix(server): refresh Changes totals after remote polls (#28600)
Yash-Singh1 Nov 1, 2026
2b1021c
perf(codex): trace app server request latency (#28666)
Yash-Singh1 Nov 1, 2026
82b10d5
fix(web): align pull request file viewed checkboxes (#28665)
Yash-Singh1 Nov 1, 2026
80120b1
fix(desktop): skip prepared server bytecode on Linux (#28700)
Yash-Singh1 Nov 1, 2026
8b22b76
Merge tag 'v0.1.7-nightly.20261101.4035' into chamaquita/upstream-v0.…
chamaquita-d3[bot] Nov 1, 2026
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
11 changes: 5 additions & 6 deletions apps/desktop/scripts/main-process-bundle.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as NodeFSP from "node:fs/promises";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeVM from "node:vm";
import * as NodeURL from "node:url";
import { build } from "vite-plus/pack";
import { assert, it } from "vite-plus/test";

Expand Down Expand Up @@ -114,18 +115,16 @@ it("loads the emitted packaged boot entry and backend cache preload", async () =
const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-boot-"));
try {
const entries = ["src/boot.ts", "src/compileCache.ts"];
await NodeFSP.mkdir(NodePath.join(directory, "src"));
await Promise.all(
entries.map((entry) =>
NodeFSP.copyFile(new URL(`../${entry}`, import.meta.url), NodePath.join(directory, entry)),
),
);
assert.ok(Array.isArray(desktopConfig.pack));
for (const packConfig of desktopConfig.pack) {
if (!Array.isArray(packConfig.entry)) continue;
if (!packConfig.entry.some((entry) => entries.includes(entry))) continue;
await build({
...packConfig,
entry: packConfig.entry.map((entry) =>
NodeURL.fileURLToPath(new URL(`../${entry}`, import.meta.url)),
),
outDir: NodePath.join(directory, "dist-electron"),
config: false,
cwd: directory,
tsconfig: false,
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer";
import * as ManagedRuntime from "effect/ManagedRuntime";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Stream from "effect/Stream";
import { ChildProcessSpawner } from "effect/process";

import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
Expand All @@ -22,6 +23,8 @@ import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts";
const layerShellEnvironment = (prepared = true) =>
Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, {
installIntoProcess: Effect.succeed(prepared),
awaitDiscovery: Effect.succeed(prepared),
updates: Stream.empty,
});

const layerBackendConfiguration = (prepared = true) =>
Expand Down Expand Up @@ -237,6 +240,7 @@ describe("DesktopBackendConfiguration", () => {
assert.equal(first.cwd, environment.backendCwd);
assert.equal(first.captureOutput, true);
assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1");
assert.equal(first.env.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING, "1");
assert.isUndefined(first.env.T3CODE_PORT);
assert.isUndefined(first.env.T3CODE_MODE);
assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/backend/DesktopBackendConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
env: {
...backendChildEnvPatch(),
ELECTRON_RUN_AS_NODE: "1",
T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1",
T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath),
},
extendEnv: true,
Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/src/compileCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@
import * as NodeModule from "node:module";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import { enablePreparedServerCache } from "../../../scripts/lib/server-compile-cache.ts";

// Linux /tmp is shared; macOS and Windows temp dirs are per user.
// AppImage mounts change paths each launch, so Node's path-keyed cache would miss and accumulate copies.
try {
if (!process.env.APPIMAGE) {
if (!process.env.APPIMAGE || process.env.ELECTRON_RUN_AS_NODE === "1") {
const cacheRoot =
// oxlint-disable-next-line t3code/no-global-process-runtime -- Loads before any Effect runtime.
process.platform === "linux"
? process.env.XDG_CACHE_HOME || NodePath.join(NodeOS.homedir(), ".cache")
: NodeOS.tmpdir();
NodeModule.enableCompileCache(NodePath.join(cacheRoot, "t3code", "compile-cache"));
const directory = NodePath.join(cacheRoot, "t3code", "compile-cache");
const prepared =
process.env.ELECTRON_RUN_AS_NODE === "1" &&
enablePreparedServerCache(process.argv[1] ?? "", directory);
if (!prepared && !process.env.APPIMAGE) {
NodeModule.enableCompileCache(directory);
}
}
} catch {}
2 changes: 1 addition & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,10 @@ const layerDesktopAppActivation = DesktopAppActivation.layer.pipe(
const layerDesktopBackend = DesktopBackendPool.layer.pipe(
Layer.provideMerge(DesktopAppIdentity.layer),
Layer.provideMerge(DesktopBackendConfiguration.layer),
Layer.provideMerge(DesktopShellEnvironment.layer),
Layer.provideMerge(DesktopWslEnvironment.layer),
Layer.provideMerge(DesktopWslServerTree.layer),
Layer.provideMerge(DesktopTelemetryPublisher.layer),
Layer.provideMerge(DesktopShellEnvironment.layer),
Layer.provideMerge(layerDesktopWindow),
);

Expand Down
4 changes: 1 addition & 3 deletions apps/desktop/src/preview/DesktopBrowserHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,9 +302,7 @@ export const make = Effect.gen(function* () {
if (tab.relay) return tab.relay;
const { webContents, debugger: debuggee } = tab.debuggee;
const send = (method: string, params: Record<string, unknown>, sessionId?: string) =>
sessionId === undefined
? sendPageCommand(webContents, debuggee, method, params)
: debuggee.sendCommand(method, params, sessionId);
sendPageCommand(webContents, debuggee, method, params, sessionId);
const detachedOwners = new Set<string>();
/** Viewers and recordings stream; the agent's own sessions never do. */
const streamingOwners = new Set<string>();
Expand Down
145 changes: 145 additions & 0 deletions apps/desktop/src/preview/GuestInput.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as NodeVM from "node:vm";

import { describe, expect, it, vi } from "vite-plus/test";

import { sendPageCommand, toKeyboardInputEvents } from "./GuestInput.ts";
Expand Down Expand Up @@ -42,6 +44,149 @@ describe("preview guest input", () => {
expect(guest.sendCommand).not.toHaveBeenCalledWith("Input.insertText", expect.anything());
});

it.each([
{
sessionId: undefined,
method: "Input.dispatchMouseEvent",
type: "mousePressed",
outcome: "restore",
},
{
sessionId: "child-frame",
method: "Input.dispatchMouseEvent",
type: "mousePressed",
outcome: "restore",
},
{
sessionId: undefined,
method: "Input.dispatchTouchEvent",
type: "touchStart",
outcome: "restore",
},
{
sessionId: undefined,
method: "Input.dispatchMouseEvent",
type: "mousePressed",
outcome: "other-field",
},
{
sessionId: undefined,
method: "Input.dispatchMouseEvent",
type: "mousePressed",
outcome: "removed",
},
{
sessionId: undefined,
method: "Input.dispatchMouseEvent",
type: "mousePressed",
outcome: "failure",
},
])(
"preserves host focus for $method in $sessionId with $outcome",
async ({ sessionId, method, type, outcome }) => {
const document = { activeElement: {} as object };
const composer = {
isConnected: true,
focus: () => {
document.activeElement = composer;
},
};
const preview = { tagName: "WEBVIEW", getWebContentsId: () => 7 };
const otherField = { tagName: "INPUT" };
document.activeElement = composer;
const context = NodeVM.createContext({ document });
const host = {
isDestroyed: () => false,
executeJavaScript: async (script: string) => NodeVM.runInContext(script, context),
};
const guest = { id: 7, focusedFrame: null, hostWebContents: host };
const debuggee = {
sendCommand: async () => {
document.activeElement = outcome === "other-field" ? otherField : preview;
if (outcome === "removed") composer.isConnected = false;
if (outcome === "failure") throw new Error("Pointer failed");
return {};
},
};
const result = sendPageCommand(
guest as unknown as Electron.WebContents,
debuggee as unknown as Electron.Debugger,
method,
{ type, x: 10, y: 10, button: "left" },
sessionId,
);
if (outcome === "failure") await expect(result).rejects.toThrow("Pointer failed");
else await result;
expect(document.activeElement).toBe(
outcome === "other-field" ? otherField : outcome === "removed" ? preview : composer,
);
expect(Object.keys(context)).toEqual(["document"]);
},
);

it.each([
{
method: "Input.dispatchMouseEvent",
press: "mousePressed",
release: "mouseReleased",
failPress: false,
},
{
method: "Input.dispatchTouchEvent",
press: "touchStart",
release: "touchEnd",
failPress: false,
},
{
method: "Input.dispatchMouseEvent",
press: "mousePressed",
release: "mouseReleased",
failPress: true,
},
])(
"keeps $method releases behind presses waiting for host focus, failed press: $failPress",
async ({ method, press, release, failPress }) => {
const captureStarted = Promise.withResolvers<void>();
const finishCapture = Promise.withResolvers<void>();
const events: Array<unknown> = [];
const guest = {
id: 7,
focusedFrame: null,
hostWebContents: {
isDestroyed: () => false,
executeJavaScript: async () => {
captureStarted.resolve();
await finishCapture.promise;
},
},
};
const debuggee = {
sendCommand: async (_method: string, params: Record<string, unknown>) => {
events.push(params["type"]);
if (failPress && params["type"] === press) throw new Error("Press failed");
return {};
},
};
const send = (type: string) =>
sendPageCommand(
guest as unknown as Electron.WebContents,
debuggee as unknown as Electron.Debugger,
method,
{ type },
);
const pressed = send(press);
await captureStarted.promise;
const released = send(release);
finishCapture.resolve();
const results = await Promise.allSettled([pressed, released]);
expect(results.map((result) => result.status)).toEqual([
failPress ? "rejected" : "fulfilled",
"fulfilled",
]);
expect(events).toEqual([press, release]);
},
);

it("refuses input when a cross-site frame holds the unfocused page's focus", async () => {
const guest = makeGuest({ focused: false, focusedDocument: false });
await expect(guest.send("Input.insertText", { text: "secret" })).rejects.toThrow(
Expand Down
65 changes: 65 additions & 0 deletions apps/desktop/src/preview/GuestInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,78 @@ const evaluate = async (debuggee: Electron.Debugger, expression: string) => {
return reply.result?.value;
};

let focusSequence = 0;

/** A CDP pointer press focuses the guest's native view even with focus emulation enabled. */
const sendPointerPress = async (
webContents: Electron.WebContents,
debuggee: Electron.Debugger,
method: string,
params: Record<string, unknown>,
sessionId: string | undefined,
) => {
const host = webContents.hostWebContents;
if (!host || host.isDestroyed() || webContents.focusedFrame) {
return debuggee.sendCommand(method, params, sessionId);
}
const key = JSON.stringify(`__t3PreviewFocus${++focusSequence}`);
await host.executeJavaScript(`(() => {
const active = document.activeElement;
if (!active || active === document.body || active.tagName === "WEBVIEW") return;
globalThis[${key}] = () => {
delete globalThis[${key}];
const current = document.activeElement;
if (active.isConnected && current?.tagName === "WEBVIEW" &&
current.getWebContentsId() === ${webContents.id}) active.focus({ preventScroll: true });
};
})()`);
try {
return await debuggee.sendCommand(method, params, sessionId);
} finally {
if (!host.isDestroyed()) {
await host.executeJavaScript(`globalThis[${key}]?.()`);
}
}
};

const pointerCommands = new WeakMap<Electron.WebContents, Promise<unknown>>();

/** Focus capture crosses processes; later pointer events must not overtake the press. */
const sendPointerCommand = async (
webContents: Electron.WebContents,
debuggee: Electron.Debugger,
method: string,
params: Record<string, unknown>,
sessionId: string | undefined,
) => {
const previous = pointerCommands.get(webContents) ?? Promise.resolve();
const current = previous
.catch(() => {})
.then(() =>
params["type"] === "mousePressed" || params["type"] === "touchStart"
? sendPointerPress(webContents, debuggee, method, params, sessionId)
: debuggee.sendCommand(method, params, sessionId),
);
pointerCommands.set(webContents, current);
try {
return await current;
} finally {
if (pointerCommands.get(webContents) === current) pointerCommands.delete(webContents);
}
};

/** Sends one command to the guest's page, keeping reloads and input inside it. */
export const sendPageCommand = async (
webContents: Electron.WebContents,
debuggee: Electron.Debugger,
method: string,
params: Record<string, unknown>,
sessionId?: string,
): Promise<unknown> => {
if (method === "Input.dispatchMouseEvent" || method === "Input.dispatchTouchEvent") {
return sendPointerCommand(webContents, debuggee, method, params, sessionId);
}
if (sessionId !== undefined) return debuggee.sendCommand(method, params, sessionId);
// Electron's debugger routes Page.reload on a webview to its embedder window.
if (method === "Page.reload") {
if (params["ignoreCache"] === true) webContents.reloadIgnoringCache();
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/serverCompileCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { generatePreparedServerCache } from "../../../scripts/lib/server-compile-cache.ts";
Loading
Loading