Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c13a021
feat: default sidebar v2 on for nightly and dev builds (#4491)
t3dotgg Jul 28, 2026
dd5ea32
fix(web): 33 web UI fixes (#4700)
maxktz Jul 28, 2026
b0c4992
Make mobile Thread List v2 the default (#4717)
juliusmarminge Jul 28, 2026
d25b157
Fix mobile showcase workflow without Clerk (#4718)
juliusmarminge Jul 28, 2026
e00781a
Fix relay credential lookup for unlinked environments (#4692)
juliusmarminge Jul 28, 2026
9cf9fc9
Settle merged PR threads immediately (#4704)
t3dotgg Jul 28, 2026
362f127
feat(web): add maria's sidebar header artwork toggle (#4652)
maxktz Jul 28, 2026
4f584da
feat(web): add appearance settings category (#4715)
maxktz Jul 28, 2026
9ccfd9d
fix(desktop): allow updater-controlled relaunch (#4721)
0x4bs3nt Jul 28, 2026
3c50a64
fix(web): prevent diff panel scroll jumping (#4724)
0x4bs3nt Jul 28, 2026
55dd016
Link inline code file paths in chat markdown (#4726)
juliusmarminge Jul 28, 2026
f1a68ac
Fix Android showcase capture and rebuild v2 queued rows (#4730)
juliusmarminge Jul 28, 2026
887dd6e
perf(server): negotiate permessage-deflate on the websocket (#4705)
t3dotgg Jul 28, 2026
b64ae88
docs: overhaul agent guidance (#4782)
t3dotgg Jul 28, 2026
2deea7a
Prevent sidebar row labels from truncating (#4789)
juliusmarminge Jul 28, 2026
8829e2f
perf(server): gzip large thread snapshots (#4788)
t3dotgg Jul 28, 2026
f2d2fb2
fix(web): stashed prompts now survive switching providers (#4787)
t3dotgg Jul 28, 2026
38a6e3c
Fix Git ref refresh resource storms (#4727)
juliusmarminge Jul 28, 2026
5fcdefd
perf(server): trim stale context-window rows and drop dead replay RPC…
t3dotgg Jul 28, 2026
1ba3d01
fix(web): defer command palette filesystem navigation (#2109)
juliusmarminge Jul 28, 2026
2919147
fix(release): skip scripts during Vercel installs (#4796)
t3dotgg Jul 28, 2026
936593c
refactor(client): share filesystem browse navigation (#4797)
juliusmarminge Jul 28, 2026
8fe8f9a
fix(mobile): defer filesystem navigation (#4799)
juliusmarminge Jul 28, 2026
8650f05
refactor(server): use native HTTP compression streams (#4798)
juliusmarminge Jul 29, 2026
60af905
Remove Connect waitlist and add GA announcement tooling (#4691)
juliusmarminge Jul 29, 2026
1b61ce1
Fix Connect sign-in settings label (#4806)
juliusmarminge Jul 29, 2026
694f8d1
chore(release): prepare v0.0.30
t3-code-bot[bot] Jul 29, 2026
8b27132
Merge branch 'main' into claude/sync-upstream-v0.0.30
NoahHendrickson Jul 29, 2026
2a6d5f1
fix(fork): absorb upstream v0.0.30 into fork surfaces
NoahHendrickson Jul 29, 2026
755e8c7
fix(fork): land review feedback on the v0.0.30 sync
NoahHendrickson Jul 29, 2026
458acfc
fix(fork): land multi-agent review findings on the v0.0.30 sync
NoahHendrickson Jul 29, 2026
ac649cb
docs(fork): record the prompt-stash data-loss keep as a pending relea…
NoahHendrickson Jul 29, 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
181 changes: 134 additions & 47 deletions AGENTS.md

Large diffs are not rendered by default.

22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,36 @@
# T3 Code

T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, Cursor, and OpenCode, more coming soon).
T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app, [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes).

Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them.

## "Wait, what are you selling me?"

Nothing. We built T3 Code because we wanted the best possible development experience with agents. We were inspired by existing solutions like the Codex desktop app, Conductor, Claude Desktop and Cursor Glass, but none met our bar.

We wanted something performant, remote-ready, and truly open. If we ever go the wrong direction, we want you to have everything you need to fork and build the editor that you want.

## Installation

> [!WARNING]
> T3 Code currently supports Codex, Claude, Cursor, and OpenCode.
> Install and authenticate at least one provider before use:
> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use:
>
> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `cursor-agent login`
> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`

### Run without installing
### Try it out (install-free)

The easiest way to test T3 Code is to run the server in your terminal:

```bash
npx t3@latest
```

This will launch T3 Code's backend on your machine as well as the local web app to control your agents.

Tip: Use `npx t3@latest --help` for the full CLI reference.

### Desktop app
Expand Down Expand Up @@ -47,7 +59,7 @@ yay -S t3code-bin

We are very very early in this project. Expect bugs.

We are not accepting contributions yet.
We are (mostly) not accepting contributions yet. Small fixes may be considered. Big features will not be.

There's no public docs site yet, checkout the miscellaneous markdown files in [docs](./docs).

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.29",
"version": "0.0.30",
"private": true,
"type": "module",
"main": "dist-electron/main.cjs",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
calls.setDockIcon.push(iconPath);
}),
appendCommandLineSwitch: () => Effect.void,
onBeforeQuitForUpdate: () => Effect.void,
on: () => Effect.void,
} satisfies ElectronApp.ElectronApp["Service"]);

Expand Down
123 changes: 123 additions & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";

import type * as Electron from "electron";

import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
import * as DesktopShutdown from "./DesktopShutdown.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";

describe("DesktopLifecycle", () => {
for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>) {
it.effect(`lets the updater's quit event proceed on ${platform}`, () => {
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();

const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
whenReady: Effect.void,
quit: Effect.void,
exit: () => Effect.void,
relaunch: () => Effect.void,
setPath: () => Effect.void,
setName: () => Effect.void,
setAboutPanelOptions: () => Effect.void,
setAppUserModelId: () => Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: () => Effect.succeed(true),
setDesktopName: () => Effect.void,
setDockIcon: () => Effect.void,
appendCommandLineSwitch: () => Effect.void,
onBeforeQuitForUpdate: (listener) =>
Effect.acquireRelease(
Effect.sync(() => {
appListeners.set("before-quit-for-update", listener);
}),
() =>
Effect.sync(() => {
appListeners.delete("before-quit-for-update");
}),
).pipe(Effect.asVoid),
on: (eventName, listener) =>
Effect.acquireRelease(
Effect.sync(() => {
appListeners.set(
eventName,
listener as unknown as (...args: readonly unknown[]) => void,
);
}),
() =>
Effect.sync(() => {
appListeners.delete(eventName);
}),
).pipe(Effect.asVoid),
} satisfies ElectronApp.ElectronApp["Service"]);

const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, {
shouldUseDarkColors: Effect.succeed(false),
setSource: () => Effect.void,
onUpdated: () => Effect.void,
});

const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, {
createMain: Effect.die("unexpected window creation"),
ensureMain: Effect.die("unexpected window creation"),
revealOrCreateMain: Effect.die("unexpected window creation"),
activate: Effect.void,
createMainIfBackendReady: Effect.void,
showConnectingSplash: Effect.void,
handleBackendReady: () => Effect.void,
handleBackendNotReady: Effect.void,
flushMainWindowBounds: Effect.void,
dispatchMenuAction: () => Effect.void,
syncAppearance: Effect.void,
});

const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
platform,
isDevelopment: false,
} as DesktopEnvironment.DesktopEnvironment["Service"]);

const layer = DesktopLifecycle.layer.pipe(
Layer.provideMerge(electronAppLayer),
Layer.provideMerge(electronThemeLayer),
Layer.provideMerge(desktopWindowLayer),
Layer.provideMerge(environmentLayer),
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
);

return Effect.scoped(
Effect.gen(function* () {
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
yield* lifecycle.register;

appListeners.get("before-quit-for-update")?.();

let prevented = false;
const event = {
preventDefault: () => {
prevented = true;
},
} as Electron.Event;
appListeners.get("before-quit")?.(event);

assert.isFalse(
prevented,
"cancelling this event prevents the updater from completing its relaunch",
);

const state = yield* DesktopState.DesktopState;
assert.isTrue(yield* Ref.get(state.quitting));
}),
).pipe(Effect.provide(layer));
});
}
});
14 changes: 13 additions & 1 deletion apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,16 +176,28 @@ export const make = DesktopLifecycle.of({
const context = yield* Effect.context<DesktopLifecycleRuntimeServices>();
const runEffect = Effect.runPromiseWith(context);
let quitAllowed = false;
let updaterQuitAllowed = false;
yield* electronTheme.onUpdated(() => {
void runEffect(
desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")),
);
});
yield* electronApp.onBeforeQuitForUpdate(() => {
// Electron's updater owns the remaining quit/install/relaunch sequence.
// Cancelling the following app "before-quit" event breaks that sequence,
// most visibly on macOS where the native updater performs the relaunch.
updaterQuitAllowed = true;
void runEffect(
logLifecycleInfo("allowing updater-controlled quit").pipe(
Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"),
),
);
});
yield* electronApp.on("before-quit", (event: Electron.Event) => {
handleBeforeQuit(
event,
runEffect,
() => quitAllowed,
() => quitAllowed || updaterQuitAllowed,
() => {
quitAllowed = true;
},
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/electron/ElectronApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { beforeEach, vi } from "vite-plus/test";

const {
appendSwitchMock,
autoUpdaterOnMock,
autoUpdaterRemoveListenerMock,
exitMock,
getAppPathMock,
getVersionMock,
Expand All @@ -23,6 +25,8 @@ const {
whenReadyMock,
} = vi.hoisted(() => ({
appendSwitchMock: vi.fn(),
autoUpdaterOnMock: vi.fn(),
autoUpdaterRemoveListenerMock: vi.fn(),
exitMock: vi.fn(),
getAppPathMock: vi.fn(() => "/app"),
getVersionMock: vi.fn(() => "1.2.3"),
Expand All @@ -43,6 +47,10 @@ const {
}));

vi.mock("electron", () => ({
autoUpdater: {
on: autoUpdaterOnMock,
removeListener: autoUpdaterRemoveListenerMock,
},
app: {
commandLine: {
appendSwitch: appendSwitchMock,
Expand Down Expand Up @@ -77,6 +85,8 @@ import * as ElectronApp from "./ElectronApp.ts";
describe("ElectronApp", () => {
beforeEach(() => {
appendSwitchMock.mockClear();
autoUpdaterOnMock.mockClear();
autoUpdaterRemoveListenerMock.mockClear();
exitMock.mockClear();
onMock.mockClear();
quitMock.mockClear();
Expand Down Expand Up @@ -153,4 +163,22 @@ describe("ElectronApp", () => {
assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]);
}).pipe(Effect.provide(ElectronApp.layer)),
);

it.effect("scopes native updater quit listeners", () =>
Effect.gen(function* () {
const listener = vi.fn();

yield* Effect.scoped(
Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
yield* electronApp.onBeforeQuitForUpdate(listener);
}),
);

assert.deepEqual(autoUpdaterOnMock.mock.calls, [["before-quit-for-update", listener]]);
assert.deepEqual(autoUpdaterRemoveListenerMock.mock.calls, [
["before-quit-for-update", listener],
]);
}).pipe(Effect.provide(ElectronApp.layer)),
);
});
13 changes: 13 additions & 0 deletions apps/desktop/src/electron/ElectronApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export class ElectronApp extends Context.Service<
readonly setDesktopName: (desktopName: string) => Effect.Effect<void>;
readonly setDockIcon: (iconPath: string) => Effect.Effect<void>;
readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>;
readonly onBeforeQuitForUpdate: (
listener: () => void,
) => Effect.Effect<void, never, Scope.Scope>;
readonly on: <Args extends ReadonlyArray<unknown>>(
eventName: string,
listener: (...args: Args) => void,
Expand Down Expand Up @@ -178,6 +181,16 @@ export const make = ElectronApp.of({
}
Electron.app.commandLine.appendSwitch(switchName, value);
}),
onBeforeQuitForUpdate: (listener) =>
Effect.acquireRelease(
Effect.sync(() => {
Electron.autoUpdater.on("before-quit-for-update", listener);
}),
() =>
Effect.sync(() => {
Electron.autoUpdater.removeListener("before-quit-for-update", listener);
}),
).pipe(Effect.asVoid),
on: addScopedAppListener,
});

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const clientSettings: ClientSettings = {
confirmThreadDelete: false,
dismissedProviderUpdateNotificationKeys: [],
diffIgnoreWhitespace: true,
environmentIdentificationMode: "artwork",
favorites: [],
glassOpacity: 80,
providerModelPreferences: {},
Expand All @@ -30,6 +31,7 @@ const clientSettings: ClientSettings = {
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
sidebarV2Enabled: false,
sidebarV2ConfiguredByUser: false,
timestampFormat: "24-hour",
wordWrap: true,
};
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
setDesktopName: () => Effect.void,
setDockIcon: () => Effect.void,
appendCommandLineSwitch: () => Effect.void,
onBeforeQuitForUpdate: () => Effect.void,
on: () => Effect.void,
} satisfies ElectronApp.ElectronApp["Service"]);

Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen";
import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
import {
SettingsLegalDocumentCloseHeaderButton,
Expand Down Expand Up @@ -190,10 +189,11 @@ const SettingsSheetStack = createNativeStackNavigator({
},
}),
SettingsWaitlist: createNativeStackScreen({
screen: SettingsWaitlistRouteScreen,
// Keep the old deep link working after the Connect GA launch.
screen: SettingsAuthRouteScreen,
linking: "waitlist",
options: {
title: "Join the waitlist",
title: "Sign in",
},
}),
},
Expand Down
Loading
Loading