Skip to content

Commit 2f3596d

Browse files
authored
feat: Connectivity service + offline handling (#492)
...
1 parent f52ba0c commit 2f3596d

16 files changed

Lines changed: 426 additions & 39 deletions

File tree

apps/array/src/main/di/container.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import "reflect-metadata";
22
import { Container } from "inversify";
33
import { AgentService } from "../services/agent/service.js";
4+
import { ConnectivityService } from "../services/connectivity/service.js";
45
import { ContextMenuService } from "../services/context-menu/service.js";
56
import { DeepLinkService } from "../services/deep-link/service.js";
67
import { DockBadgeService } from "../services/dock-badge/service.js";
@@ -22,6 +23,7 @@ export const container = new Container({
2223
});
2324

2425
container.bind(MAIN_TOKENS.AgentService).to(AgentService);
26+
container.bind(MAIN_TOKENS.ConnectivityService).to(ConnectivityService);
2527
container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
2628
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);
2729
container.bind(MAIN_TOKENS.DockBadgeService).to(DockBadgeService);

apps/array/src/main/di/tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
export const MAIN_TOKENS = Object.freeze({
88
// Services
99
AgentService: Symbol.for("Main.AgentService"),
10+
ConnectivityService: Symbol.for("Main.ConnectivityService"),
1011
ContextMenuService: Symbol.for("Main.ContextMenuService"),
1112
DockBadgeService: Symbol.for("Main.DockBadgeService"),
1213
ExternalAppsService: Symbol.for("Main.ExternalAppsService"),
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { z } from "zod";
2+
3+
export const connectivityStatusOutput = z.object({
4+
isOnline: z.boolean(),
5+
});
6+
7+
export type ConnectivityStatusOutput = z.infer<typeof connectivityStatusOutput>;
8+
9+
export const ConnectivityEvent = {
10+
StatusChange: "status-change",
11+
} as const;
12+
13+
export interface ConnectivityEvents {
14+
[ConnectivityEvent.StatusChange]: ConnectivityStatusOutput;
15+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { net } from "electron";
2+
import { injectable, postConstruct } from "inversify";
3+
import { logger } from "../../lib/logger.js";
4+
import { TypedEventEmitter } from "../../lib/typed-event-emitter.js";
5+
import {
6+
ConnectivityEvent,
7+
type ConnectivityEvents,
8+
type ConnectivityStatusOutput,
9+
} from "./schemas.js";
10+
11+
const log = logger.scope("connectivity");
12+
13+
const CHECK_URL = "https://www.google.com/generate_204";
14+
const MIN_POLL_INTERVAL_MS = 3_000;
15+
const MAX_POLL_INTERVAL_MS = 10_000;
16+
const ONLINE_POLL_INTERVAL_MS = 3_000;
17+
18+
@injectable()
19+
export class ConnectivityService extends TypedEventEmitter<ConnectivityEvents> {
20+
private isOnline = false;
21+
private pollTimeoutId: ReturnType<typeof setTimeout> | null = null;
22+
private currentPollInterval = MIN_POLL_INTERVAL_MS;
23+
24+
@postConstruct()
25+
init(): void {
26+
this.isOnline = net.isOnline();
27+
log.info("Initial connectivity status", { isOnline: this.isOnline });
28+
29+
this.startPolling();
30+
}
31+
32+
getStatus(): ConnectivityStatusOutput {
33+
return { isOnline: this.isOnline };
34+
}
35+
36+
async checkNow(): Promise<ConnectivityStatusOutput> {
37+
await this.checkConnectivity();
38+
return { isOnline: this.isOnline };
39+
}
40+
41+
private setOnline(online: boolean): void {
42+
if (this.isOnline === online) return;
43+
44+
this.isOnline = online;
45+
log.info("Connectivity status changed", { isOnline: online });
46+
this.emit(ConnectivityEvent.StatusChange, { isOnline: online });
47+
48+
this.currentPollInterval = MIN_POLL_INTERVAL_MS;
49+
}
50+
51+
private async checkConnectivity(): Promise<void> {
52+
if (!net.isOnline()) {
53+
this.setOnline(false);
54+
return;
55+
}
56+
57+
if (!this.isOnline) {
58+
const verified = await this.verifyWithHttp();
59+
this.setOnline(verified);
60+
}
61+
}
62+
63+
private async verifyWithHttp(): Promise<boolean> {
64+
try {
65+
const response = await net.fetch(CHECK_URL, { method: "HEAD" });
66+
return response.ok || response.status === 204;
67+
} catch (error) {
68+
log.debug("HTTP connectivity check failed", { error });
69+
return false;
70+
}
71+
}
72+
73+
private startPolling(): void {
74+
if (this.pollTimeoutId) return;
75+
76+
this.currentPollInterval = MIN_POLL_INTERVAL_MS;
77+
this.schedulePoll();
78+
}
79+
80+
private schedulePoll(): void {
81+
// when online: just poll net.isOnline periodically
82+
// when offline: poll more frequently with backoff to detect recovery
83+
const interval = this.isOnline
84+
? ONLINE_POLL_INTERVAL_MS
85+
: this.currentPollInterval;
86+
87+
this.pollTimeoutId = setTimeout(async () => {
88+
this.pollTimeoutId = null;
89+
90+
const wasOffline = !this.isOnline;
91+
await this.checkConnectivity();
92+
93+
if (!this.isOnline && wasOffline) {
94+
this.currentPollInterval = Math.min(
95+
this.currentPollInterval * 1.5,
96+
MAX_POLL_INTERVAL_MS,
97+
);
98+
}
99+
100+
this.schedulePoll();
101+
}, interval);
102+
}
103+
}

apps/array/src/main/trpc/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { agentRouter } from "./routers/agent.js";
22
import { analyticsRouter } from "./routers/analytics.js";
3+
import { connectivityRouter } from "./routers/connectivity.js";
34
import { contextMenuRouter } from "./routers/context-menu.js";
45
import { deepLinkRouter } from "./routers/deep-link.js";
56
import { dockBadgeRouter } from "./routers/dock-badge.js";
@@ -22,6 +23,7 @@ import { router } from "./trpc.js";
2223
export const trpcRouter = router({
2324
agent: agentRouter,
2425
analytics: analyticsRouter,
26+
connectivity: connectivityRouter,
2527
contextMenu: contextMenuRouter,
2628
dockBadge: dockBadgeRouter,
2729
encryption: encryptionRouter,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { container } from "../../di/container.js";
2+
import { MAIN_TOKENS } from "../../di/tokens.js";
3+
import {
4+
ConnectivityEvent,
5+
type ConnectivityEvents,
6+
connectivityStatusOutput,
7+
} from "../../services/connectivity/schemas.js";
8+
import type { ConnectivityService } from "../../services/connectivity/service.js";
9+
import { publicProcedure, router } from "../trpc.js";
10+
11+
const getService = () =>
12+
container.get<ConnectivityService>(MAIN_TOKENS.ConnectivityService);
13+
14+
function subscribe<K extends keyof ConnectivityEvents>(event: K) {
15+
return publicProcedure.subscription(async function* (opts) {
16+
const service = getService();
17+
const iterable = service.toIterable(event, { signal: opts.signal });
18+
for await (const data of iterable) {
19+
yield data;
20+
}
21+
});
22+
}
23+
24+
export const connectivityRouter = router({
25+
getStatus: publicProcedure.output(connectivityStatusOutput).query(() => {
26+
const service = getService();
27+
return service.getStatus();
28+
}),
29+
30+
checkNow: publicProcedure
31+
.output(connectivityStatusOutput)
32+
.mutation(async () => {
33+
const service = getService();
34+
return service.checkNow();
35+
}),
36+
37+
onStatusChange: subscribe(ConnectivityEvent.StatusChange),
38+
});

apps/array/src/renderer/App.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AuthScreen } from "@features/auth/components/AuthScreen";
33
import { useAuthStore } from "@features/auth/stores/authStore";
44
import { Flex, Spinner, Text } from "@radix-ui/themes";
55
import { initializePostHog } from "@renderer/lib/analytics";
6+
import { initializeConnectivityStore } from "@renderer/stores/connectivityStore";
67
import { trpcVanilla } from "@renderer/trpc/client";
78
import { toast } from "@utils/toast";
89
import { useEffect, useState } from "react";
@@ -16,6 +17,11 @@ function App() {
1617
initializePostHog();
1718
}, []);
1819

20+
// Initialize connectivity monitoring
21+
useEffect(() => {
22+
return initializeConnectivityStore();
23+
}, []);
24+
1925
// Global workspace error listener for toasts
2026
useEffect(() => {
2127
const subscription = trpcVanilla.workspace.onError.subscribe(undefined, {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { WifiSlash } from "@phosphor-icons/react";
2+
import { Button, Dialog, Flex, Text } from "@radix-ui/themes";
3+
4+
interface ConnectivityPromptProps {
5+
open: boolean;
6+
isChecking: boolean;
7+
onRetry: () => void;
8+
onDismiss: () => void;
9+
}
10+
11+
export function ConnectivityPrompt({
12+
open,
13+
isChecking,
14+
onRetry,
15+
onDismiss,
16+
}: ConnectivityPromptProps) {
17+
return (
18+
<Dialog.Root open={open}>
19+
<Dialog.Content
20+
maxWidth="360px"
21+
onEscapeKeyDown={(e) => e.preventDefault()}
22+
onInteractOutside={(e) => e.preventDefault()}
23+
>
24+
<Flex direction="column" gap="3">
25+
<Flex align="center" gap="2">
26+
<WifiSlash size={20} weight="bold" color="var(--gray-11)" />
27+
<Dialog.Title className="mb-0">No internet connection</Dialog.Title>
28+
</Flex>
29+
<Dialog.Description>
30+
<Text size="2" color="gray">
31+
Array requires an internet connection to use AI features. Check
32+
your connection and try again.
33+
</Text>
34+
</Dialog.Description>
35+
<Flex justify="end" gap="3" mt="2">
36+
<Button
37+
type="button"
38+
variant="soft"
39+
color="gray"
40+
onClick={onDismiss}
41+
disabled={isChecking}
42+
>
43+
Dismiss
44+
</Button>
45+
<Button type="button" onClick={onRetry} loading={isChecking}>
46+
Try Again
47+
</Button>
48+
</Flex>
49+
</Flex>
50+
</Dialog.Content>
51+
</Dialog.Root>
52+
);
53+
}

apps/array/src/renderer/components/MainLayout.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ConnectivityPrompt } from "@components/ConnectivityPrompt";
12
import { HeaderRow } from "@components/HeaderRow";
23
import { KeyboardShortcutsSheet } from "@components/KeyboardShortcutsSheet";
34
import { StatusBar } from "@components/StatusBar";
@@ -10,6 +11,7 @@ import { MainSidebar } from "@features/sidebar/components/MainSidebar";
1011
import { TaskDetail } from "@features/task-detail/components/TaskDetail";
1112
import { TaskInput } from "@features/task-detail/components/TaskInput";
1213
import { useTasks } from "@features/tasks/hooks/useTasks";
14+
import { useConnectivity } from "@hooks/useConnectivity";
1315
import { useIntegrations } from "@hooks/useIntegrations";
1416
import { Box, Flex } from "@radix-ui/themes";
1517
import { useNavigationStore } from "@stores/navigationStore";
@@ -28,6 +30,7 @@ export function MainLayout() {
2830
close: closeShortcutsSheet,
2931
} = useShortcutsSheetStore();
3032
const { data: tasks } = useTasks();
33+
const { showPrompt, isChecking, check, dismiss } = useConnectivity();
3134

3235
useIntegrations();
3336
useTaskDeepLink();
@@ -75,6 +78,12 @@ export function MainLayout() {
7578
onOpenChange={(open) => (open ? null : closeShortcutsSheet())}
7679
/>
7780
<UpdatePrompt />
81+
<ConnectivityPrompt
82+
open={showPrompt}
83+
isChecking={isChecking}
84+
onRetry={check}
85+
onDismiss={dismiss}
86+
/>
7887
<GlobalEventHandlers
7988
onToggleCommandMenu={handleToggleCommandMenu}
8089
onToggleShortcutsSheet={toggleShortcutsSheet}

apps/array/src/renderer/features/message-editor/components/MessageEditor.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import "./message-editor.css";
22
import type { ExecutionMode } from "@features/sessions/stores/sessionStore";
3+
import { useConnectivity } from "@hooks/useConnectivity";
34
import { ArrowUp, Stop } from "@phosphor-icons/react";
45
import { Flex, IconButton, Text, Tooltip } from "@radix-ui/themes";
56
import { EditorContent } from "@tiptap/react";
@@ -47,11 +48,13 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
4748
const context = useDraftStore((s) => s.contexts[sessionId]);
4849
const focusRequested = useDraftStore((s) => s.focusRequested[sessionId]);
4950
const clearFocusRequest = useDraftStore((s) => s.actions.clearFocusRequest);
51+
const { isOnline } = useConnectivity();
5052
const taskId = context?.taskId;
5153
const disabled = context?.disabled ?? false;
5254
const isLoading = context?.isLoading ?? false;
5355
const isCloud = context?.isCloud ?? false;
5456
const repoPath = context?.repoPath;
57+
const isDisabled = disabled || !isOnline;
5558

5659
const {
5760
editor,
@@ -162,7 +165,11 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
162165
) : (
163166
<Tooltip
164167
content={
165-
disabled || isEmpty ? "Enter a message" : "Send message"
168+
!isOnline
169+
? "You're offline"
170+
: isDisabled || isEmpty
171+
? "Enter a message"
172+
: "Send message"
166173
}
167174
>
168175
<IconButton
@@ -172,12 +179,13 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
172179
e.stopPropagation();
173180
submit();
174181
}}
175-
disabled={disabled || isEmpty}
182+
disabled={isDisabled || isEmpty}
176183
loading={isLoading}
177184
style={{
178185
backgroundColor:
179-
disabled || isEmpty ? "var(--accent-a4)" : undefined,
180-
color: disabled || isEmpty ? "var(--accent-8)" : undefined,
186+
isDisabled || isEmpty ? "var(--accent-a4)" : undefined,
187+
color:
188+
isDisabled || isEmpty ? "var(--accent-8)" : undefined,
181189
}}
182190
>
183191
<ArrowUp size={14} weight="bold" />

0 commit comments

Comments
 (0)