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
22 changes: 22 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ jobs:
node-version-file: package.json
cache: true
run-install: true
env:
pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata

- id: release_meta
name: Resolve release version
Expand Down Expand Up @@ -174,6 +176,14 @@ jobs:
--current-tag "${{ steps.release_meta.outputs.tag }}" \
--github-output

# Share only the verification results, not the large registry metadata cache.
- name: Upload dependency verification
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: release-dependency-verification
path: ${{ runner.temp }}/pnpm-metadata/lockfile-verified.jsonl

quality:
name: Release quality checks
needs: [preflight]
Expand Down Expand Up @@ -446,7 +456,18 @@ jobs:
path: ${{ steps.package_cache_path.outputs.path }}
key: windows-release-packages-v1-${{ matrix.arch }}-${{ hashFiles('pnpm-lock.yaml') }}

# pnpm checks the lockfile and policy before reusing this result. A missing
# artifact leaves the cache empty, so installation runs the checks again.
- name: Download dependency verification
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: release-dependency-verification
path: ${{ runner.temp }}/pnpm-metadata

- name: Install desktop dependencies
env:
pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata
run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts...

- name: Cache resource monitor
Expand Down Expand Up @@ -583,6 +604,7 @@ jobs:
- name: Build desktop artifact
shell: bash
env:
pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata
T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# T3 Code

T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, OpenCode) and serves web, desktop, and mobile clients.
T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity) and serves web, desktop, and mobile clients.

You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor.

Expand Down Expand Up @@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste

- **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature.
- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime`
- **Providers.** Codex, Claude, Cursor, Grok, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, and Antigravity each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
- **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.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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.
Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, and Google Antigravity. If they're set up on your computer, T3 Code can control them.

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

Expand All @@ -13,13 +13,14 @@ We wanted something performant, remote-ready, and truly open. If we ever go the
## Installation

> [!WARNING]
> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use:
> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, and Antigravity. 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 `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`
> - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required.

### Try it out (install-free)

Expand Down
158 changes: 158 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3137,6 +3137,164 @@ describe("PreviewManager", () => {
),
);

effectIt.effect("settles the pick when the annotation screenshot never arrives", () =>
withManager((manager) =>
Effect.gen(function* () {
let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined;
fromId.mockReturnValue({
id: 42,
isDestroyed: () => false,
getType: () => "webview",
getURL: () => "https://example.com",
getTitle: () => "Example",
isLoading: () => false,
isFocused: () => true,
getZoomFactor: () => 1,
setZoomFactor: vi.fn(),
setAudioMuted: vi.fn(),
isCurrentlyAudible: () => false,
on: vi.fn(),
once: vi.fn(),
off: vi.fn(),
// A wedged compositor leaves `capturePage` pending forever.
capturePage: vi.fn(() => new Promise(() => {})),
ipc: {
on: vi.fn((channel: string, listener: typeof onPicked) => {
if (channel === "preview:element-picked") onPicked = listener;
}),
off: vi.fn(),
removeListener: vi.fn(),
},
send: webviewSend,
navigationHistory: { canGoBack: () => false, canGoForward: () => false },
setWindowOpenHandler: vi.fn(),
debugger: {
isAttached: () => false,
attach: vi.fn(),
sendCommand: vi.fn(async () => undefined),
on: vi.fn(),
off: vi.fn(),
},
} as never);

yield* manager.createTab("tab_1");
yield* manager.registerWebview("tab_1", 42);
const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild);
yield* Effect.yieldNow;

onPicked?.(
{},
{
id: "annotation_1",
pageUrl: "https://example.com",
pageTitle: "Example",
comment: "Tighten this spacing",
elements: [],
regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }],
strokes: [],
styleChanges: [],
screenshot: null,
createdAt: "2026-06-11T00:00:00.000Z",
},
null,
"send",
);
yield* Effect.yieldNow;
expect(pick.pollUnsafe()).toBeUndefined();

yield* TestClock.adjust("6 seconds");
// The pick has to give up on the crop rather than strand the renderer,
// which would leave the composer stuck on "Capturing…".
const result = yield* Fiber.join(pick);
expect(result?.annotation.screenshot).toBeNull();
expect(result?.screenshotFailed).toBe(true);
expect(result?.submission).toBe("send");
expect(webviewSend).toHaveBeenCalledWith("preview:annotation-captured");
}),
),
);

effectIt.effect("a stale capture from a replaced pick never touches the next pick", () =>
withManager((manager) =>
Effect.gen(function* () {
let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined;
fromId.mockReturnValue({
id: 42,
isDestroyed: () => false,
getType: () => "webview",
getURL: () => "https://example.com",
getTitle: () => "Example",
isLoading: () => false,
isFocused: () => true,
getZoomFactor: () => 1,
setZoomFactor: vi.fn(),
setAudioMuted: vi.fn(),
isCurrentlyAudible: () => false,
on: vi.fn(),
once: vi.fn(),
off: vi.fn(),
capturePage: vi.fn(() => new Promise(() => {})),
ipc: {
on: vi.fn((channel: string, listener: typeof onPicked) => {
if (channel === "preview:element-picked") onPicked = listener;
}),
off: vi.fn(),
removeListener: vi.fn(),
},
send: webviewSend,
navigationHistory: { canGoBack: () => false, canGoForward: () => false },
setWindowOpenHandler: vi.fn(),
debugger: {
isAttached: () => false,
attach: vi.fn(),
sendCommand: vi.fn(async () => undefined),
on: vi.fn(),
off: vi.fn(),
},
} as never);
const annotation = {
id: "annotation_1",
pageUrl: "https://example.com",
pageTitle: "Example",
comment: "Tighten this spacing",
elements: [],
regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }],
strokes: [],
styleChanges: [],
screenshot: null,
createdAt: "2026-06-11T00:00:00.000Z",
};

yield* manager.createTab("tab_1");
yield* manager.registerWebview("tab_1", 42);
const firstPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild);
yield* Effect.yieldNow;
// The first pick submits and its crop hangs.
onPicked?.({}, annotation, null, "send");
yield* Effect.yieldNow;

// A second pick on the same tab replaces the first, which resumes null.
const secondPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild);
yield* Effect.yieldNow;
expect(yield* Fiber.join(firstPick)).toBeNull();
webviewSend.mockClear();

// The first pick's crop times out while the second pick is live. It
// must not signal the overlay, which would tear down the second pick.
yield* TestClock.adjust("6 seconds");
yield* Effect.yieldNow;
expect(webviewSend).not.toHaveBeenCalledWith("preview:annotation-captured");
expect(secondPick.pollUnsafe()).toBeUndefined();

onPicked?.({}, { ...annotation, id: "annotation_2" }, null, "attach");
yield* TestClock.adjust("6 seconds");
const result = yield* Fiber.join(secondPick);
expect(result?.annotation.id).toBe("annotation_2");
expect(result?.submission).toBe("attach");
}),
),
);

effectIt.effect("navigates the guest history when the thumb-button ipc fires", () =>
withManager((manager) =>
Effect.gen(function* () {
Expand Down
Loading
Loading