Skip to content
Open
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
98 changes: 96 additions & 2 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface ThreadDetailScreenProps {
readonly draftMessage: string;
readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle";
readonly connectionReconnectAttempt: number;
readonly activeThreadBusy: boolean;
readonly environmentId: EnvironmentId;
readonly projectWorkspaceRoot: string | null;
Expand Down Expand Up @@ -165,20 +166,104 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE

const WORKING_INDICATOR_HEIGHT = 44;

type ConnectionStateLabel = "ready" | "connecting" | "reconnecting" | "disconnected" | "idle";

function describeConnectionBanner(
state: ConnectionStateLabel,
error: string | null,
attempt: number,
): { readonly title: string; readonly detail: string | null } | null {
const attemptSuffix = attempt > 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 (
<View className="px-4 pb-2" style={{ flexShrink: 0 }}>
<View className={containerClass}>
<Text className={titleClass}>{banner.title}</Text>
{banner.detail ? <Text className={detailClass}>{banner.detail}</Text> : null}
</View>
</View>
);
});

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 (
<View className="px-4 pb-2" style={{ flexShrink: 0 }}>
<View className="self-start rounded-full border border-amber-300/70 bg-amber-50/90 px-3 py-2 dark:border-amber-500/40 dark:bg-amber-500/10">
<Text className="font-t3-medium text-xs text-amber-700 dark:text-amber-200">
Last seen working for {durationLabel} · paused (disconnected){attemptSuffix}
</Text>
</View>
</View>
);
}

return (
<View className="px-4 pb-2" style={{ flexShrink: 0 }}>
<View className="self-start rounded-full border border-neutral-200/80 bg-neutral-50/90 px-3 py-2 dark:border-white/[0.08] dark:bg-white/[0.04]">
Expand Down Expand Up @@ -287,8 +372,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
offset={{ closed: 0, opened: 0 }}
>
<View onLayout={handleOverlayLayout}>
<ConnectionStatusBanner
state={props.connectionStateLabel}
error={props.connectionError}
reconnectAttempt={props.connectionReconnectAttempt}
/>
{props.activeWorkStartedAt ? (
<WorkingDurationPill startedAt={props.activeWorkStartedAt} />
<WorkingDurationPill
startedAt={props.activeWorkStartedAt}
connectionState={props.connectionStateLabel}
reconnectAttempt={props.connectionReconnectAttempt}
/>
) : null}

{props.activePendingApproval || props.activePendingUserInput ? (
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}
Expand Down
35 changes: 35 additions & 0 deletions apps/mobile/src/lib/wsTransport.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions apps/mobile/src/state/remote-runtime-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface ConnectedEnvironmentSummary {
readonly displayUrl: string;
readonly connectionState: EnvironmentConnectionState;
readonly connectionError: string | null;
readonly reconnectAttempt: number;
}

export interface SelectedThreadRef {
Expand Down
55 changes: 48 additions & 7 deletions apps/mobile/src/state/use-remote-environment-registry.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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({
Expand All @@ -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);
Expand Down Expand Up @@ -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<void> {
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) => {
Expand Down Expand Up @@ -449,6 +489,7 @@ export function useRemoteEnvironmentBootstrap() {

return () => {
cancelled = true;
appStateSubscription.remove();
for (const session of drainEnvironmentSessions()) {
void session.connection.dispose();
}
Expand Down
45 changes: 43 additions & 2 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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)),
),
Expand Down
Loading