Skip to content
Closed
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
4 changes: 3 additions & 1 deletion apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
import { useProjects } from "../../state/entities";
import { environmentThreadShells } from "../../state/threads";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useWorkspaceState } from "../../state/workspace";
import { useUnreachableEnvironmentIds, useWorkspaceState } from "../../state/workspace";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
import { useDeviceType, useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
Expand Down Expand Up @@ -116,6 +116,7 @@ export function HomeRouteScreen() {
Order.mapInput(Order.String, (environment: { readonly label: string }) => environment.label),
);
}, [savedConnectionsById, workspaceEnvironments]);
const unreachableIds = useUnreachableEnvironmentIds();
const availableEnvironmentIds = useMemo(
() => new Set(environments.map((environment) => environment.environmentId)),
[environments],
Expand Down Expand Up @@ -254,6 +255,7 @@ export function HomeRouteScreen() {
<HomeScreen
catalogState={catalogState}
environments={environments}
unreachableEnvironmentIds={unreachableIds}
onAddConnection={() =>
navigation.navigate("SettingsSheet", {
screen: "SettingsContent",
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ interface HomeScreenProps {
readonly environments: ReadonlyArray<
HomeListFilterMenuEnvironment & Pick<WorkspaceEnvironment, "connectionState">
>;
readonly unreachableEnvironmentIds: ReadonlySet<EnvironmentId>;
readonly searchQuery: string;
readonly selectedEnvironmentId: EnvironmentId | null;
readonly selectedProjectKey: string | null;
Expand Down Expand Up @@ -587,6 +588,9 @@ export function HomeScreen(props: HomeScreenProps) {
: null
}
environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
environmentUnreachable={props.unreachableEnvironmentIds.has(
item.pendingTask.environmentId,
)}
showPendingDivider={item.showPendingDivider}
showTrailingDivider={item.showTrailingDivider}
onSelectPendingTask={props.onSelectPendingTask}
Expand Down Expand Up @@ -652,6 +656,7 @@ export function HomeScreen(props: HomeScreenProps) {
: null
}
environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
environmentUnreachable={props.unreachableEnvironmentIds.has(thread.environmentId)}
searchMatch={threadSearchMatchByKey.get(
threadSearchMatchKey({
environmentId: thread.environmentId,
Expand Down Expand Up @@ -703,6 +708,7 @@ export function HomeScreen(props: HomeScreenProps) {
projectTitleByProjectKey: v2ProjectTitleByProjectKey,
listEnvironments,
savedConnectionsById: props.savedConnectionsById,
unreachableEnvironmentIds: props.unreachableEnvironmentIds,
searchQuery: props.searchQuery,
selectedThreadKey,
threadSearchMatchByKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ function workspaceState(overrides: Partial<WorkspaceState> = {}): WorkspaceState
hasConnections: true,
hasLoadedShellSnapshot: true,
hasPendingShellSnapshot: false,
hasSyncingConnectedEnvironment: false,
hasReadyEnvironment: true,
hasConnectingEnvironment: false,
connectingEnvironments: [],
Expand All @@ -20,6 +21,22 @@ function workspaceState(overrides: Partial<WorkspaceState> = {}): WorkspaceState
};
}

function environment(
overrides: Partial<WorkspaceState["connectingEnvironments"][number]> = {},
): WorkspaceState["connectingEnvironments"][number] {
return {
environmentId: "environment-1" as never,
environmentLabel: "Julius’s Mac mini",
displayUrl: "",
isRelayManaged: false,
isEnabled: true,
connectionState: "reconnecting",
connectionError: null,
connectionErrorTraceId: null,
...overrides,
};
}

describe("workspace connection status", () => {
it("stays hidden while a ready environment is connected", () => {
expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull();
Expand Down Expand Up @@ -58,6 +75,73 @@ describe("workspace connection status", () => {
});
});

it("stays hidden while another environment is connected", () => {
const state = workspaceState({
hasConnectingEnvironment: true,
connectingEnvironments: [environment({ connectionError: "Timed out" })],
connectionError: "Timed out",
});

expect(workspaceConnectionStatusPresentation(state)).toBeNull();
});

it("ignores the pending shell of an environment that is only being retried", () => {
const state = workspaceState({
hasConnectingEnvironment: true,
hasPendingShellSnapshot: true,
connectingEnvironments: [environment({ connectionError: "Timed out" })],
connectionError: "Timed out",
});

expect(workspaceConnectionStatusPresentation(state)).toBeNull();
});

it("reports sync of a connected environment while another is unreachable", () => {
const state = workspaceState({
hasConnectingEnvironment: true,
hasPendingShellSnapshot: true,
hasSyncingConnectedEnvironment: true,
connectingEnvironments: [environment({ connectionError: "Timed out" })],
connectionError: "Timed out",
});

expect(workspaceConnectionStatusPresentation(state)).toEqual({
label: "Syncing threads...",
showsProgress: true,
});
});

it("stops promising a reconnect once an attempt has failed", () => {
const state = workspaceState({
hasConnectingEnvironment: true,
hasReadyEnvironment: false,
connectingEnvironments: [environment({ connectionError: "Timed out" })],
connectionError: "Timed out",
});

expect(workspaceConnectionStatusPresentation(state)).toEqual({
label: "Julius’s Mac mini is unreachable",
showsProgress: false,
});
});

it("keeps reconnecting while any environment is still on a first attempt", () => {
const state = workspaceState({
hasConnectingEnvironment: true,
hasReadyEnvironment: false,
connectingEnvironments: [
environment({ connectionError: "Timed out" }),
environment({ environmentId: "environment-2" as never, connectionState: "connecting" }),
],
connectionError: "Timed out",
});

expect(workspaceConnectionStatusPresentation(state)).toEqual({
label: "Reconnecting 2 environments",
showsProgress: true,
});
});

it("surfaces connection errors before the generic disconnected fallback", () => {
const state = workspaceState({
connectionError: "Could not reach Julius’s Mac mini",
Expand All @@ -72,7 +156,10 @@ describe("workspace connection status", () => {
});

it("shows shell catch-up while cached threads remain visible", () => {
const state = workspaceState({ hasPendingShellSnapshot: true });
const state = workspaceState({
hasPendingShellSnapshot: true,
hasSyncingConnectedEnvironment: true,
});

expect(workspaceConnectionStatusPresentation(state)).toEqual({
label: "Syncing threads...",
Expand All @@ -84,6 +171,7 @@ describe("workspace connection status", () => {
const state = workspaceState({
hasLoadedShellSnapshot: false,
hasPendingShellSnapshot: true,
hasSyncingConnectedEnvironment: true,
});

expect(workspaceConnectionStatusPresentation(state)).toEqual({
Expand Down
54 changes: 40 additions & 14 deletions apps/mobile/src/features/home/workspace-connection-status.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,70 @@
import type { WorkspaceState } from "../../state/workspaceModel";
import { environmentIsUnreachable, type WorkspaceState } from "../../state/workspaceModel";

export interface WorkspaceConnectionStatusPresentation {
readonly label: string;
readonly showsProgress: boolean;
}

function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean {
if (state.networkStatus === "offline") return true;
// One connected environment keeps the app usable, so another that is asleep
// or unreachable is reported on its own rows instead of taking the title.
// Its retries also mark its shell as pending, so only a connected
// environment's sync counts here.
if (state.hasReadyEnvironment) return state.hasSyncingConnectedEnvironment;
return (
state.networkStatus === "offline" ||
state.hasPendingShellSnapshot ||
state.connectionError !== null ||
state.hasConnectingEnvironment ||
state.hasPendingShellSnapshot ||
(state.hasLoadedShellSnapshot && !state.hasReadyEnvironment)
state.hasLoadedShellSnapshot
);
}

function connectingEnvironmentsLabel(
environments: WorkspaceState["connectingEnvironments"],
): string | null {
if (environments.length === 0) return null;
// After a failed attempt the retries continue in the background, possibly
// for hours, so the label stops promising a reconnect.
const unreachable = environments.every(environmentIsUnreachable);
if (environments.length === 1) {
const label = environments[0]!.environmentLabel;
return unreachable ? `${label} is unreachable` : `Reconnecting to ${label}`;
}
return unreachable
? `${environments.length} environments unreachable`
: `Reconnecting ${environments.length} environments`;
}

function workspaceConnectionStatusLabel(state: WorkspaceState): string {
if (state.networkStatus === "offline") return "You are offline";
if (state.connectingEnvironments.length === 1) {
return `Reconnecting to ${state.connectingEnvironments[0]!.environmentLabel}`;
if (!state.hasReadyEnvironment) {
const connectingLabel = connectingEnvironmentsLabel(state.connectingEnvironments);
if (connectingLabel !== null) return connectingLabel;
if (state.connectionError !== null) return state.connectionError;
}
if (state.connectingEnvironments.length > 1) {
return `Reconnecting ${state.connectingEnvironments.length} environments`;
}
if (state.connectionError !== null) return state.connectionError;
if (state.hasPendingShellSnapshot) {
return state.hasLoadedShellSnapshot ? "Syncing threads..." : "Loading threads...";
}
return "Not connected";
}

function showsConnectionProgress(state: WorkspaceState): boolean {
if (state.networkStatus === "offline") return false;
// Only shell sync reaches the title while an environment is connected.
if (state.hasReadyEnvironment) return true;
if (state.connectingEnvironments.length > 0) {
return !state.connectingEnvironments.every(environmentIsUnreachable);
}
return state.connectionError === null && state.hasPendingShellSnapshot;
}

export function workspaceConnectionStatusPresentation(
state: WorkspaceState,
): WorkspaceConnectionStatusPresentation | null {
if (!shouldShowWorkspaceConnectionStatus(state)) return null;
return {
label: workspaceConnectionStatusLabel(state),
showsProgress:
state.networkStatus !== "offline" &&
state.connectionError === null &&
(state.connectingEnvironments.length > 0 || state.hasPendingShellSnapshot),
showsProgress: showsConnectionProgress(state),
};
}
6 changes: 5 additions & 1 deletion apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { usePendingThreadOrder } from "../../state/thread-order";
import { threadListEnvironmentsAtom } from "../../state/server";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
import { useWorkspaceState } from "../../state/workspace";
import { useUnreachableEnvironmentIds, useWorkspaceState } from "../../state/workspace";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
Expand Down Expand Up @@ -162,6 +162,7 @@ function ThreadNavigationSidebarPane(
label: connection.environmentLabel,
}))
.sort((left, right) => left.label.localeCompare(right.label));
const unreachableIds = useUnreachableEnvironmentIds();
const availableEnvironmentIds = new Set(
environments.map((environment) => environment.environmentId),
);
Expand Down Expand Up @@ -516,6 +517,7 @@ function ThreadNavigationSidebarPane(
projectByKey,
projectTitleByProjectKey,
savedConnectionsById,
unreachableIds,
listEnvironments,
threadSearchMatchByKey,
workingShelfEnabled,
Expand Down Expand Up @@ -566,6 +568,7 @@ function ThreadNavigationSidebarPane(
: null
}
environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
environmentUnreachable={unreachableIds.has(item.pendingTask.environmentId)}
pane="sidebar"
showPendingDivider={item.showPendingDivider}
onSelectPendingTask={openPendingTask}
Expand Down Expand Up @@ -597,6 +600,7 @@ function ThreadNavigationSidebarPane(
: null
}
environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
environmentUnreachable={unreachableIds.has(thread.environmentId)}
searchMatch={threadSearchMatchByKey.get(
threadSearchMatchKey({
environmentId: thread.environmentId,
Expand Down
Loading
Loading