Skip to content

Commit 2f1b72d

Browse files
Merge remote-tracking branch 'origin/main' into fix/issue-26390
2 parents aff29bd + 2a73827 commit 2f1b72d

19 files changed

Lines changed: 561 additions & 103 deletions

‎apps/web/src/cloud/managedRelayState.ts‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import {
1212
createAtomCommandScheduler,
1313
createRuntimeCommand,
1414
} from "@t3tools/client-runtime/state/runtime";
15-
import type {
16-
RelayClientDeviceRecord,
17-
RelayClientEnvironmentRecord,
18-
RelayT3ChatLinkResponse,
15+
import {
16+
DEFAULT_MANAGED_TUNNEL_LIMIT,
17+
T3CHAT_PLAN_TUNNEL_LIMITS,
18+
type RelayClientDeviceRecord,
19+
type RelayClientEnvironmentRecord,
20+
type RelayT3ChatLinkResponse,
1921
} from "@t3tools/contracts/relay";
2022
import type { EnvironmentId } from "@t3tools/contracts";
2123
import * as Context from "effect/Context";
@@ -144,3 +146,51 @@ export function useManagedRelayDevices() {
144146
refresh,
145147
};
146148
}
149+
150+
/** How this account can raise its tunnel cap: link T3 Chat, or upgrade the linked plan. */
151+
export type ManagedTunnelUpgrade = "link" | "pro" | "premier" | null;
152+
153+
// Mirrors the relay's cap without manual overrides, which the relay does not expose.
154+
function managedTunnelCap(t3Chat: RelayT3ChatLinkResponse): {
155+
readonly limit: number;
156+
readonly upgrade: ManagedTunnelUpgrade;
157+
} {
158+
if (!t3Chat.available) return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: null };
159+
switch (t3Chat.link?.plan) {
160+
case "premier":
161+
return { limit: T3CHAT_PLAN_TUNNEL_LIMITS.premier, upgrade: null };
162+
case "pro":
163+
return { limit: T3CHAT_PLAN_TUNNEL_LIMITS.pro, upgrade: "premier" };
164+
case "free":
165+
return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: "pro" };
166+
case undefined:
167+
return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: "link" };
168+
}
169+
}
170+
171+
/** Refetches the account's environments and T3 Chat plan after a tunnel changes. */
172+
export function refreshManagedTunnelUsage() {
173+
const accountId = appAtomRegistry.get(managedRelaySessionAtom)?.accountId;
174+
if (!accountId) return;
175+
managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId);
176+
managedRelayQueryManager.refreshT3ChatLink(appAtomRegistry, accountId);
177+
}
178+
179+
/** Managed tunnels on this account and its cap. Null fields are still loading or failed. */
180+
export function useManagedTunnelUsage() {
181+
const environments = useManagedRelayEnvironments();
182+
const t3Chat = useManagedRelayT3ChatLink();
183+
const used =
184+
environments.accountId === null
185+
? null
186+
: (environments.data?.filter(
187+
(environment) => environment.endpoint.providerKind === "cloudflare_tunnel",
188+
).length ?? null);
189+
const cap = t3Chat.accountId !== null && t3Chat.data ? managedTunnelCap(t3Chat.data) : null;
190+
return {
191+
used,
192+
limit: cap?.limit ?? null,
193+
upgrade: cap?.upgrade ?? null,
194+
refresh: refreshManagedTunnelUsage,
195+
};
196+
}

‎apps/web/src/cloud/useCloudLinkController.browser.test.tsx‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import {
44
EnvironmentId,
55
type EnvironmentCloudLinkStateResult,
66
} from "@t3tools/contracts";
7-
import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay";
7+
import { ManagedRelay } from "@t3tools/client-runtime/relay";
8+
import {
9+
RelayEnvironmentLinkLimitExceededError,
10+
type RelayManagedEndpointProviderKind,
11+
} from "@t3tools/contracts/relay";
12+
import * as Cause from "effect/Cause";
813
import { AsyncResult } from "effect/reactivity";
914
import { useLayoutEffect } from "react";
1015
import { describe, expect, it, vi } from "vite-plus/test";
@@ -88,6 +93,7 @@ vi.mock(import("./publicConfig"), async (original) => ({
8893
resolveRelayClerkTokenOptions: () => ({}) as never,
8994
}));
9095

96+
import { CloudEnvironmentLinkError } from "./linkEnvironment";
9197
import { useCloudLinkController, type CloudLinkDesiredState } from "./useCloudLinkController";
9298

9399
const target = {
@@ -125,9 +131,8 @@ async function mountController() {
125131
/>,
126132
);
127133
return Object.assign(screen, {
128-
get controller() {
129-
return controller;
130-
},
134+
// Object.assign would copy a getter's value once, so read the latest render through a function.
135+
controller: () => controller,
131136
async reconcile(desired: CloudLinkDesiredState) {
132137
let succeeded = false;
133138
await act(async () => {
@@ -225,4 +230,33 @@ describe("useCloudLinkController", () => {
225230
expect(testState.link).not.toHaveBeenCalled();
226231
expect(testState.preferences).not.toHaveBeenCalled();
227232
});
233+
234+
it("reports a relay tunnel cap refusal as tunnelLimit instead of an error", async () => {
235+
using _setup = setup();
236+
testState.snapshot = { ...linkedState, linked: false, managedTunnelActive: false };
237+
testState.link.mockResolvedValueOnce(
238+
AsyncResult.failure(
239+
Cause.fail(
240+
new CloudEnvironmentLinkError({
241+
message: "environment-links failed",
242+
cause: new ManagedRelay.ManagedRelayRequestFailedError({
243+
action: "link relay environment",
244+
cause: null,
245+
relayError: new RelayEnvironmentLinkLimitExceededError({
246+
code: "environment_link_limit_exceeded",
247+
maxTunnels: 3,
248+
traceId: "trace-1",
249+
}),
250+
}),
251+
}),
252+
),
253+
),
254+
);
255+
await using screen = await mountController();
256+
257+
expect(await screen.reconcile({ managedTunnel: true, publish: false })).toBe(false);
258+
expect(screen.controller()?.tunnelLimit).toBe(3);
259+
expect(screen.controller()?.operationError).toBeNull();
260+
expect(testState.toast).not.toHaveBeenCalled();
261+
});
228262
});

‎apps/web/src/cloud/useCloudLinkController.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,22 @@ import {
77
AuthRelayWriteScope,
88
type EnvironmentCloudLinkStateResult,
99
} from "@t3tools/contracts";
10+
import { ManagedRelay } from "@t3tools/client-runtime/relay";
1011
import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay";
1112
import {
1213
isAtomCommandInterrupted,
1314
settlePromise,
1415
squashAtomCommandFailure,
1516
} from "@t3tools/client-runtime/state/runtime";
17+
import * as Schema from "effect/Schema";
1618
import { useState } from "react";
1719

1820
import { toastManager } from "../components/ui/toast";
1921
import { relayEnvironmentDiscovery } from "../state/relay";
2022
import { readEnvironmentScope } from "../state/session";
2123
import { useAtomCommand } from "../state/use-atom-command";
24+
import { CloudEnvironmentLinkError } from "./linkEnvironment";
25+
import { refreshManagedTunnelUsage } from "./managedRelayState";
2226
import {
2327
linkPrimaryEnvironment as linkPrimaryEnvironmentAtom,
2428
unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom,
@@ -46,6 +50,18 @@ function isManagedTunnelActive(
4650
);
4751
}
4852

53+
const isManagedRelayRequestFailedError = Schema.is(ManagedRelay.ManagedRelayRequestFailedError);
54+
55+
/** The account's tunnel cap when the relay refused a new tunnel for being at it. */
56+
function tunnelLimitFromLinkFailure(cause: unknown): number | null {
57+
if (!(cause instanceof CloudEnvironmentLinkError)) return null;
58+
const relayCause = cause.cause;
59+
if (!isManagedRelayRequestFailedError(relayCause)) return null;
60+
return relayCause.relayError?._tag === "RelayEnvironmentLinkLimitExceededError"
61+
? relayCause.relayError.maxTunnels
62+
: null;
63+
}
64+
4965
export function useCloudLinkController() {
5066
const { getToken, isSignedIn } = useAuth();
5167
const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, {
@@ -68,6 +84,7 @@ export function useCloudLinkController() {
6884
? relayEnvironments.get(primaryEnvironmentId)?.environment.endpoint.providerKind
6985
: undefined;
7086
const [operationError, setOperationError] = useState<string | null>(null);
87+
const [tunnelLimit, setTunnelLimit] = useState<number | null>(null);
7188

7289
const reportUpdateFailure = (cause: unknown) => {
7390
const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access.";
@@ -100,6 +117,7 @@ export function useCloudLinkController() {
100117

101118
const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise<boolean> => {
102119
setOperationError(null);
120+
setTunnelLimit(null);
103121
const target = primaryCloudLinkState.target;
104122
if (!target) {
105123
reportUpdateFailure(new Error("Local environment is not ready yet."));
@@ -138,6 +156,7 @@ export function useCloudLinkController() {
138156
target,
139157
clerkToken: tokenResult._tag === "Success" ? (tokenResult.value ?? null) : null,
140158
});
159+
refreshManagedTunnelUsage();
141160
if (unlinkResult._tag === "Failure") {
142161
if (!isAtomCommandInterrupted(unlinkResult)) {
143162
reportUpdateFailure(squashAtomCommandFailure(unlinkResult));
@@ -168,9 +187,15 @@ export function useCloudLinkController() {
168187
clerkToken,
169188
mode: desired.managedTunnel ? "managed" : "publish_only",
170189
});
190+
// Any link attempt can change the account's tunnel count.
191+
refreshManagedTunnelUsage();
171192
if (linkResult._tag === "Failure") {
172193
if (!isAtomCommandInterrupted(linkResult)) {
173-
reportUpdateFailure(squashAtomCommandFailure(linkResult));
194+
const cause = squashAtomCommandFailure(linkResult);
195+
// The caller shows an upgrade prompt instead of an error.
196+
const limit = tunnelLimitFromLinkFailure(cause);
197+
if (limit === null) reportUpdateFailure(cause);
198+
else setTunnelLimit(limit);
174199
}
175200
primaryCloudLinkState.refresh();
176201
return false;
@@ -215,6 +240,7 @@ export function useCloudLinkController() {
215240
publishAgentActivity,
216241
holdWebhooksWhileOffline,
217242
operationError,
243+
tunnelLimit,
218244
reconcileCloudState,
219245
};
220246
}

‎apps/web/src/components/Sidebar.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,8 @@ interface SidebarThreadPartitionInput {
337337
readonly serverConfigs: ReadonlyMap<EnvironmentId, EnvironmentServerConfig>;
338338
readonly optimisticDrop: SidebarOptimisticDrop | null;
339339
readonly workingShelfEnabled: boolean;
340+
/** Boring mode: folders list active threads by the user's latest message. */
341+
readonly sortActiveBySend: boolean;
340342
// Snooze wakes, the queued-turn grace and the Working shelf's exit grace depend
341343
// on the clock, so the minute tick, the precise snooze-wake tick and the inbox
342344
// tracker's expiry version are inputs: a new key repartitions.
@@ -458,6 +460,7 @@ function useSidebarThreads(input: {
458460
serverConfigs,
459461
optimisticDrop,
460462
workingShelfEnabled,
463+
sortActiveBySend: folderGroups !== null,
461464
clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`,
462465
});
463466

@@ -592,6 +595,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) {
592595
serverConfigs,
593596
optimisticDrop,
594597
workingShelfEnabled,
598+
sortActiveBySend,
595599
} = input;
596600
const preciseNow = new Date().toISOString();
597601
const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent);
@@ -650,9 +654,11 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) {
650654
}
651655
// A loaded config gates dragging only; the sort must not depend on it, so web and mobile agree.
652656
const sortedPinned = sortPinnedThreadsForSidebar(pinned);
653-
const sortedActive = workingShelfEnabled
654-
? sortInboxThreadsByReturn(active, inboxObservation.returnedAt)
655-
: sortThreadsForSidebar(active);
657+
const sortedActive = sortActiveBySend
658+
? sortWorkingThreadsBySend(active)
659+
: workingShelfEnabled
660+
? sortInboxThreadsByReturn(active, inboxObservation.returnedAt)
661+
: sortThreadsForSidebar(active);
656662
return {
657663
pinnedThreads:
658664
optimisticDrop?.section !== "pinned" || optimisticDrop.order === null

0 commit comments

Comments
 (0)