Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f6978db
chore(turbo): pass through PATHEXT (#2184)
adammansfield Apr 20, 2026
40b3a80
fix(server): trim OpenCode provider model names (#2252)
adinschmidt Apr 21, 2026
055897f
fix: enforce opencode >= 1.14.19 and reveal window on Wayland (#2262)
mwolson Apr 21, 2026
3a1daa8
Add close buttons to toasts (#2023)
noxire-dev Apr 21, 2026
b7c89cf
Refresh Codex protocol bindings to `be75785504ff152fa6333e380a2d50642…
juliusmarminge Apr 21, 2026
b8305af
fix: increase Claude auth probe timeout to 10s (#2272)
Heinz-G Apr 22, 2026
e25db3a
Fix provider cache atomic write temp path collisions (#2291)
juliusmarminge Apr 23, 2026
aa2d385
fix(server): restore CODEX_HOME tilde expansion for Codex launches (#…
HaukeSchnau Apr 23, 2026
fd3b96b
Add IntelliJ project icon to the list of possible favicon paths (#1651)
basmilius Apr 23, 2026
b0b7b38
fix(server): detect localized Windows command errors (#2152)
raulpesilva Apr 23, 2026
8d1d699
Refactor provider model selections to option arrays (#2246)
juliusmarminge Apr 23, 2026
d5b7690
Exclude subscribe RPCs from latency tracking (#2313)
juliusmarminge Apr 23, 2026
0ee302e
fix(request-permission): add `dynamic_tool_call` to command request (…
th1m0 Apr 23, 2026
0d55a42
fix(web): ignore stale runtime projection snapshots (#2301)
Pedro-Revez-Silva Apr 23, 2026
188df6d
Fix Claude session cwd resume drift (#2292)
juliusmarminge Apr 23, 2026
00b5c3e
Add task sidebar auto-open setting (#2314)
justsomelegs Apr 23, 2026
ada410b
chore(release): prepare v0.0.21
t3-code[bot] Apr 23, 2026
6f54caa
sync: merge upstream/main v0.0.21 (17 commits)
aaditagrawal Apr 24, 2026
aecf1b2
ci: bump quality job timeout to 30min
aaditagrawal Apr 24, 2026
5acd9ed
review: address CodeRabbit feedback on sync PR
aaditagrawal Apr 25, 2026
213f782
fix(rpc): match subscribe at start of tag without trailing delimiter
aaditagrawal Apr 25, 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
1 change: 1 addition & 0 deletions .cursor/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
plans/
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
quality:
name: Format, Lint, Typecheck, Test, Browser Test, Build
runs-on: ubuntu-24.04
timeout-minutes: 10
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@t3tools/desktop",
"version": "0.0.20",
"version": "0.0.21",
"private": true,
"type": "module",
"main": "dist-electron/main.cjs",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/clientPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function makeSecretStorage(available: boolean): DesktopSecretStorage {
}

const clientSettings: ClientSettings = {
autoOpenPlanSidebar: false,
confirmThreadArchive: true,
confirmThreadDelete: false,
diffWordWrap: true,
Expand Down
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 @@
} from "./updateMachine.ts";
import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
import { resolveDesktopAppBranding } from "./appBranding.ts";
import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts";

syncShellEnvironment();

Expand All @@ -94,7 +95,7 @@
const LOG_LIST_CHANNEL = "desktop:log-list";
const LOG_READ_CHANNEL = "desktop:log-read";
const LOG_OPEN_DIR_CHANNEL = "desktop:log-open-dir";
const GET_WS_URL_CHANNEL = "desktop:get-ws-url";

Check warning on line 98 in apps/desktop/src/main.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint(no-unused-vars)

Variable 'GET_WS_URL_CHANNEL' is declared but never used. Unused variables should start with a '_'.
const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
Expand Down Expand Up @@ -2026,16 +2027,17 @@
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);
}
}
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "t3",
"version": "0.0.20",
"version": "0.0.21",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/atomicWrite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Effect, FileSystem, Path } from "effect";
import * as Random from "effect/Random";

export const writeFileStringAtomically = (input: {
readonly filePath: string;
readonly contents: string;
}) =>
Effect.scoped(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempFileId = yield* Random.nextUUIDv4;
const targetDirectory = path.dirname(input.filePath);

yield* fs.makeDirectory(targetDirectory, { recursive: true });
const tempDirectory = yield* fs.makeTempDirectoryScoped({
directory: targetDirectory,
prefix: `${path.basename(input.filePath)}.`,
});
const tempPath = path.join(tempDirectory, `${tempFileId}.tmp`);

yield* fs.writeFileString(tempPath, input.contents);
yield* fs.rename(tempPath, input.filePath);
}),
);
16 changes: 10 additions & 6 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
} from "effect";
import * as Semaphore from "effect/Semaphore";
import { ServerConfig } from "./config.ts";
import { writeFileStringAtomically } from "./atomicWrite.ts";
import { fromLenientJson } from "@t3tools/shared/schemaJson";

type WhenToken =
Expand Down Expand Up @@ -677,14 +678,17 @@ const makeKeybindings = Effect.gen(function* () {
});

const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {
const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`;

return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe(
Effect.map((encoded) => `${encoded}\n`),
Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })),
Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)),
Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)),
Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))),
Effect.flatMap((encoded) =>
writeFileStringAtomically({
filePath: keybindingsConfigPath,
contents: encoded,
}).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
),
),
Effect.mapError(
(cause) =>
new KeybindingsConfigError({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ describe("ProviderCommandReactor", () => {
(input.runtimeMode === "approval-required" || input.runtimeMode === "full-access")
? input.runtimeMode
: "full-access",
...(typeof input === "object" &&
input !== null &&
"cwd" in input &&
typeof input.cwd === "string"
? { cwd: input.cwd }
: {}),
...(modelSelection.model !== undefined ? { model: modelSelection.model } : {}),
threadId,
resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ const make = Effect.gen(function* () {
thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null;
if (existingSessionThreadId) {
const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;
const cwdChanged = effectiveCwd !== activeSession?.cwd;
const sessionModelSwitch =
currentProvider === undefined
? "in-session"
Expand All @@ -363,6 +364,7 @@ const make = Effect.gen(function* () {

if (
!runtimeModeChanged &&
!cwdChanged &&
!shouldRestartForModelChange &&
!shouldRestartForModelSelectionChange
) {
Expand All @@ -380,6 +382,9 @@ const make = Effect.gen(function* () {
currentRuntimeMode: thread.session?.runtimeMode,
desiredRuntimeMode: thread.runtimeMode,
runtimeModeChanged,
previousCwd: activeSession?.cwd,
desiredCwd: effectiveCwd,
cwdChanged,
modelChanged,
shouldRestartForModelChange,
shouldRestartForModelSelectionChange,
Expand All @@ -394,6 +399,7 @@ const make = Effect.gen(function* () {
restartedSessionThreadId: restartedSession.threadId,
provider: restartedSession.provider,
runtimeMode: restartedSession.runtimeMode,
cwd: restartedSession.cwd,
});
yield* bindSessionToThread(restartedSession);
return restartedSession.threadId;
Expand Down
20 changes: 19 additions & 1 deletion apps/server/src/processRunner.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { runProcess } from "./processRunner.ts";
import { isWindowsCommandNotFound, runProcess } from "./processRunner.ts";

describe("runProcess", () => {
it("fails when output exceeds max buffer in default mode", async () => {
Expand All @@ -21,3 +21,21 @@ describe("runProcess", () => {
expect(result.stderrTruncated).toBe(false);
});
});

describe("isWindowsCommandNotFound", () => {
it("matches the localized German cmd.exe error text", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "win32", configurable: true });

try {
expect(
isWindowsCommandNotFound(
1,
"wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt",
),
).toBe(true);
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
}
});
});
15 changes: 14 additions & 1 deletion apps/server/src/processRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,23 @@ function normalizeSpawnError(command: string, args: readonly string[], error: un
return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`);
}

const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [
/is not recognized as an internal or external command/i,
/n.o . reconhecido como um comando interno/i,
/non . riconosciuto come comando interno o esterno/i,
/n.est pas reconnu en tant que commande interne/i,
/no se reconoce como un comando interno o externo/i,
/wird nicht als interner oder externer befehl/i,
] as const;

function hasWindowsCommandNotFoundMessage(output: string): boolean {
return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(output));
}

export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean {
if (process.platform !== "win32") return false;
if (code === 9009) return true;
return /is not recognized as an internal or external command/i.test(stderr);
return hasWindowsCommandNotFoundMessage(stderr);
}

function normalizeExitError(
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/project/Layers/ProjectFaviconResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const FAVICON_CANDIDATES = [
"assets/icon.png",
"assets/logo.svg",
"assets/logo.png",
".idea/icon.svg",
] as const;

// Files that may contain a <link rel="icon"> or icon metadata declaration.
Expand Down
Loading
Loading