Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
157 commits
Select commit Hold shift + click to select a range
a955610
feat: attach a GitHub account per project (#1)
enisze Jul 29, 2026
98a1ce0
feat(web): attach a provider account to each project (#2)
enisze Jul 29, 2026
4cff695
feat(web): make project settings discoverable and pin accounts per pr…
enisze Jul 29, 2026
334279a
feat(web): mark diff files as viewed (#4)
enisze Jul 29, 2026
3396b19
feat(web): add selectable diff syntax theme in settings (#5)
enisze Jul 29, 2026
979e8e6
fix(server): open existing chat instead of failing on duplicate boots…
enisze Jul 29, 2026
6ab2e6c
fix(server): restore access to existing worktrees (#7)
enisze Jul 30, 2026
8c3057b
feat(web): add merge PR action to git dropdown
enisze Jul 30, 2026
b4d1d10
Merge pull request #8 from enisze/t3code/add-merge-pr-dropdown
enisze Jul 30, 2026
390c62c
fix(server): scope gh's child git processes to the selected account (#9)
enisze Jul 30, 2026
6ee1a0b
feat(web): add "New chat in this worktree" header button (#10)
enisze Jul 30, 2026
2a58284
fix(server): apply the selected GitHub account to all network git ope…
enisze Jul 30, 2026
553e205
fix(web): keep the last diff file reachable with word wrap on
enisze Jul 31, 2026
c882735
feat(web): worktree chat tabs and a header overflow menu
enisze Jul 31, 2026
c870327
fix(server): give actionable messages for gh merge failures
enisze Jul 31, 2026
9221e57
Merge pull request #12 from enisze/t3code/create-new-dmg
enisze Jul 31, 2026
34cb7da
fix(server): adaptive PR merge method and reliable per-project accoun…
enisze Jul 31, 2026
a73e1fb
Merge pull request #13 from enisze/t3code/fix-github-pull-request-merge
enisze Jul 31, 2026
141d457
fix(server): stop merge button racing GitHub mergeability computation
enisze Aug 1, 2026
ea422c2
Merge pull request #14 from enisze/t3code/fix-merge-button-transient-…
enisze Aug 1, 2026
dbff501
feat(web): collapse same-worktree chats into one sidebar row
enisze Aug 1, 2026
fcc406a
Merge pull request #15 from enisze/t3code/hide-same-workspace-chats
enisze Aug 1, 2026
63c3a6e
fix(web): keep the last diff file reachable with word wrap
enisze Aug 2, 2026
a9b847e
Merge pull request #16 from enisze/t3code/fix-last-diff-cutoff
enisze Aug 2, 2026
5d40656
feat(web): add branch changes and review actions
enisze Aug 2, 2026
955043e
Merge pull request #17 from enisze/t3code/add-review-button-and-view
enisze Aug 2, 2026
27786fc
feat(web): start reviews in a new chat
enisze Aug 2, 2026
bce34b9
Merge pull request #18 from enisze/t3code/open-review-chat
enisze Aug 2, 2026
5c9c1bf
feat(web): close chats from worktree tabs
enisze Aug 2, 2026
b39c7eb
Merge pull request #19 from enisze/t3code/close-chats-in-tabview
enisze Aug 2, 2026
ea505b3
feat(project): per-project review model selection
enisze Aug 3, 2026
5877ddc
docs(providers): fix Claude multi-account setup to use CLAUDE_CONFIG_DIR
enisze Aug 3, 2026
98cca61
Merge pull request #20 from enisze/t3code/project-chat-review-models
enisze Aug 3, 2026
57a461c
feat(web): move worktree new-chat icon next to tabs; surface git stde…
enisze Aug 3, 2026
29948fc
Merge origin/main into t3code/fix-chat-icon-github-push
enisze Aug 3, 2026
90d7842
Merge pull request #21 from enisze/t3code/fix-chat-icon-github-push
enisze Aug 3, 2026
4e73032
feat: prefer project model/account for commit text generation; move u…
enisze Aug 3, 2026
2fe568c
Merge pull request #22 from enisze/t3code/move-usage-limits-sidebar
enisze Aug 3, 2026
46d84c3
feat(providers): keep timed-out provider selectable with an exclamation
enisze Aug 3, 2026
6f42b3c
Merge pull request #23 from enisze/t3code/e88c4f1e
enisze Aug 3, 2026
be7f377
feat(git): add Resolve conflicts action that prompts the agent
enisze Aug 3, 2026
b9855a8
Merge pull request #24 from enisze/t3code/git-resolve-conflicts-button
enisze Aug 3, 2026
91440f6
feat(files): surface gitignored .env* files in the editor file tree
enisze Aug 3, 2026
7a9b083
fix(providers): show Claude usage for OAuth accounts missing subscrip…
enisze Aug 3, 2026
9af23b5
refactor(sidebar): remove settled-thread tail shelf and paging
enisze Aug 3, 2026
d8d3160
Merge pull request #25 from enisze/t3code/b4b0989d
enisze Aug 3, 2026
b452ce5
feat(providers): resolve Claude OAuth usage per instance
enisze Aug 3, 2026
8b29354
fix(sidebar): prefer user-opened sibling when resolving worktree row
enisze Aug 3, 2026
b6c6e40
feat(settings): inline provider rename and chat-scoped usage summary
enisze Aug 3, 2026
149977d
Merge pull request #26 from enisze/feature/505d1cba
enisze Aug 3, 2026
e46727b
feat: continuation handoffs, tasks panel, sidebar grouping & usage po…
enisze Aug 3, 2026
8519693
feat(sidebar,run-panel): collapsible project groups + always-on Run dock
enisze Aug 3, 2026
265478a
Merge pull request #27 from enisze/feature/a20e02f7
enisze Aug 3, 2026
abc1bd0
feat(workspace): share files, diff, terminals & preview across worktr…
enisze Aug 4, 2026
0030c93
Merge pull request #28 from enisze/feature/hide-projects-and-quick-ne…
enisze Aug 4, 2026
2cb3c5f
fix(workspace): sync shared worktree panels for a new chat before submit
enisze Aug 4, 2026
8c5d929
feat(chat,tasks): show stop button during prompts & persist run-panel…
enisze Aug 4, 2026
f4c3999
Merge pull request #29 from enisze/fix/new-chat-workspace-sync
enisze Aug 4, 2026
21416e0
feat(workspace): keep empty projects, per-chat turn diffs & resizable…
enisze Aug 5, 2026
b1fba6b
fix(git): target the branch a PR was created from as its base
enisze Aug 5, 2026
846bcba
feat(vcs): author commits as the project's selected GitHub account
enisze Aug 5, 2026
6799446
fix(terminal): root thread terminals at the worktree, not the main repo
enisze Aug 5, 2026
4c4aa4b
Merge pull request #31 from enisze/feature/keep-empty-projects-adjust…
enisze Aug 5, 2026
b10c1f4
feat(surface): default to Diff navigator with per-file diff tabs
enisze Aug 5, 2026
3e7beed
fix(tasks-dock): root dock terminals at the worktree; terminal tabs +…
enisze Aug 5, 2026
4cfa1cc
feat(surface): open file diffs as chat-column tabs; fix Diff/Files tabs
enisze Aug 5, 2026
386edaa
feat(surface): split diff by default, always-present Files tab, drop …
enisze Aug 5, 2026
d8f317b
fix(surface): default diff scope to branch changes, not the working tree
enisze Aug 5, 2026
e594480
fix(query): don't surface interrupted requests as errors; auto-recover
enisze Aug 5, 2026
9096a82
feat(surface): single file viewer, viewed review flow, default worktr…
enisze Aug 5, 2026
6c80c12
Merge pull request #32 from enisze/feature/surface-default-diff-file-…
enisze Aug 5, 2026
515b450
fix(preview): open browser preview in the worktree's shared workspace…
enisze Aug 5, 2026
19d1276
fix(diff): include uncommitted and untracked work in the branch diff …
enisze Aug 6, 2026
db153db
fix(diff): stop truncating the working-tree and branch review previews
enisze Aug 6, 2026
52a0707
fix(checkpoint): scope the inline turn diff to the turn's own changes
enisze Aug 6, 2026
5e177df
feat(preview): open the browser preview as a chat-column content tab
enisze Aug 6, 2026
df9b158
Merge pull request #33 from enisze/feature/open-browser-preview-in-chat
enisze Aug 6, 2026
eb1e277
feat(web): expand the worktree review workspace
enisze Aug 7, 2026
01a8123
fix(web): polish review workspace flows
enisze Aug 7, 2026
44886dc
Merge pull request #35 from enisze/feature/fix-github-cli-change-requ…
enisze Aug 7, 2026
b68d129
fix(web): stop new-chat overlay from hiding the file diff & restyle t…
enisze Aug 8, 2026
e32d48f
Merge pull request #36 from enisze/feature/fix-diff-view-overlap-new-…
enisze Aug 8, 2026
bd7d05b
fix(diff): add combined working-tree scope and correct dropdown scopes
enisze Aug 8, 2026
638b2d9
fix(diff): always resolve the review base against origin
enisze Aug 8, 2026
7975c47
Merge pull request #37 from enisze/feature/fix-diff-dropdown-change-s…
enisze Aug 8, 2026
7592af7
fix(diff): stabilize scrolling and correct working tree scope
enisze Aug 8, 2026
d5a9801
Merge pull request #38 from enisze/feature/fix-diff-view-scroll-jump
enisze Aug 8, 2026
3497c83
fix(diff): prevent scroll jumps while wrapping lines
enisze Aug 8, 2026
04da93d
Merge pull request #39 from enisze/feature/fix-scroll-jump-in-view
enisze Aug 8, 2026
2f38ee9
fix(diff): keep wrapped virtual scrolling responsive
enisze Aug 8, 2026
e90727b
Merge pull request #40 from enisze/feature/fix-diff-virtual-scroll-stall
enisze Aug 8, 2026
c5a638f
fix(diff): refresh working tree after completed turns
enisze Aug 8, 2026
0d4c691
Merge pull request #41 from enisze/feature/fix-working-tree-current-c…
enisze Aug 8, 2026
1b1e0d1
fix(diff): clear viewed state after file changes
enisze Aug 8, 2026
a0dd6b7
Merge pull request #42 from enisze/feature/fix-viewed-state-after-fil…
enisze Aug 8, 2026
1b48534
fix(chat): reveal new chats from file diff views
enisze Aug 8, 2026
920c4fe
Merge pull request #43 from enisze/feature/fix-new-chat-from-file-diff
enisze Aug 8, 2026
57081de
feat(chat): create worktrees without starting agents
enisze Aug 9, 2026
ed49210
Merge pull request #44 from enisze/feature/create-worktree-empty-chat
enisze Aug 9, 2026
6efc985
fix(web): keep worktree context in sync
enisze Aug 9, 2026
a1297f7
Merge pull request #45 from enisze/feature/review-button-working-tree…
enisze Aug 9, 2026
8cfe7c5
feat(web): expand unchanged diff sections
enisze Aug 9, 2026
810e63e
Merge pull request #46 from enisze/feature/expand-unmodified-diff-lines
enisze Aug 9, 2026
dea4464
feat(web): improve active chat file workflows
enisze Aug 9, 2026
4bc20bd
Merge pull request #47 from enisze/feature/quick-open-chat-review-fixes
enisze Aug 9, 2026
cf6812f
fix(web): open chat file mentions in viewer
enisze Aug 9, 2026
756750f
Merge pull request #48 from enisze/feature/clickable-file-mentions-in…
enisze Aug 10, 2026
1860a2a
fix(web): keep archived threads discoverable
enisze Aug 10, 2026
5d6f3c2
Merge pull request #49 from enisze/fix/archive-thread-lifecycle
enisze Aug 10, 2026
1beb974
fix(web): keep project order stable when a chat is archived
enisze Aug 10, 2026
e6d173b
fix(github): detect PR merge conflicts and unresolved review comments
enisze Aug 10, 2026
76ec8dc
fix(vcs): explain remote git push rejections and the account used
enisze Aug 10, 2026
60849a2
fix(web): open the real PR worktree when starting a chat from a PR
enisze Aug 10, 2026
1a53d87
Merge pull request #50 from enisze/feature/preserve-project-order-fix…
enisze Aug 10, 2026
5e996ed
fix(chat): don't auto-create worktree for empty worktree drafts
enisze Aug 10, 2026
1b32bbc
Merge pull request #51 from enisze/feature/defer-worktree-creation-un…
enisze Aug 10, 2026
4af92b8
fix(web): keep diff review state and counters current
enisze Aug 10, 2026
3ab6cee
Merge pull request #52 from enisze/fix/diff-viewed-counters
enisze Aug 10, 2026
82f1f2b
fix(mobile): sort sidebar threads by recent activity
enisze Aug 10, 2026
517583f
Merge pull request #53 from enisze/feature/sort-sidebar-threads-by-la…
enisze Aug 10, 2026
763367a
fix(web): promote worktrees by newest chat
enisze Aug 10, 2026
8d5b862
Merge pull request #54 from enisze/feature/fix-thread-sort-by-newest-…
enisze Aug 10, 2026
412d638
feat(git): use project default base branch
enisze Aug 11, 2026
c76ed90
Merge pull request #55 from enisze/feature/project-default-base-branch
enisze Aug 11, 2026
a6677a7
feat(web): enlarge diff navigator file rows
enisze Aug 11, 2026
74ba4bb
feat(projects): per-project settings page and worktree file copying
enisze Aug 11, 2026
e86869c
feat(sidebar): put project settings gear on sidebar v2 thread rows
enisze Aug 11, 2026
e5ea2e9
fix(web): stop file names overlapping the diff stat
enisze Aug 16, 2026
1ab446d
Merge pull request #56 from enisze/feature/enlarge-file-diff-selectio…
enisze Aug 16, 2026
5a9ddde
fix(web): stabilize worktree diff file open and new-chat selection
enisze Aug 17, 2026
beef911
Merge pull request #57 from enisze/fix/worktree-diff-open-flicker-and…
enisze Aug 17, 2026
cb95d4d
fix(web): open existing branches in dedicated worktrees
enisze Aug 17, 2026
bcd3a7b
fix(vcs): retry transient provider 5xx failures
enisze Aug 17, 2026
f576701
Merge pull request #58 from enisze/feature/fix-open-worktree-from-exi…
enisze Aug 17, 2026
3955c39
fix(web): reuse the primary checkout when opening an already-checked-…
enisze Aug 17, 2026
ad28eeb
Merge pull request #59 from enisze/feature/fix-local-checkout-failure
enisze Aug 17, 2026
bec2e6f
fix(web): preserve file tree expansion state across reloads
enisze Aug 18, 2026
867030e
Merge pull request #61 from enisze/feature/fix-filetree-auto-expand-o…
enisze Aug 18, 2026
47865be
feat(vcs): auto-rebase and retry a push when the remote moved ahead
enisze Aug 19, 2026
f066ab1
Merge pull request #62 from enisze/feature/auto-rebase-on-push
enisze Aug 19, 2026
02be2fe
feat(vcs): open the existing worktree that already holds a branch
enisze Aug 19, 2026
fe9fae9
Merge pull request #63 from enisze/feature/open-existing-worktree-wit…
enisze Aug 19, 2026
743d2d0
feat(diff): show worktree changes for a draft before sending
enisze Aug 20, 2026
aeb0691
fix(review): review the worktree's live branch, not the stored ref
enisze Aug 20, 2026
febc865
Merge pull request #64 from enisze/feature/show-worktree-changes-with…
enisze Aug 20, 2026
fc14f13
fix(sidebar): keep worktree row ordered after closing a chat
enisze Aug 20, 2026
8aa550c
Merge pull request #65 from enisze/feature/keep-sidebar-order-on-chat…
enisze Aug 20, 2026
24fb9c7
fix(chat): keep the streaming response next to the composer
enisze Aug 25, 2026
afbbf2f
fix(git): resolve remote-only branches when opening one as a worktree
enisze Aug 25, 2026
a0c94ec
Merge pull request #66 from enisze/feature/fix-chat-scroll-empty-space
enisze Aug 25, 2026
0003594
fix(desktop): keep the advertised LAN host in sync with the current n…
enisze Aug 28, 2026
ddea1e4
Merge pull request #67 from enisze/feature/fix-mobile-app-store-error
enisze Aug 28, 2026
2dff0e5
fix(mobile): default bare IP pairing to HTTP (#4990)
Lucenx9 Jul 30, 2026
f64a898
fix(mobile): stop a directly-saved backend from hiding its T3 Connect…
Rishet11 Aug 22, 2026
2a3267f
fix(desktop): keep tailscale spawn defects from breaking advertised e…
Rishet11 Aug 22, 2026
5140aeb
fix(mobile): recover the QR pairing scanner when camera access is den…
hey-jj Aug 15, 2026
0763091
test(desktop): add the missing client-settings field to the fixture
enisze Aug 28, 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
2 changes: 1 addition & 1 deletion .agents/skills/test-t3-mobile/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Use these client origins:
- Android Emulator: `http://10.0.2.2:<server-port>`
- Physical device: bind the backend to `0.0.0.0` and use the host's reachable LAN origin

Always enter the complete `http://` origin; the mobile host field otherwise assumes HTTPS. When testing web and mobile together, run `vp run dev --home-dir <base-dir> --host 127.0.0.1` instead and do not launch a second backend over the same base directory.
Enter the complete `http://` origin to make the test transport explicit. Bare IP addresses default to HTTP, while bare hostnames default to HTTPS. When testing web and mobile together, run `vp run dev --home-dir <base-dir> --host 127.0.0.1` instead and do not launch a second backend over the same base directory.

## Start or reuse Metro safely

Expand Down
78 changes: 78 additions & 0 deletions apps/desktop/src/backend/DesktopServerExposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,84 @@ describe("DesktopServerExposure", () => {
),
);

it.effect("re-resolves the advertised LAN host after the machine changes networks", () => {
// `read` hands back this same object every call, so reassigning `en0` models the
// machine roaming to another network while the app stays open.
const roamingNetworkInterfaces: Record<
string,
DesktopNetworkInterfaces.DesktopNetworkInterfaceInfo[]
> = {
en0: [{ address: "192.168.2.37", family: "IPv4", internal: false }],
};

return withHarness(
roamingNetworkInterfaces,
Effect.gen(function* () {
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
yield* serverExposure.configureFromSettings({ port: 3773 });
const change = yield* serverExposure.setMode("network-accessible");
assert.equal(change.state.endpointUrl, "http://192.168.2.37:3773");

roamingNetworkInterfaces.en0 = [
{ address: "192.168.1.21", family: "IPv4", internal: false },
];

const state = yield* serverExposure.getState;
assert.equal(state.mode, "network-accessible");
assert.equal(state.advertisedHost, "192.168.1.21");
assert.equal(state.endpointUrl, "http://192.168.1.21:3773");

const endpoints = yield* serverExposure.getAdvertisedEndpoints;
assert.deepEqual(
endpoints.map((endpoint) => endpoint.httpBaseUrl),
["http://127.0.0.1:3773/", "http://192.168.1.21:3773/"],
);

// The backend is already bound to every interface, so the new address needs
// no relaunch — binding and loopback URL must stay put.
const backendConfig = yield* serverExposure.backendConfig;
assert.equal(backendConfig.bindHost, "0.0.0.0");
assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:3773/");
}),
);
});

it.effect("keeps a local-only fallback unadvertised when a LAN address appears later", () => {
const lateNetworkInterfaces: Record<
string,
DesktopNetworkInterfaces.DesktopNetworkInterfaceInfo[]
> = {};

return withHarness(
lateNetworkInterfaces,
Effect.gen(function* () {
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
const settings = yield* DesktopAppSettings.DesktopAppSettings;

yield* settings.setServerExposureMode("network-accessible");
const state = yield* serverExposure.configureFromSettings({ port: 3773 });
assert.equal(state.mode, "local-only");

lateNetworkInterfaces.en0 = [{ address: "192.168.1.21", family: "IPv4", internal: false }];

// The fallback bound loopback only, so advertising the address that showed up
// afterwards would point clients at a socket this process never listens on.
const refreshed = yield* serverExposure.getState;
assert.equal(refreshed.mode, "local-only");
assert.equal(refreshed.advertisedHost, null);
assert.equal(refreshed.endpointUrl, null);

const endpoints = yield* serverExposure.getAdvertisedEndpoints;
assert.deepEqual(
endpoints.map((endpoint) => endpoint.httpBaseUrl),
["http://127.0.0.1:3773/"],
);
}),
{},
dieOnSpawnLayer(),
);
});

it.effect("does not spawn the tailscale CLI while server exposure is local-only", () =>
withHarness(
lanNetworkInterfaces,
Expand Down
59 changes: 57 additions & 2 deletions apps/desktop/src/backend/DesktopServerExposure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,44 @@ function resolveRuntimeState(input: {
};
}

/**
* Recompute the advertised LAN host from the current interfaces.
*
* The LAN address is resolved once while starting up, but laptops move between
* networks (and DHCP leases rotate) while the app stays open for days. The
* backend is bound to `0.0.0.0`, so the new address already serves traffic —
* only the advertised host goes stale, and a stale host sends pairing links and
* QR codes to an IP nobody answers on. Rebinding is never needed here, so this
* refresh deliberately leaves `bindHost`/`localHttpUrl`/`port` untouched.
*/
const withRefreshedAdvertisedHost = (input: {
readonly state: RuntimeState;
readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces;
readonly advertisedHostOverride: Option.Option<string>;
}): RuntimeState => {
// Only network-accessible runs bind the LAN interface; a local-only run (including
// one that fell back for want of an address) has nothing reachable to advertise.
if (input.state.mode !== "network-accessible") {
return input.state;
}

const advertisedHost = resolveLanAdvertisedHost(
input.networkInterfaces,
Option.getOrUndefined(input.advertisedHostOverride),
);
if (advertisedHost === Option.getOrNull(input.state.advertisedHost)) {
return input.state;
}

return {
...input.state,
endpointUrl: Option.fromNullishOr(
advertisedHost === null ? null : `http://${advertisedHost}:${input.state.port}`,
),
advertisedHost: Option.fromNullishOr(advertisedHost),
};
};

const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): boolean =>
previous.port !== next.port ||
previous.bindHost !== next.bindHost ||
Expand Down Expand Up @@ -426,7 +464,18 @@ export const make = Effect.gen(function* () {

const readNetworkInterfaces = networkInterfaces.read;

const getState = Ref.get(stateRef).pipe(Effect.map(toContractState));
const refreshAdvertisedHost = Effect.gen(function* () {
const currentNetworkInterfaces = yield* readNetworkInterfaces;
return yield* Ref.updateAndGet(stateRef, (state) =>
withRefreshedAdvertisedHost({
state,
networkInterfaces: currentNetworkInterfaces,
advertisedHostOverride: config.desktopLanHostOverride,
}),
);
});

const getState = refreshAdvertisedHost.pipe(Effect.map(toContractState));
const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig));

const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")(
Expand Down Expand Up @@ -522,8 +571,14 @@ export const make = Effect.gen(function* () {
);

const getAdvertisedEndpoints = Effect.gen(function* () {
const state = yield* Ref.get(stateRef);
const currentNetworkInterfaces = yield* readNetworkInterfaces;
const state = yield* Ref.updateAndGet(stateRef, (current) =>
withRefreshedAdvertisedHost({
state: current,
networkInterfaces: currentNetworkInterfaces,
advertisedHostOverride: config.desktopLanHostOverride,
}),
);
const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({
port: state.port,
exposure: toResolvedExposure(state),
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,
diffTheme: "pierre-dark",
environmentIdentificationMode: "artwork",
favorites: [],
glassOpacity: 80,
Expand All @@ -32,6 +33,7 @@ const clientSettings: ClientSettings = {
sidebarThreadPreviewCount: 6,
sidebarV2Enabled: false,
sidebarV2ConfiguredByUser: false,
sidebarV2GroupByProject: false,
timestampFormat: "24-hour",
wordWrap: true,
};
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/archive/archivedThreadList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ function makeProject(
workspaceRoot: `/workspaces/${input.id}`,
repositoryIdentity: null,
defaultModelSelection: null,
gitHubAccount: null,
worktreeBranchPrefix: null,
defaultWorktreeBranch: null,
scripts: [],
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
Expand Down
16 changes: 14 additions & 2 deletions apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useEffect, useState } from "react";
import { Alert, Platform, ScrollView, View } from "react-native";
import { Alert, Linking, Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";

Expand Down Expand Up @@ -76,9 +76,21 @@ export function ConnectionsNewRouteScreen({
return;
}

if (permission.canAskAgain) {
Alert.alert(
"Camera access needed",
"Allow camera access to scan an environment pairing QR code.",
);
return;
}

Alert.alert(
"Camera access needed",
"Allow camera access to scan an environment pairing QR code.",
"Camera access was denied for this app. Open Settings to enable it.",
[
{ text: "Cancel", style: "cancel" },
{ text: "Open Settings", onPress: () => void Linking.openSettings() },
],
);
}, [cameraPermission?.granted, requestCameraPermission]);

Expand Down
31 changes: 30 additions & 1 deletion apps/mobile/src/features/connection/environmentSections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { EnvironmentId } from "@t3tools/contracts";
import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
import { describe, expect, it } from "vite-plus/test";
import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
import { splitEnvironmentSections } from "./environmentSections";
import { relayManagedEnvironmentIds, splitEnvironmentSections } from "./environmentSections";

function connectedEnvironment(
input: Omit<Partial<ConnectedEnvironmentSummary>, "environmentId"> & {
Expand Down Expand Up @@ -34,6 +34,17 @@ function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord {
};
}

describe("relayManagedEnvironmentIds", () => {
it("leaves out a backend that was saved directly", () => {
const ids = relayManagedEnvironmentIds([
connectedEnvironment({ environmentId: "environment-local", isRelayManaged: false }),
connectedEnvironment({ environmentId: "environment-cloud", isRelayManaged: true }),
]);

expect([...ids]).toEqual([EnvironmentId.make("environment-cloud")]);
});
});

describe("mobile environment settings sections", () => {
it("keeps saved relay-managed connections under T3 Connect", () => {
const local = connectedEnvironment({
Expand Down Expand Up @@ -111,6 +122,24 @@ describe("mobile environment settings sections", () => {
expect(sections.availableCloudEnvironments).toEqual([]);
});

it("still offers a cloud environment saved directly as a local backend", () => {
const local = connectedEnvironment({
environmentId: "environment-cloud",
isRelayManaged: false,
});

const sections = splitEnvironmentSections({
connectedEnvironments: [local],
cloudEnvironments: [cloudEnvironment("environment-cloud")],
});

expect(sections.localEnvironments).toEqual([local]);
expect(sections.connectedCloudEnvironments).toEqual([]);
expect(
sections.availableCloudEnvironments.map((environment) => environment.environmentId),
).toEqual([EnvironmentId.make("environment-cloud")]);
});

it("keeps failed relay environments in the local connection row", () => {
const cloud = connectedEnvironment({
environmentId: "environment-cloud",
Expand Down
22 changes: 19 additions & 3 deletions apps/mobile/src/features/connection/environmentSections.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { EnvironmentId } from "@t3tools/contracts";
import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";

Expand All @@ -12,10 +13,25 @@ export interface EnvironmentSections {
readonly availableCloudEnvironments: ReadonlyArray<RelayClientEnvironmentRecord>;
}

export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections {
const savedEnvironmentIds = new Set(
input.connectedEnvironments.map((environment) => environment.environmentId),
/**
* Ids of the environments that already occupy a T3 Connect slot. A backend saved directly is
* not one of them, so it must not suppress the cloud environment that happens to share its id.
*/
export function relayManagedEnvironmentIds(
environments: ReadonlyArray<{
readonly environmentId: EnvironmentId;
readonly isRelayManaged: boolean;
}>,
): ReadonlySet<EnvironmentId> {
return new Set(
environments
.filter((environment) => environment.isRelayManaged)
.map((environment) => environment.environmentId),
);
}

export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections {
const savedEnvironmentIds = relayManagedEnvironmentIds(input.connectedEnvironments);

return {
localEnvironments: input.connectedEnvironments.filter(
Expand Down
21 changes: 21 additions & 0 deletions apps/mobile/src/features/connection/pairing.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
import { describe, expect, it } from "vite-plus/test";

import {
buildPairingUrl,
extractPairingUrlFromQrPayload,
PairingQrPayloadEmptyError,
parsePairingUrl,
} from "./pairing";

describe("buildPairingUrl", () => {
it("uses HTTP for a schemeless IP address", () => {
expect(buildPairingUrl("192.168.1.100:3773", "pairing-token")).toBe(
"http://192.168.1.100:3773/#token=pairing-token",
);
});

it("keeps HTTPS as the default for a schemeless hostname", () => {
expect(buildPairingUrl("remote.example.com", "pairing-token")).toBe(
"https://remote.example.com/#token=pairing-token",
);
});

it("preserves an explicit scheme for an IP address", () => {
expect(buildPairingUrl("https://192.168.1.100:3773", "pairing-token")).toBe(
"https://192.168.1.100:3773/#token=pairing-token",
);
});
});

describe("extractPairingUrlFromQrPayload", () => {
it("trims raw pairing urls from qr payloads", () => {
expect(
Expand Down
17 changes: 16 additions & 1 deletion apps/mobile/src/features/connection/pairing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@ import * as Schema from "effect/Schema";

const MOBILE_PAIRING_URL_PARAM = "pairingUrl";

function isIpLiteral(host: string): boolean {
try {
const hostname = new URL(`http://${host}`).hostname.replace(/^\[|\]$/g, "");
if (hostname.includes(":")) return true;

const octets = hostname.split(".");
return (
octets.length === 4 &&
octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255)
);
} catch {
return false;
}
}

export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass<PairingQrPayloadEmptyError>()(
"PairingQrPayloadEmptyError",
{},
Expand All @@ -19,7 +34,7 @@ export function buildPairingUrl(host: string, code: string): string {
if (!c) return h;

try {
const url = new URL(h.includes("://") ? h : `https://${h}`);
const url = new URL(h.includes("://") ? h : `${isIpLiteral(h) ? "http" : "https"}://${h}`);
url.hash = new URLSearchParams([["token", c]]).toString();
return url.toString();
} catch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useEnvironments } from "../../state/environments";
import { relayEnvironmentDiscovery } from "../../state/relay";
import { useAtomCommand } from "../../state/use-atom-command";
import { projectWorkspaceEnvironment, type WorkspaceEnvironment } from "../../state/workspaceModel";
import { relayManagedEnvironmentIds } from "./environmentSections";

export interface RelayEnvironmentView {
readonly environment: RelayClientEnvironmentRecord;
Expand Down Expand Up @@ -49,7 +50,7 @@ export function useConnectionController() {
[environments],
);
const registeredIds = useMemo(
() => new Set(connectedEnvironments.map((environment) => environment.environmentId)),
() => relayManagedEnvironmentIds(connectedEnvironments),
[connectedEnvironments],
);
const relayEnvironments = useMemo<ReadonlyArray<RelayEnvironmentView>>(
Expand Down
Loading
Loading