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
35a6840
fix(web): restore sidebar v2 thread actions and terminal icon (#4712)
Noojuno Jul 30, 2026
2d9066e
fix(web): settle button now works on hover, not just right-click (#4905)
t3dotgg Jul 30, 2026
758deca
fix(clients): disable add project while disconnected (#4834)
StiensWout Jul 30, 2026
b125b76
fix(composer): hide default Codex service tier (#4784)
maxktz Jul 30, 2026
748203e
docs: link iOS and Android app store downloads (#4902)
t3dotgg Jul 30, 2026
6efcf3e
fix(web): align remote server update action (#4731)
StiensWout Jul 30, 2026
6ab2f16
fix(connect): suggest a serve command that matches how you ran connec…
t3dotgg Jul 30, 2026
00da6b5
fix(mobile): stop shared content errors in Personal Team builds (#4943)
t3dotgg Jul 30, 2026
f0f16e4
perf(mobile): sends respond instantly, thread opens stop freezing (#4…
t3dotgg Jul 30, 2026
9146ed2
fix(web): show Codex fast mode as a bolt (#4947)
t3dotgg Jul 30, 2026
2f466ff
docs: seed worktrees with a copy of real userdata instead of banning …
t3dotgg Jul 30, 2026
2652fee
fix(mobile): support dragged images in the composer (#4953)
t3dotgg Jul 30, 2026
90f3913
fix(mobile): stop long iOS threads from jumping while scrolling up (#…
t3dotgg Jul 30, 2026
f0c6ba9
fix(web): keep worktree default when switching a draft's machine (#4964)
t3dotgg Jul 30, 2026
69a18ce
perf(mobile): reconnect environments immediately on resume (#4878)
t3dotgg Jul 30, 2026
cbe8052
feat(web): pasting a huge screenshot now compresses it instead of err…
t3dotgg Jul 30, 2026
5c9358a
feat(web): regenerate thread titles from sidebar (#4810)
t3dotgg Jul 30, 2026
197d348
fix(web): show server update progress through reconnect (#4903)
t3dotgg Jul 30, 2026
4b71a2a
feat(search): find threads by conversation content (#4959)
t3dotgg Jul 30, 2026
50871eb
fix: marketing site Vercel builds no longer die after ~100 deploys (#…
t3dotgg Jul 30, 2026
9dd425b
docs: split user and maintainer docs, fix 100+ stale claims (#4807)
t3dotgg Jul 30, 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
27 changes: 18 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ T3 Code has 3 key app surfaces: **web**, **desktop**, and **mobile**.

**Desktop** is the main surface most users install first. It's a full Electron app that bundles the server runner as well. The desktop app can also be used as the host server, allowing remote connections from app.t3.codes or the mobile app.

**Mobile** is a React native app for both iOS and Android. The mobile app allows for connecting to any T3 Code server to control work remotely. It is still in early access (Testflight), but it is pretty close to shipping globally.
**Mobile** is a React Native app for both iOS and Android, available on the App Store and Google Play. The mobile app allows for connecting to any T3 Code server to control work remotely.

## A note from Theo

Expand Down Expand Up @@ -59,7 +59,7 @@ We need to be on the same page with terminology. When communicating, use this la
## The three ways to hurt yourself

1. **Killing by pattern.** Never `pkill -f`, `pgrep | kill`, or `kill` a PID you found by matching a name, path, or worktree string. Your own agent process has this worktree's path in its argv, and this machine runs several other dev servers at once. Kill only a PID you captured at spawn, or the owner of your port from `ss -H -ltnp` after confirming `/proc/<pid>/cwd` is your worktree.
2. **Touching the live install.** `~/.t3/userdata` is the developer's real T3 Code database, in use while you work. Read-only inspection is fine. Never start a server against it, never open it read-write, never clean it up.
2. **Writing to the live install.** `~/.t3/userdata` is the developer's real T3 Code database, in use while you work. Reading it and copying from it are fine, and a good way to get real test data (see Test data). Never start a server against it, never open it read-write, never clean it up.
3. **Baking in origins.** Never set `VITE_HTTP_URL` or `VITE_WS_URL` for dev. Dev is single-origin and Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known`. Setting them bakes localhost into the bundle and silently breaks every remote browser.

## Hit every surface
Expand All @@ -72,7 +72,7 @@ The most common defect in this repo is a change that works on the path you teste
- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow.
- **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug.
- **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real.
- **Docs.** `docs/` mirrors this structure. Behavior changes that a user would notice belong in `docs/user/`; architecture changes in `docs/architecture/`; new vocabulary in `docs/reference/encyclopedia.md`.
- **Docs.** `docs/` splits by audience. Behavior changes that a user would notice belong in `docs/user/` (shipped-product voice, no repo tooling or source paths); architecture and contributor changes in `docs/internals/`; runbooks in `docs/operations/`; new vocabulary in `docs/internals/glossary.md`.

## Dev servers

Expand All @@ -85,10 +85,19 @@ The most common defect in this repo is a change that works on the path you teste

## Test data

An empty database is a bad test. Seed your worktree's `.t3` instead of pointing at live state:
An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state:

- Copy from `~/.t3/userdata` (the developer's real data, the most realistic test set) or `~/.t3/dev`. Worktree state lives at `<worktree>/.t3/userdata`.
- Snapshot the database with `VACUUM INTO`, which is safe even while a server has the source open and yields one consistent file:

```bash
mkdir -p .t3/userdata
rm -f .t3/userdata/state.sqlite* # VACUUM INTO refuses to overwrite
bun -e "new (require('bun:sqlite').Database)(process.env.HOME + '/.t3/userdata/state.sqlite', { readonly: true }).run(\"VACUUM INTO '.t3/userdata/state.sqlite'\")"
```

A plain `cp` is only safe when no server has the source open, and must bring the `-wal` and `-shm` siblings along. A live file copy is a corrupt copy.

- Copy from `~/.t3/dev`, never from `~/.t3/userdata`.
- Copy `state.sqlite` together with its `-wal` and `-shm` siblings, and only while no server has the source open. A live copy is a corrupt copy.
- Bring `secrets` and `settings.json` only if the flow under test needs them.
- Copy in, never symlink. Data flows one way: into your sandbox, never back out.

Expand All @@ -114,13 +123,13 @@ An empty database is a bad test. Seed your worktree's `.t3` instead of pointing

Clients send typed WebSocket requests. The server turns them into _commands_, a pure _decider_ turns commands into persisted _events_, and a _projector_ derives the read model the UI renders. Provider CLIs run as subprocesses; per-provider _adapters_ translate their native protocols into orchestration events. Side effects run in queue-backed _reactors_ that emit _receipts_ when milestones land. Each turn ends with a _checkpoint_, a hidden git ref, so the app can diff and restore.

Full glossary with file links: `docs/reference/encyclopedia.md`
Full glossary with file links: `docs/internals/glossary.md`

## Where code lives

- `apps/server` - WebSocket, orchestration, providers, checkpointing. Effect-heavy: read `.repos/effect-smol/LLMS.md` and `docs/operations/effect-fn-checklist.md` before writing Effect code.
- `apps/server` - WebSocket, orchestration, providers, checkpointing. Effect-heavy: read `.repos/effect-smol/LLMS.md` before writing Effect code.
- `apps/web` - React/Vite UI. `apps/desktop` wraps it, `apps/mobile` is React Native, `apps/marketing` is the site.
- `packages/contracts` - Effect/Schema contracts. Schema only, no runtime logic.
- `packages/contracts` - Effect/Schema contracts plus small derived helpers. No heavy runtime logic.
- `packages/shared` - shared runtime utils, subpath exports, no barrel.
- `packages/client-runtime` - client code shared by web and mobile.
- `.repos/` - vendored read-only references. Prefer their patterns over invented ones. Never edit or import from them. Sync with `vpr sync:repos` when bumping the matching dependency.
Expand Down
27 changes: 15 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# T3 Code

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).
T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [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.

Expand All @@ -17,13 +17,13 @@ We wanted something performant, remote-ready, and truly open. If we ever go the
>
> - 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`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `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`

### Try it out (install-free)

The easiest way to test T3 Code is to run the server in your terminal:
The easiest way to test T3 Code is to run the server in your terminal (requires Node.js 22.16+, 23.11+, or 24.10+):

```bash
npx t3@latest
Expand Down Expand Up @@ -61,17 +61,20 @@ We are very very early in this project. Expect bugs.

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).

## Documentation

- [Getting started](./docs/getting-started/quick-start.md)
- [Remote access](./docs/user/remote-access.md)
- [Keeping T3 Code in sync](./docs/user/server-updates.md)
- [Architecture overview](./docs/architecture/overview.md)
- [Provider guides](./docs/providers/codex.md)
- [Operations](./docs/operations/ci.md)
- [Reference](./docs/reference/encyclopedia.md)
Full docs live in [docs/](./docs). There's no docs site yet.

- [Install and first run](./docs/user/install.md)
- [Permission modes](./docs/user/permission-modes.md)
- [Keyboard shortcuts](./docs/user/keybindings.md)
- [Remote access from a phone or another machine](./docs/user/remote-access.md)
- [Keeping app and server in sync](./docs/user/updating.md)
- [Source control integrations](./docs/user/source-control.md)
- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md)
- Linux: [run T3 Code as a background service](./docs/user/background-service.md)

Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md).

## If you REALLY want to contribute still.... read this first

Expand Down
9 changes: 8 additions & 1 deletion apps/marketing/src/layouts/Layout.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
---
import { GITHUB_REPOSITORY_URL, MARKETING_STATS } from "../lib/site";
import {
ANDROID_PLAY_STORE_URL,
GITHUB_REPOSITORY_URL,
IOS_APP_STORE_URL,
MARKETING_STATS,
} from "../lib/site";

interface Props {
title?: string;
Expand Down Expand Up @@ -69,6 +74,8 @@ const {
<a href={GITHUB_REPOSITORY_URL} target="_blank" rel="noopener noreferrer">GitHub</a>
<a href="https://discord.gg/jn4EGJjrvv" target="_blank" rel="noopener noreferrer">Discord</a>
<a href="/download">Download</a>
<a href={IOS_APP_STORE_URL} target="_blank" rel="noopener noreferrer">iOS app</a>
<a href={ANDROID_PLAY_STORE_URL} target="_blank" rel="noopener noreferrer">Android app</a>
<a href="/terms-of-service">Terms</a>
<a href="/privacy-policy">Privacy</a>
<a href="/security-policy">Security</a>
Expand Down
6 changes: 6 additions & 0 deletions apps/marketing/src/lib/site.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
export const GITHUB_REPOSITORY_URL = "https://github.com/pingdotgg/t3code";

export const IOS_APP_STORE_URL =
"https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824";

export const ANDROID_PLAY_STORE_URL =
"https://play.google.com/store/apps/details?id=com.t3tools.t3code";

export const MARKETING_STATS = {
githubStars: "14k+",
users: "100,000",
Expand Down
22 changes: 21 additions & 1 deletion apps/marketing/src/pages/download.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
import Layout from "../layouts/Layout.astro";
import { RELEASES_URL } from "../lib/releases";
import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "../lib/site";
---

<Layout title="Download — T3 Code" description="Download T3 Code for macOS, Windows, or Linux.">
Expand Down Expand Up @@ -57,6 +58,24 @@ import { RELEASES_URL } from "../lib/releases";
</a>
</div>
</section>

<!-- Mobile -->
<section class="platform-section">
<div class="platform-header">
<svg class="platform-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="7" y="2" width="10" height="20" rx="2.5"/><path d="M11 18.5h2"/></svg>
<h2 class="platform-name">Mobile</h2>
</div>
<div class="download-cards">
<a class="download-card" href={IOS_APP_STORE_URL} target="_blank" rel="noopener noreferrer">
<span class="card-arch">iPhone &amp; iPad</span>
<span class="card-format">App Store</span>
</a>
<a class="download-card" href={ANDROID_PLAY_STORE_URL} target="_blank" rel="noopener noreferrer">
<span class="card-arch">Android</span>
<span class="card-format">Google Play</span>
</a>
</div>
</section>
</div>

<p class="releases-link">
Expand All @@ -73,7 +92,8 @@ import { RELEASES_URL } from "../lib/releases";

async function init() {
const versionLabel = document.getElementById("version-label");
const cards = document.querySelectorAll<HTMLAnchorElement>(".download-card");
// Only release-asset cards; mobile store cards have no data-asset and keep their href.
const cards = document.querySelectorAll<HTMLAnchorElement>(".download-card[data-asset]");

try {
const release = await fetchLatestRelease();
Expand Down
35 changes: 34 additions & 1 deletion apps/marketing/src/pages/index.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
---
import Layout from "../layouts/Layout.astro";
import { GITHUB_REPOSITORY_URL, MARKETING_STATS } from "../lib/site";
import {
ANDROID_PLAY_STORE_URL,
GITHUB_REPOSITORY_URL,
IOS_APP_STORE_URL,
MARKETING_STATS,
} from "../lib/site";
import { tweets } from "../lib/tweets";

const desktopEndorsementRows = [
Expand Down Expand Up @@ -66,6 +71,12 @@ const mobileEndorsementRows = [
<span>Steal our code (legally)</span>
<svg class="hero-source-arrow" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17L17 7M8 7h9v9"/></svg>
</a>
<p class="hero-mobile-line">
Also on your phone:
<a href={IOS_APP_STORE_URL} target="_blank" rel="noopener noreferrer">iOS</a>
<span aria-hidden="true">·</span>
<a href={ANDROID_PLAY_STORE_URL} target="_blank" rel="noopener noreferrer">Android</a>
</p>
</div>

<div class="hero-preview">
Expand Down Expand Up @@ -578,6 +589,28 @@ const mobileEndorsementRows = [
opacity: 1;
}

.hero-mobile-line {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--fg-dim);
font-size: 13px;
letter-spacing: -0.005em;
}

.hero-mobile-line a {
color: var(--fg-muted);
text-decoration: underline;
text-decoration-color: rgba(161, 161, 170, 0.4);
text-underline-offset: 3px;
transition: color 0.18s ease, text-decoration-color 0.18s ease;
}

.hero-mobile-line a:hover {
color: var(--fg);
text-decoration-color: var(--fg);
}

/* Download button icons (platform-aware) */
.dl-icon {
display: none;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ private final class ComposerTextView: UITextView {
replace(textRange, withText: "")
}

private func loadImages(from providers: [NSItemProvider]) {
func loadImages(from providers: [NSItemProvider]) {
let group = DispatchGroup()
let lock = NSLock()
var images = [UIImage?](repeating: nil, count: providers.count)
Expand Down Expand Up @@ -282,7 +282,7 @@ private final class ComposerTextView: UITextView {
}
}

public final class T3ComposerEditorView: ExpoView, UITextViewDelegate {
public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDropDelegate {
private let textView = ComposerTextView()
private let placeholderLabel = UILabel()
private var value = ""
Expand Down Expand Up @@ -326,6 +326,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate {

clipsToBounds = false
textView.delegate = self
textView.textDropDelegate = self
textView.backgroundColor = .clear
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
Expand Down Expand Up @@ -500,6 +501,41 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate {
return true
}

public func textDroppableView(
_ textDroppableView: UIView & UITextDroppable,
proposalForDrop drop: UITextDropRequest
) -> UITextDropProposal {
guard droppedImageProviders(in: drop) != nil else {
return drop.suggestedProposal
}

// The composer owns image drops so UIKit does not insert NSTextAttachments
// that the controlled plain-text value cannot represent.
let proposal = UITextDropProposal(operation: .copy)
proposal.dropAction = .insert
proposal.dropPerformer = .delegate
return proposal
}

public func textDroppableView(
_ textDroppableView: UIView & UITextDroppable,
willPerformDrop drop: UITextDropRequest
) {
guard let imageProviders = droppedImageProviders(in: drop) else {
return
}
textView.loadImages(from: imageProviders)
}

private func droppedImageProviders(in drop: UITextDropRequest) -> [NSItemProvider]? {
let providers = drop.dropSession.items.map(\.itemProvider)
guard !providers.isEmpty,
providers.allSatisfy({ $0.canLoadObject(ofClass: UIImage.self) }) else {
return nil
}
return providers
}

public func textViewDidBeginEditing(_ textView: UITextView) {
onComposerFocus()
}
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"@expo-google-fonts/dm-sans": "^0.4.2",
"@expo/metro-runtime": "~56.0.15",
"@expo/ui": "~56.0.18",
"@legendapp/list": "3.2.0",
"@legendapp/list": "3.3.3",
"@noble/curves": "catalog:",
"@noble/hashes": "catalog:",
"@pierre/diffs": "catalog:",
Expand Down
11 changes: 10 additions & 1 deletion apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,15 @@ function workspacePathFromState(state: NavigationState): string {
return path.startsWith("/") ? path : `/${path}`;
}

// The drain hook subscribes to the outbox, all thread shells, projects, and
// connection statuses. Hosting it in a null-rendering leaf keeps those
// updates from re-rendering RootStackLayout (and with it every screen) on
// each enqueue, shell change, or reconnect.
function ThreadOutboxDrainWorker() {
useThreadOutboxDrain();
return null;
}

function RootStackLayout(props: {
readonly children: React.ReactNode;
readonly state: NavigationState;
Expand All @@ -292,7 +301,6 @@ function RootStackLayout(props: {
const { pendingShare } = useIncomingShare();
const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
useAgentNotificationNavigation();
useThreadOutboxDrain();
// Presents the T3 Connect onboarding sheet after an in-session sign-in.
useConnectOnboardingNavigation();
// Launcher app shortcuts: routes shortcut taps and tracks opened threads.
Expand Down Expand Up @@ -320,6 +328,7 @@ function RootStackLayout(props: {

return (
<HardwareKeyboardCommandProvider pathname={pathname}>
<ThreadOutboxDrainWorker />
<ShowcaseCaptureCoordinator pathname={pathname} />
<ClerkSettingsSheetDetentProvider initiallyExpanded={false}>
<AdaptiveWorkspaceLayout pathname={workspacePathname}>
Expand Down
21 changes: 21 additions & 0 deletions apps/mobile/src/connection/app-state-wakeups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "@effect/vitest";

import {
MOBILE_BACKGROUND_RECONNECT_AFTER_MS,
mobileApplicationActiveWakeup,
} from "./app-state-wakeups";

describe("mobileApplicationActiveWakeup", () => {
it("uses a fast probe after a short interruption", () => {
expect(mobileApplicationActiveWakeup(null, 20_000)).toBe("application-active-probe");
expect(
mobileApplicationActiveWakeup(20_000, 20_000 + MOBILE_BACKGROUND_RECONNECT_AFTER_MS - 1),
).toBe("application-active-probe");
});

it("replaces the session after a meaningful background suspension", () => {
expect(
mobileApplicationActiveWakeup(20_000, 20_000 + MOBILE_BACKGROUND_RECONNECT_AFTER_MS),
).toBe("application-active-reconnect");
});
});
Loading
Loading