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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Everything else T3 Code does — multi-provider agent control, checkpoints and d

Full docs live in [docs/](./docs):

- [Install and first run](./docs/user/install.md)
- [Install and first run](#download) (the inherited [`t3` CLI install guide](./docs/user/install.md) installs upstream T3 Code, not MT Code)
- [Permission modes](./docs/user/permission-modes.md)
- [Keyboard shortcuts](./docs/user/keybindings.md)
- [Desktop notifications](./docs/user/desktop-notifications.md)
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/scripts/electron-launcher.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,12 @@ describe("electron development launcher", () => {
// The source icons are real repo paths, joined for the host.
assert.match(development.sourceIconPath, /assets[\\/]dev[\\/]blueprint-macos-1024\.png$/);
assert.equal(development.generatedIconPath, "/runtime/icon-dev.icns");
assert.match(production.sourceIconPath, /assets[\\/]prod[\\/]black-macos-1024\.png$/);
// Personal builds use the Munim icon (munim distro) or the Nightly look,
// falling back to the stock production icon when neither asset exists.
assert.match(
production.sourceIconPath,
/assets[\\/](?:munim[\\/]munim|nightly[\\/]nightly|prod[\\/]black)-macos-1024\.png$/,
);
assert.equal(production.generatedIconPath, "/runtime/icon-prod.icns");
});
});
10 changes: 10 additions & 0 deletions apps/desktop/src/app/desktopProtocolUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ describe("extractDesktopProtocolUrl", () => {
scheme: "t3code-dev",
expected: "t3code-dev://app/",
},
{
name: "ignores thread deep links and other hosts the window cannot load",
argv: [
"t3code://app/kept",
"t3code://threads/00000000-0000-4000-8000-000000000000/thread-1",
"t3code://evil/anything",
],
scheme: "t3code",
expected: "t3code://app/kept",
},
])("$name", ({ argv, scheme, expected }) => {
assert.equal(extractDesktopProtocolUrl(argv, scheme), expected);
});
Expand Down
10 changes: 9 additions & 1 deletion apps/desktop/src/app/desktopProtocolUrl.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
// Used by second-instance argv and macOS open-url to find the custom-scheme
// URL the already-running desktop window should load.
// Only the app's own host: the protocol handler serves nothing else, so loading
// e.g. a `<scheme>://threads/...` thread link (same scheme in non-MT builds,
// handled by DesktopDeepLink) or any other host would blank the main window.
export function isDesktopProtocolUrl(value: string, scheme: string): boolean {
return value.startsWith(`${scheme}://`);
if (!value.startsWith(`${scheme}://`)) return false;
try {
return new URL(value).host === "app";
} catch {
return false;
}
}

export function isDesktopClerkOAuthCallback(value: string, scheme: string): boolean {
Expand Down
10 changes: 6 additions & 4 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2848,15 +2848,17 @@ describe("PreviewManager", () => {
.pipe(Effect.forkChild({ startImmediately: true }));
expect(evaluate.pollUnsafe()).toBeUndefined();

yield* TestClock.adjust(4_000);
// The fork bounds each compositor grab at AUTOMATION_CAPTURE_TIMEOUT_MS
// and reattaches once before giving up (see automationSnapshot).
yield* TestClock.adjust(PreviewManager.AUTOMATION_CAPTURE_TIMEOUT_MS);
yield* TestClock.adjust(PreviewManager.AUTOMATION_CAPTURE_TIMEOUT_MS);
const exit = yield* Fiber.join(snapshot);
expect(Exit.isFailure(exit)).toBe(true);
expect(capturePage).toHaveBeenCalledTimes(3);
expect(capturePage).toHaveBeenCalledTimes(2);
if (Exit.isSuccess(exit)) return;
const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
expect(error).toMatchObject({
_tag: "PreviewOperationError",
operation: "automationSnapshot.capturePage",
_tag: "PreviewCaptureUnavailableError",
tabId: "tab_1",
webContentsId: 42,
cause: { _tag: "TimeoutError" },
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/updates/DesktopUpdates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,39 @@ describe("DesktopUpdates", () => {
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});

it.effect("auto-downloads an update announced while its check still runs", () =>
Effect.gen(function* () {
// electron-updater emits update-available from inside checkForUpdates,
// while the check still holds the action reservation.
const checkStarted = yield* Deferred.make<void>();
const releaseCheck = yield* Deferred.make<void>();
const harness = makeHarness({
checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseCheck)),
),
});

yield* Effect.scoped(
Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* updates.configure;
const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped);
yield* Deferred.await(checkStarted);
harness.emit("update-available", { version: "1.2.4" });
yield* flushCallbacks;

yield* Deferred.succeed(releaseCheck, undefined);
yield* Fiber.join(checkFiber);
yield* flushCallbacks;

const state = yield* updates.getState;
assert.equal(state.status, "downloading");
assert.equal(state.availableVersion, "1.2.4");
}),
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
}),
);

it.effect("enables full changelog release notes on latest and nightly", () => {
const harness = makeHarness();

Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/updates/DesktopUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,23 @@ export const make = Effect.gen(function* () {
);
}).pipe(Effect.withSpan("desktop.updates.startPollers"));

// electron-updater emits update-available from inside checkForUpdates, while
// that check still holds the action reservation; a download started right
// away would be refused. Wait for the check to release it first.
const awaitCheckRelease = Effect.scoped(
Effect.gen(function* () {
const actionCompletions = yield* PubSub.subscribe(finishedUpdateActions);
while (true) {
const activeAction = yield* Ref.get(activeUpdateActionRef);
if (Option.isNone(activeAction) || activeAction.value !== "check") return;
const finished = yield* PubSub.take(actionCompletions).pipe(
Effect.timeoutOption(PREPARED_INSTALL_CHECK_WAIT),
);
if (Option.isNone(finished)) return;
}
}),
);

const handleUpdateAvailable = Effect.fn("desktop.updates.handleUpdateAvailable")(function* (
raw: unknown,
) {
Expand Down Expand Up @@ -792,6 +809,7 @@ export const make = Effect.gen(function* () {
omittedReleaseCount,
});
// Personal fork: download as soon as an update is available (no rocket click).
yield* awaitCheckRelease;
const downloadResult = yield* downloadAvailableUpdate;
yield* logUpdaterInfo("auto-download finished", {
version: info.version,
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/features/connection/pairing.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, expect, it } from "vite-plus/test";
import { describe, expect, it, vi } from "vite-plus/test";

// branding reads expo-constants, whose native runtime cannot load under Vitest.
vi.mock("expo-constants", () => ({ default: { expoConfig: null } }));

import {
buildPairingUrl,
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/lib/authClientMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ export function authClientMetadata(appVersion?: string): AuthClientPresentationM

return {
label: getMobileClientLabel(),
deviceType: "mobile",
deviceType:
Device.deviceType === Device.DeviceType.TABLET
? "tablet"
: Device.deviceType === Device.DeviceType.PHONE
? "mobile"
: "unknown",
...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),
...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}),
...(deviceModel ? { deviceModel } : {}),
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/lib/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ vi.mock("react-native", () => ({

vi.mock("expo-device", () => mobileDevice);

// branding reads expo-constants, whose native runtime cannot load under Vitest.
vi.mock("expo-constants", () => ({ default: { expoConfig: null } }));

describe("mobile remote connection records", () => {
afterEach(() => {
mobilePlatform.OS = "ios";
Expand Down
11 changes: 10 additions & 1 deletion apps/mobile/src/lib/mobileTheme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
T3_CODE_LIGHT_THEME_COLORS,
T3_CODE_DARK_THEME_COLORS,
MOBILE_THEME_IDS,
MT_CODE_THEME_ID,
getThemeColorsForAppearance,
} from "@t3tools/shared/themePalettes";
import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support";
Expand All @@ -19,6 +20,7 @@ import {
flattenThemeColor,
getMobileThemePreviewColors,
getMobileThemeVariables,
MOBILE_THEME_OPTIONS,
normalizeMobileThemeId,
normalizeMobileThemeMode,
resolveMobileThemeIds,
Expand Down Expand Up @@ -68,8 +70,15 @@ describe("mobile themes", () => {
);
});

it("does not offer the desktop-only MT Code palette on mobile", () => {
expect(MOBILE_THEME_OPTIONS.map((option) => option.id)).not.toContain(MT_CODE_THEME_ID);
});

it("shares all built-in desktop palettes", () => {
expect(BUILT_IN_THEMES.map((theme) => theme.id)).toEqual(BUILT_IN_THEME_IDS);
// The MT Code palette is desktop/web only (see MT_CODE_THEME_ID).
expect(
BUILT_IN_THEMES.map((theme) => theme.id).filter((id) => id !== MT_CODE_THEME_ID),
).toEqual(BUILT_IN_THEME_IDS);
for (const themeId of BUILT_IN_THEME_IDS) {
expect(getMobileThemeVariables(themeId, "light")["--color-screen"]).toMatch(/^#/);
expect(getMobileThemeVariables(themeId, "dark")["--color-screen"]).toMatch(/^#/);
Expand Down
11 changes: 8 additions & 3 deletions apps/mobile/src/lib/mobileTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
T3_CODE_DARK_THEME_COLORS,
getThemeColorsForAppearance,
MOBILE_DEFAULT_THEME_ID,
MT_CODE_THEME_ID,
MOBILE_THEME_IDS as SHARED_MOBILE_THEME_IDS,
type MobileThemeId as SharedMobileThemeId,
type ThemeAppearance,
Expand All @@ -14,7 +15,6 @@ import {
STANDARD_THEME_PREVIEW_COLORS,
type ThemePreviewColors,
} from "@t3tools/shared/themePreview";
import { getProductName } from "./branding";

export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID;
export const MOBILE_THEME_IDS = [...SHARED_MOBILE_THEME_IDS, "material-you"] as const;
Expand All @@ -27,9 +27,14 @@ export const MOBILE_THEME_OPTIONS: ReadonlyArray<{
readonly id: MobileThemeId;
readonly label: string;
}> = [
{ id: DEFAULT_MOBILE_THEME_ID, label: getProductName() },
{ id: DEFAULT_MOBILE_THEME_ID, label: "T3 Code" },
{ id: "material-you", label: "Material You" },
...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })),
// The MT Code palette ships to web and desktop only: mobile has no theme
// classes for it, and normalizeMobileThemeId would discard the choice.
...BUILT_IN_THEMES.filter((theme) => theme.id !== MT_CODE_THEME_ID).map((theme) => ({
id: theme.id as MobileThemeId,
label: theme.label,
})),
];

// Closed set: every key `createMobileThemeVariables` writes. Reads of a
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/widgets/AgentActivity.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, it, vi } from "vite-plus/test";

// branding reads expo-constants, whose native runtime cannot load under Vitest.
vi.mock("expo-constants", () => ({ default: { expoConfig: null } }));

vi.mock("@expo/ui/swift-ui", () => ({
HStack: "HStack",
Image: "Image",
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/cloud/servicePreflight.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, it } from "@effect/vitest";

import { resolveAppDisplayName } from "../appDisplayName.ts";
import { runServicePreflight } from "./servicePreflight.ts";
import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";

Expand All @@ -13,8 +14,8 @@ it.each([1, 2])("blocks legacy launcher protocol %i", (launcherProtocol) => {
).toEqual({
status: "blocked",
version: "1.2.3",
reason:
"This release requires a newer T3 Code service launcher. Update it on the server machine.",
// The brand is whatever build this runs in, so assert against the same source.
reason: `This release requires a newer ${resolveAppDisplayName()} service launcher. Update it on the server machine.`,
});
});

Expand Down
17 changes: 16 additions & 1 deletion apps/server/src/notifications/SelfHostedPushNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,16 @@ export const make = Effect.gen(function* () {
updated_at = excluded.updated_at
`,
});
// expo_push_token is UNIQUE, and the upsert only resolves a device_id
// conflict: a token re-registered under a new device id would fail the insert
// and leave the stale row receiving the pushes. The newest registration wins.
const releasePushToken = SqlSchema.void({
Request: Schema.Struct({ deviceId: Schema.String, expoPushToken: Schema.String }),
execute: ({ deviceId, expoPushToken }) => sql`
DELETE FROM self_hosted_push_devices
WHERE expo_push_token = ${expoPushToken} AND device_id <> ${deviceId}
`,
});
const deleteDevice = SqlSchema.void({
Request: Schema.Struct({ deviceId: Schema.String }),
execute: ({ deviceId }) =>
Expand All @@ -132,7 +142,12 @@ export const make = Effect.gen(function* () {

const register: SelfHostedPushNotifications["Service"]["register"] = (input) =>
DateTime.now.pipe(
Effect.flatMap((now) => upsertDevice({ ...input, updatedAt: DateTime.formatIso(now) })),
Effect.flatMap((now) =>
releasePushToken({ deviceId: input.deviceId, expoPushToken: input.expoPushToken }).pipe(
Effect.andThen(upsertDevice({ ...input, updatedAt: DateTime.formatIso(now) })),
sql.withTransaction,
),
),
Effect.mapError(
(cause) => new SelfHostedPushNotificationError({ operation: "register", cause }),
),
Expand Down
27 changes: 15 additions & 12 deletions apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,9 +368,8 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
// removed. Keep the workspace outside the scoped directory and let it
// go with a retrying removal once the child has gone.
const workspaceCwd = yield* fs.makeTempDirectory({ prefix: "t3-claude-probe-cwd-" });
// Node's own retry rather than an Effect schedule: it.effect runs on a
// TestClock, so a scheduled retry would wait for time nobody advances.
// If the child still holds the directory after that, an empty temp
// Node's own retry rather than an Effect schedule keeps the cleanup
// independent of the test clock. If the child still holds the directory after that, an empty temp
// directory is left behind rather than failing the test for it.
yield* Effect.addFinalizer(() =>
Effect.promise(() =>
Expand All @@ -383,15 +382,19 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
),
);

const capabilities = yield* probeClaudeCapabilities(
decodeClaudeSettings({ binaryPath: executablePath }),
{
...process.env,
T3_PROBE_INVOCATION_PATH: invocationPath,
T3_PROBE_STALL_USAGE: "true",
ENABLE_CLAUDEAI_MCP_SERVERS: "true",
},
workspaceCwd,
// Real clock: only the probe's own usage deadline releases the stalled
// request, and a TestClock that nobody advances never reaches it.
const capabilities = yield* TestClock.withLive(
probeClaudeCapabilities(
decodeClaudeSettings({ binaryPath: executablePath }),
{
...process.env,
T3_PROBE_INVOCATION_PATH: invocationPath,
T3_PROBE_STALL_USAGE: "true",
ENABLE_CLAUDEAI_MCP_SERVERS: "true",
},
workspaceCwd,
),
);

assert.deepEqual(capabilities, {
Expand Down
7 changes: 3 additions & 4 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,9 @@ const readClaudeRateLimitTier: Effect.Effect<
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configDir = process.env.CLAUDE_CONFIG_DIR?.trim();
const candidates = [
...(configDir ? [path.join(configDir, ".claude.json")] : []),
path.join(NodeOS.homedir(), ".claude.json"),
];
// Like the CLI: with CLAUDE_CONFIG_DIR set, the account file lives there and
// the home-directory file belongs to a different (default) login.
const candidates = [path.join(configDir || NodeOS.homedir(), ".claude.json")];
for (const file of candidates) {
const parsed = yield* fs
.readFileString(file)
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as CodexInstallation from "../CodexInstallation.ts";
import * as ServerEnvironment from "../../environment/ServerEnvironment.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, it, assert } from "@effect/vitest";
import { afterEach, beforeEach, vi } from "vite-plus/test";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -3186,7 +3187,6 @@ it.layer(Layer.mergeAll(TestServices, ServerSettingsModule.layerTest(), TestHttp
"claudeAgent",
"codex",
"cursor",
"devin",
"grok",
"opencode",
"pi",
Expand Down Expand Up @@ -3218,6 +3218,15 @@ it.layer(Layer.mergeAll(TestServices, ServerSettingsModule.layerTest(), TestHttp
// ── checkClaudeProviderStatus tests ──────────────────────────

describe("checkClaudeProviderStatus", () => {
// The subscription label reads the account's rate-limit tier from
// .claude.json; point it at a missing config dir so the host's own
// login (e.g. a Max 20x account) cannot change the expected labels.
beforeEach(() => {
vi.stubEnv("CLAUDE_CONFIG_DIR", "/nonexistent/t3-test-claude-config");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it.effect("returns ready when claude is installed and authenticated", () =>
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus(
Expand Down
Loading
Loading