Skip to content
Draft
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
2 changes: 2 additions & 0 deletions apps/array/src/main/di/container.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "reflect-metadata";
import { Container } from "inversify";
import { AgentService } from "../services/agent/service.js";
import { ConnectivityService } from "../services/connectivity/service.js";
import { ContextMenuService } from "../services/context-menu/service.js";
import { DeepLinkService } from "../services/deep-link/service.js";
import { DockBadgeService } from "../services/dock-badge/service.js";
Expand All @@ -22,6 +23,7 @@ export const container = new Container({
});

container.bind(MAIN_TOKENS.AgentService).to(AgentService);
container.bind(MAIN_TOKENS.ConnectivityService).to(ConnectivityService);
container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);
container.bind(MAIN_TOKENS.DockBadgeService).to(DockBadgeService);
Expand Down
1 change: 1 addition & 0 deletions apps/array/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
export const MAIN_TOKENS = Object.freeze({
// Services
AgentService: Symbol.for("Main.AgentService"),
ConnectivityService: Symbol.for("Main.ConnectivityService"),
ContextMenuService: Symbol.for("Main.ContextMenuService"),
DockBadgeService: Symbol.for("Main.DockBadgeService"),
ExternalAppsService: Symbol.for("Main.ExternalAppsService"),
Expand Down
15 changes: 15 additions & 0 deletions apps/array/src/main/services/connectivity/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { z } from "zod";

export const connectivityStatusOutput = z.object({
isOnline: z.boolean(),
});

export type ConnectivityStatusOutput = z.infer<typeof connectivityStatusOutput>;

export const ConnectivityEvent = {
StatusChange: "status-change",
} as const;

export interface ConnectivityEvents {
[ConnectivityEvent.StatusChange]: ConnectivityStatusOutput;
}
103 changes: 103 additions & 0 deletions apps/array/src/main/services/connectivity/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { net } from "electron";
import { injectable, postConstruct } from "inversify";
import { logger } from "../../lib/logger.js";
import { TypedEventEmitter } from "../../lib/typed-event-emitter.js";
import {
ConnectivityEvent,
type ConnectivityEvents,
type ConnectivityStatusOutput,
} from "./schemas.js";

const log = logger.scope("connectivity");

const CHECK_URL = "https://www.google.com/generate_204";
const MIN_POLL_INTERVAL_MS = 3_000;
const MAX_POLL_INTERVAL_MS = 10_000;
const ONLINE_POLL_INTERVAL_MS = 3_000;

@injectable()
export class ConnectivityService extends TypedEventEmitter<ConnectivityEvents> {
private isOnline = false;
private pollTimeoutId: ReturnType<typeof setTimeout> | null = null;
private currentPollInterval = MIN_POLL_INTERVAL_MS;

@postConstruct()
init(): void {
this.isOnline = net.isOnline();
log.info("Initial connectivity status", { isOnline: this.isOnline });

this.startPolling();
}

getStatus(): ConnectivityStatusOutput {
return { isOnline: this.isOnline };
}

async checkNow(): Promise<ConnectivityStatusOutput> {
await this.checkConnectivity();
return { isOnline: this.isOnline };
}

private setOnline(online: boolean): void {
if (this.isOnline === online) return;

this.isOnline = online;
log.info("Connectivity status changed", { isOnline: online });
this.emit(ConnectivityEvent.StatusChange, { isOnline: online });

this.currentPollInterval = MIN_POLL_INTERVAL_MS;
}

private async checkConnectivity(): Promise<void> {
if (!net.isOnline()) {
this.setOnline(false);
return;
}

if (!this.isOnline) {
const verified = await this.verifyWithHttp();
this.setOnline(verified);
}
}

private async verifyWithHttp(): Promise<boolean> {
try {
const response = await net.fetch(CHECK_URL, { method: "HEAD" });
return response.ok || response.status === 204;
} catch (error) {
log.debug("HTTP connectivity check failed", { error });
return false;
}
}

private startPolling(): void {
if (this.pollTimeoutId) return;

this.currentPollInterval = MIN_POLL_INTERVAL_MS;
this.schedulePoll();
}

private schedulePoll(): void {
// when online: just poll net.isOnline periodically
// when offline: poll more frequently with backoff to detect recovery
const interval = this.isOnline
? ONLINE_POLL_INTERVAL_MS
: this.currentPollInterval;

this.pollTimeoutId = setTimeout(async () => {
this.pollTimeoutId = null;

const wasOffline = !this.isOnline;
await this.checkConnectivity();

if (!this.isOnline && wasOffline) {
this.currentPollInterval = Math.min(
this.currentPollInterval * 1.5,
MAX_POLL_INTERVAL_MS,
);
}

this.schedulePoll();
}, interval);
}
}
2 changes: 2 additions & 0 deletions apps/array/src/main/trpc/router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { agentRouter } from "./routers/agent.js";
import { analyticsRouter } from "./routers/analytics.js";
import { connectivityRouter } from "./routers/connectivity.js";
import { contextMenuRouter } from "./routers/context-menu.js";
import { deepLinkRouter } from "./routers/deep-link.js";
import { dockBadgeRouter } from "./routers/dock-badge.js";
Expand All @@ -22,6 +23,7 @@ import { router } from "./trpc.js";
export const trpcRouter = router({
agent: agentRouter,
analytics: analyticsRouter,
connectivity: connectivityRouter,
contextMenu: contextMenuRouter,
dockBadge: dockBadgeRouter,
encryption: encryptionRouter,
Expand Down
38 changes: 38 additions & 0 deletions apps/array/src/main/trpc/routers/connectivity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { container } from "../../di/container.js";
import { MAIN_TOKENS } from "../../di/tokens.js";
import {
ConnectivityEvent,
type ConnectivityEvents,
connectivityStatusOutput,
} from "../../services/connectivity/schemas.js";
import type { ConnectivityService } from "../../services/connectivity/service.js";
import { publicProcedure, router } from "../trpc.js";

const getService = () =>
container.get<ConnectivityService>(MAIN_TOKENS.ConnectivityService);

function subscribe<K extends keyof ConnectivityEvents>(event: K) {
return publicProcedure.subscription(async function* (opts) {
const service = getService();
const iterable = service.toIterable(event, { signal: opts.signal });
for await (const data of iterable) {
yield data;
}
});
}

export const connectivityRouter = router({
getStatus: publicProcedure.output(connectivityStatusOutput).query(() => {
const service = getService();
return service.getStatus();
}),

checkNow: publicProcedure
.output(connectivityStatusOutput)
.mutation(async () => {
const service = getService();
return service.checkNow();
}),

onStatusChange: subscribe(ConnectivityEvent.StatusChange),
});
6 changes: 6 additions & 0 deletions apps/array/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AuthScreen } from "@features/auth/components/AuthScreen";
import { useAuthStore } from "@features/auth/stores/authStore";
import { Flex, Spinner, Text } from "@radix-ui/themes";
import { initializePostHog } from "@renderer/lib/analytics";
import { initializeConnectivityStore } from "@renderer/stores/connectivityStore";
import { trpcVanilla } from "@renderer/trpc/client";
import { toast } from "@utils/toast";
import { useEffect, useState } from "react";
Expand All @@ -16,6 +17,11 @@ function App() {
initializePostHog();
}, []);

// Initialize connectivity monitoring
useEffect(() => {
return initializeConnectivityStore();
}, []);

// Global workspace error listener for toasts
useEffect(() => {
const subscription = trpcVanilla.workspace.onError.subscribe(undefined, {
Expand Down
53 changes: 53 additions & 0 deletions apps/array/src/renderer/components/ConnectivityPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { WifiSlash } from "@phosphor-icons/react";
import { Button, Dialog, Flex, Text } from "@radix-ui/themes";

interface ConnectivityPromptProps {
open: boolean;
isChecking: boolean;
onRetry: () => void;
onDismiss: () => void;
}

export function ConnectivityPrompt({
open,
isChecking,
onRetry,
onDismiss,
}: ConnectivityPromptProps) {
return (
<Dialog.Root open={open}>
<Dialog.Content
maxWidth="360px"
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<Flex direction="column" gap="3">
<Flex align="center" gap="2">
<WifiSlash size={20} weight="bold" color="var(--gray-11)" />
<Dialog.Title className="mb-0">No internet connection</Dialog.Title>
</Flex>
<Dialog.Description>
<Text size="2" color="gray">
Array requires an internet connection to use AI features. Check
your connection and try again.
</Text>
</Dialog.Description>
<Flex justify="end" gap="3" mt="2">
<Button
type="button"
variant="soft"
color="gray"
onClick={onDismiss}
disabled={isChecking}
>
Dismiss
</Button>
<Button type="button" onClick={onRetry} loading={isChecking}>
Try Again
</Button>
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
);
}
9 changes: 9 additions & 0 deletions apps/array/src/renderer/components/MainLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConnectivityPrompt } from "@components/ConnectivityPrompt";
import { HeaderRow } from "@components/HeaderRow";
import { KeyboardShortcutsSheet } from "@components/KeyboardShortcutsSheet";
import { StatusBar } from "@components/StatusBar";
Expand All @@ -10,6 +11,7 @@ import { MainSidebar } from "@features/sidebar/components/MainSidebar";
import { TaskDetail } from "@features/task-detail/components/TaskDetail";
import { TaskInput } from "@features/task-detail/components/TaskInput";
import { useTasks } from "@features/tasks/hooks/useTasks";
import { useConnectivity } from "@hooks/useConnectivity";
import { useIntegrations } from "@hooks/useIntegrations";
import { Box, Flex } from "@radix-ui/themes";
import { useNavigationStore } from "@stores/navigationStore";
Expand All @@ -28,6 +30,7 @@ export function MainLayout() {
close: closeShortcutsSheet,
} = useShortcutsSheetStore();
const { data: tasks } = useTasks();
const { showPrompt, isChecking, check, dismiss } = useConnectivity();

useIntegrations();
useTaskDeepLink();
Expand Down Expand Up @@ -75,6 +78,12 @@ export function MainLayout() {
onOpenChange={(open) => (open ? null : closeShortcutsSheet())}
/>
<UpdatePrompt />
<ConnectivityPrompt
open={showPrompt}
isChecking={isChecking}
onRetry={check}
onDismiss={dismiss}
/>
<GlobalEventHandlers
onToggleCommandMenu={handleToggleCommandMenu}
onToggleShortcutsSheet={toggleShortcutsSheet}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "./message-editor.css";
import { ArrowUp, Stop } from "@phosphor-icons/react";
import { Flex, IconButton, Text, Tooltip } from "@radix-ui/themes";
import { useConnectivityStore } from "@stores/connectivityStore";
import { EditorContent } from "@tiptap/react";
import { forwardRef, useImperativeHandle } from "react";
import { useHotkeys } from "react-hotkeys-hook";
Expand Down Expand Up @@ -45,6 +46,8 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
const isCloud = context?.isCloud ?? false;
const repoPath = context?.repoPath;

const isOffline = useConnectivityStore((s) => !s.isOnline);

const {
editor,
isEmpty,
Expand Down Expand Up @@ -147,7 +150,11 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
) : (
<Tooltip
content={
disabled || isEmpty ? "Enter a message" : "Send message"
isOffline
? "You're offline"
: disabled || isEmpty
? "Enter a message"
: "Send message"
}
>
<IconButton
Expand All @@ -157,12 +164,17 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
e.stopPropagation();
submit();
}}
disabled={disabled || isEmpty}
disabled={disabled || isEmpty || isOffline}
loading={isLoading}
style={{
backgroundColor:
disabled || isEmpty ? "var(--accent-a4)" : undefined,
color: disabled || isEmpty ? "var(--accent-8)" : undefined,
disabled || isEmpty || isOffline
? "var(--accent-a4)"
: undefined,
color:
disabled || isEmpty || isOffline
? "var(--accent-8)"
: undefined,
}}
>
<ArrowUp size={14} weight="bold" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface EditorContext {
disabled: boolean;
isLoading: boolean;
isCloud: boolean;
isOffline: boolean;
}

interface DraftState {
Expand Down Expand Up @@ -71,6 +72,7 @@ export const useDraftStore = create<DraftStore>()(
disabled: context.disabled ?? existing?.disabled ?? false,
isLoading: context.isLoading ?? existing?.isLoading ?? false,
isCloud: context.isCloud ?? existing?.isCloud ?? false,
isOffline: context.isOffline ?? existing?.isOffline ?? false,
};
}),

Expand Down
Loading
Loading