diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d41d6500d87..39417ec9be7 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -55,6 +55,7 @@ export interface ThreadDetailScreenProps { readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; + readonly connectionReconnectAttempt: number; readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; @@ -165,20 +166,104 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray 1 ? ` (attempt ${attempt})` : ""; + switch (state) { + case "ready": + return null; + case "connecting": + return { title: `Connecting to remote server…${attemptSuffix}`, detail: null }; + case "reconnecting": + return { + title: `Reconnecting to remote server…${attemptSuffix}`, + detail: "Activity may be paused until the connection recovers.", + }; + case "disconnected": + case "idle": { + const detailParts = [ + error ?? "Pull down to refresh or wait — the app will keep retrying in the background.", + ]; + if (attempt > 0) { + detailParts.push(`Reconnect attempts: ${attempt}.`); + } + return { + title: "Disconnected from remote server", + detail: detailParts.join(" "), + }; + } + } +} + +const ConnectionStatusBanner = memo(function ConnectionStatusBanner(props: { + readonly state: ConnectionStateLabel; + readonly error: string | null; + readonly reconnectAttempt: number; +}) { + const banner = describeConnectionBanner(props.state, props.error, props.reconnectAttempt); + if (!banner) { + return null; + } + const isError = props.state === "disconnected" || props.state === "idle"; + const containerClass = isError + ? "self-stretch rounded-2xl border border-red-300/70 bg-red-50/95 px-3 py-2 dark:border-red-500/40 dark:bg-red-500/10" + : "self-stretch rounded-2xl border border-amber-300/70 bg-amber-50/95 px-3 py-2 dark:border-amber-500/40 dark:bg-amber-500/10"; + const titleClass = isError + ? "font-t3-medium text-[13px] text-red-700 dark:text-red-200" + : "font-t3-medium text-[13px] text-amber-700 dark:text-amber-200"; + const detailClass = isError + ? "text-[12px] text-red-700/80 dark:text-red-200/80" + : "text-[12px] text-amber-700/80 dark:text-amber-200/80"; + return ( + + + {banner.title} + {banner.detail ? {banner.detail} : null} + + + ); +}); + const WorkingDurationPill = memo(function WorkingDurationPill(props: { readonly startedAt: string; + readonly connectionState: ConnectionStateLabel; + readonly reconnectAttempt: number; }) { + const isConnected = props.connectionState === "ready"; const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { + if (!isConnected) { + return; + } + setNowMs(Date.now()); const intervalId = setInterval(() => { setNowMs(Date.now()); }, 1_000); return () => clearInterval(intervalId); - }, [props.startedAt]); + }, [isConnected, props.startedAt]); const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; + if (!isConnected) { + const attemptSuffix = + props.reconnectAttempt > 0 ? ` · retry ${props.reconnectAttempt}` : ""; + return ( + + + + Last seen working for {durationLabel} · paused (disconnected){attemptSuffix} + + + + ); + } + return ( @@ -287,8 +372,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread offset={{ closed: 0, opened: 0 }} > + {props.activeWorkStartedAt ? ( - + ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index cff4c33a333..8766d882514 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -86,6 +86,7 @@ export function ThreadRouteScreen() { const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? connectionState; const routeConnectionError = pendingConnectionError ?? routeEnvironmentRuntime?.connectionError ?? aggregateConnectionError; + const routeReconnectAttempt = routeEnvironmentRuntime?.reconnectAttempt ?? 0; /* ─── Native header theming ──────────────────────────────────────── */ const isDark = useColorScheme() === "dark"; @@ -378,6 +379,7 @@ export function ThreadRouteScreen() { draftMessage={composer.draftMessage} draftAttachments={composer.draftAttachments} connectionStateLabel={routeConnectionState} + connectionReconnectAttempt={routeReconnectAttempt} activeThreadBusy={composer.activeThreadBusy} environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} diff --git a/apps/mobile/src/lib/wsTransport.ts b/apps/mobile/src/lib/wsTransport.ts new file mode 100644 index 00000000000..1cea5026d12 --- /dev/null +++ b/apps/mobile/src/lib/wsTransport.ts @@ -0,0 +1,35 @@ +import { + WsTransport as BaseWsTransport, + createWsRpcProtocolLayer as createSharedWsRpcProtocolLayer, + DEFAULT_RECONNECT_BACKOFF, + type WsProtocolLifecycleHandlers, + type WsRpcProtocolSocketUrlProvider, + type WsTransportOptions, +} from "@t3tools/client-runtime"; + +const MOBILE_RECONNECT_BACKOFF = { + ...DEFAULT_RECONNECT_BACKOFF, + maxRetries: null, +} as const; + +function createMobileProtocolLayer( + url: WsRpcProtocolSocketUrlProvider, + handlers?: WsProtocolLifecycleHandlers, +) { + return createSharedWsRpcProtocolLayer(url, handlers, { + backoff: MOBILE_RECONNECT_BACKOFF, + }); +} + +const mobileWsTransportOptions = { + createProtocolLayer: createMobileProtocolLayer, +} satisfies WsTransportOptions; + +export class MobileWsTransport extends BaseWsTransport { + constructor( + url: WsRpcProtocolSocketUrlProvider, + lifecycleHandlers?: WsProtocolLifecycleHandlers, + ) { + super(url, lifecycleHandlers, mobileWsTransportOptions); + } +} diff --git a/apps/mobile/src/state/remote-runtime-types.ts b/apps/mobile/src/state/remote-runtime-types.ts index 74d2cc8d995..74ba4c949c7 100644 --- a/apps/mobile/src/state/remote-runtime-types.ts +++ b/apps/mobile/src/state/remote-runtime-types.ts @@ -13,6 +13,7 @@ export interface ConnectedEnvironmentSummary { readonly displayUrl: string; readonly connectionState: EnvironmentConnectionState; readonly connectionError: string | null; + readonly reconnectAttempt: number; } export interface SelectedThreadRef { diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 4e27cb27f8b..fdf107c093f 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo } from "react"; -import { Alert } from "react-native"; +import { Alert, AppState, type AppStateStatus } from "react-native"; import { type EnvironmentRuntimeState, @@ -9,9 +9,9 @@ import { createKnownEnvironment, createWsRpcClient, EnvironmentConnectionState, - WsTransport, resolveRemoteWebSocketConnectionUrl, } from "@t3tools/client-runtime"; +import { MobileWsTransport } from "../lib/wsTransport"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; @@ -234,7 +234,7 @@ export async function connectSavedEnvironment( setEnvironmentConnectionStatus(connection.environmentId, "connecting", null); shellSnapshotManager.markPending({ environmentId: connection.environmentId }); - const transport = new WsTransport( + const transport = new MobileWsTransport( () => mobileRemoteHttpRuntime.runPromise( resolveRemoteWebSocketConnectionUrl({ @@ -250,19 +250,30 @@ export async function connectSavedEnvironment( } environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (previous) => { - const nextState = - previous.connectionState === "ready" || previous.connectionState === "reconnecting" - ? "reconnecting" - : "connecting"; + const isReconnect = + previous.connectionState === "ready" || + previous.connectionState === "reconnecting" || + previous.connectionState === "disconnected"; + const nextState = isReconnect ? "reconnecting" : "connecting"; const keepSettledFailure = previous.connectionState === "disconnected" && previous.connectionError !== null; return { ...previous, connectionState: keepSettledFailure ? "disconnected" : nextState, connectionError: keepSettledFailure ? previous.connectionError : null, + reconnectAttempt: isReconnect ? previous.reconnectAttempt + 1 : 1, }; }); }, + onOpen: () => { + if (!isCurrentAttempt()) { + return; + } + environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (previous) => ({ + ...previous, + reconnectAttempt: 0, + })); + }, onError: (message) => { if (isCurrentAttempt()) { setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); @@ -394,15 +405,44 @@ function deriveConnectedEnvironments( displayUrl: connection.displayUrl, connectionState: runtime?.connectionState ?? "idle", connectionError: runtime?.connectionError ?? null, + reconnectAttempt: runtime?.reconnectAttempt ?? 0, }; }), environmentsSortOrder, ); } +async function reconnectAllSavedEnvironments(reason: string): Promise { + const connections = Object.values(getSavedConnectionsById()); + if (connections.length === 0) { + return; + } + await Promise.all( + connections.map(async (connection) => { + try { + await connectSavedEnvironment(connection, { persist: false }); + } catch (error) { + console.warn( + `Failed to reconnect environment ${connection.environmentLabel} on ${reason}`, + error, + ); + } + }), + ); +} + export function useRemoteEnvironmentBootstrap() { useEffect(() => { let cancelled = false; + let lastAppState: AppStateStatus = AppState.currentState; + + const appStateSubscription = AppState.addEventListener("change", (nextState) => { + const previousState = lastAppState; + lastAppState = nextState; + if (nextState === "active" && previousState !== "active" && previousState !== "unknown") { + void reconnectAllSavedEnvironments("foreground"); + } + }); void loadSavedConnections() .then((connections) => { @@ -449,6 +489,7 @@ export function useRemoteEnvironmentBootstrap() { return () => { cancelled = true; + appStateSubscription.remove(); for (const session of drainEnvironmentSessions()) { void session.connection.dispose(); } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1d0af00e3a7..fa700546e35 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,5 @@ import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -58,6 +59,7 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; +import { deriveAuthClientMetadata } from "./auth/utils.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; @@ -1328,10 +1330,49 @@ export const websocketRpcRouteLayer = Layer.unwrap( ), ), ); + const clientMetadata = deriveAuthClientMetadata({ request }); + const connectionAnnotations = { + "ws.session_id": session.sessionId, + "ws.subject": session.subject, + "ws.role": session.role, + "ws.client_ip": clientMetadata.ipAddress ?? "unknown", + "ws.client_user_agent": clientMetadata.userAgent ?? "unknown", + "ws.client_device": clientMetadata.deviceType, + ...(clientMetadata.os ? { "ws.client_os": clientMetadata.os } : {}), + } as const; + const connectedAt = yield* Clock.currentTimeMillis; return yield* Effect.acquireUseRelease( - sessions.markConnected(session.sessionId), + sessions.markConnected(session.sessionId).pipe( + Effect.tap(() => + Effect.logInfo("websocket connection opened").pipe( + Effect.annotateLogs(connectionAnnotations), + ), + ), + Effect.withSpan("ws.connection.opened", { attributes: connectionAnnotations }), + ), () => rpcWebSocketHttpEffect, - () => sessions.markDisconnected(session.sessionId), + () => + Clock.currentTimeMillis.pipe( + Effect.flatMap((closedAt) => { + const durationMs = closedAt - connectedAt; + return sessions.markDisconnected(session.sessionId).pipe( + Effect.tap(() => + Effect.logInfo("websocket connection closed").pipe( + Effect.annotateLogs({ + ...connectionAnnotations, + "ws.duration_ms": durationMs, + }), + ), + ), + Effect.withSpan("ws.connection.closed", { + attributes: { + ...connectionAnnotations, + "ws.duration_ms": durationMs, + }, + }), + ); + }), + ), ); }).pipe(Effect.catchTag("AuthError", respondToAuthError)), ), diff --git a/packages/client-runtime/src/environmentRuntimeState.test.ts b/packages/client-runtime/src/environmentRuntimeState.test.ts index f2f0207f81f..119245f109c 100644 --- a/packages/client-runtime/src/environmentRuntimeState.test.ts +++ b/packages/client-runtime/src/environmentRuntimeState.test.ts @@ -27,12 +27,14 @@ describe("createEnvironmentRuntimeManager", () => { connectionState: "ready", connectionError: null, serverConfig: null, + reconnectAttempt: 0, }); expect(manager.getSnapshot(TARGET)).toEqual({ connectionState: "ready", connectionError: null, serverConfig: null, + reconnectAttempt: 0, }); }); @@ -51,6 +53,7 @@ describe("createEnvironmentRuntimeManager", () => { connectionState: "disconnected", connectionError: "Socket closed.", serverConfig: null, + reconnectAttempt: 0, }); }); @@ -63,6 +66,7 @@ describe("createEnvironmentRuntimeManager", () => { connectionState: "ready", connectionError: null, serverConfig: null, + reconnectAttempt: 0, }); manager.invalidate(TARGET); @@ -70,6 +74,7 @@ describe("createEnvironmentRuntimeManager", () => { connectionState: "idle", connectionError: null, serverConfig: null, + reconnectAttempt: 0, }); }); }); diff --git a/packages/client-runtime/src/environmentRuntimeState.ts b/packages/client-runtime/src/environmentRuntimeState.ts index e25979c8cfd..20bc4357c29 100644 --- a/packages/client-runtime/src/environmentRuntimeState.ts +++ b/packages/client-runtime/src/environmentRuntimeState.ts @@ -12,6 +12,7 @@ export interface EnvironmentRuntimeState { readonly connectionState: EnvironmentConnectionState; readonly connectionError: string | null; readonly serverConfig: T3ServerConfig | null; + readonly reconnectAttempt: number; } export interface EnvironmentRuntimeTarget { @@ -22,6 +23,7 @@ export const EMPTY_ENVIRONMENT_RUNTIME_STATE = Object.freeze();