diff --git a/apps/array/src/main/di/container.ts b/apps/array/src/main/di/container.ts index 1c7ba0e1..563d17e6 100644 --- a/apps/array/src/main/di/container.ts +++ b/apps/array/src/main/di/container.ts @@ -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"; @@ -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); diff --git a/apps/array/src/main/di/tokens.ts b/apps/array/src/main/di/tokens.ts index b6822ba7..a2b96207 100644 --- a/apps/array/src/main/di/tokens.ts +++ b/apps/array/src/main/di/tokens.ts @@ -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"), diff --git a/apps/array/src/main/services/connectivity/schemas.ts b/apps/array/src/main/services/connectivity/schemas.ts new file mode 100644 index 00000000..e494b2bf --- /dev/null +++ b/apps/array/src/main/services/connectivity/schemas.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const connectivityStatusOutput = z.object({ + isOnline: z.boolean(), +}); + +export type ConnectivityStatusOutput = z.infer; + +export const ConnectivityEvent = { + StatusChange: "status-change", +} as const; + +export interface ConnectivityEvents { + [ConnectivityEvent.StatusChange]: ConnectivityStatusOutput; +} diff --git a/apps/array/src/main/services/connectivity/service.ts b/apps/array/src/main/services/connectivity/service.ts new file mode 100644 index 00000000..2171ef12 --- /dev/null +++ b/apps/array/src/main/services/connectivity/service.ts @@ -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 { + private isOnline = false; + private pollTimeoutId: ReturnType | 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 { + 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 { + if (!net.isOnline()) { + this.setOnline(false); + return; + } + + if (!this.isOnline) { + const verified = await this.verifyWithHttp(); + this.setOnline(verified); + } + } + + private async verifyWithHttp(): Promise { + 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); + } +} diff --git a/apps/array/src/main/trpc/router.ts b/apps/array/src/main/trpc/router.ts index cc19c226..4cd3b58b 100644 --- a/apps/array/src/main/trpc/router.ts +++ b/apps/array/src/main/trpc/router.ts @@ -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"; @@ -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, diff --git a/apps/array/src/main/trpc/routers/connectivity.ts b/apps/array/src/main/trpc/routers/connectivity.ts new file mode 100644 index 00000000..f8f6522e --- /dev/null +++ b/apps/array/src/main/trpc/routers/connectivity.ts @@ -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(MAIN_TOKENS.ConnectivityService); + +function subscribe(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), +}); diff --git a/apps/array/src/renderer/App.tsx b/apps/array/src/renderer/App.tsx index c4595084..f69e1c6a 100644 --- a/apps/array/src/renderer/App.tsx +++ b/apps/array/src/renderer/App.tsx @@ -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"; @@ -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, { diff --git a/apps/array/src/renderer/components/ConnectivityPrompt.tsx b/apps/array/src/renderer/components/ConnectivityPrompt.tsx new file mode 100644 index 00000000..2fe8669f --- /dev/null +++ b/apps/array/src/renderer/components/ConnectivityPrompt.tsx @@ -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 ( + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + + + + No internet connection + + + + Array requires an internet connection to use AI features. Check + your connection and try again. + + + + + + + + + + ); +} diff --git a/apps/array/src/renderer/components/MainLayout.tsx b/apps/array/src/renderer/components/MainLayout.tsx index 11a8aad4..d716c292 100644 --- a/apps/array/src/renderer/components/MainLayout.tsx +++ b/apps/array/src/renderer/components/MainLayout.tsx @@ -1,3 +1,4 @@ +import { ConnectivityPrompt } from "@components/ConnectivityPrompt"; import { HeaderRow } from "@components/HeaderRow"; import { KeyboardShortcutsSheet } from "@components/KeyboardShortcutsSheet"; import { StatusBar } from "@components/StatusBar"; @@ -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"; @@ -28,6 +30,7 @@ export function MainLayout() { close: closeShortcutsSheet, } = useShortcutsSheetStore(); const { data: tasks } = useTasks(); + const { showPrompt, isChecking, check, dismiss } = useConnectivity(); useIntegrations(); useTaskDeepLink(); @@ -75,6 +78,12 @@ export function MainLayout() { onOpenChange={(open) => (open ? null : closeShortcutsSheet())} /> + ( const isCloud = context?.isCloud ?? false; const repoPath = context?.repoPath; + const isOffline = useConnectivityStore((s) => !s.isOnline); + const { editor, isEmpty, @@ -147,7 +150,11 @@ export const MessageEditor = forwardRef( ) : ( ( 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, }} > diff --git a/apps/array/src/renderer/features/message-editor/stores/draftStore.ts b/apps/array/src/renderer/features/message-editor/stores/draftStore.ts index e33c201b..2161b115 100644 --- a/apps/array/src/renderer/features/message-editor/stores/draftStore.ts +++ b/apps/array/src/renderer/features/message-editor/stores/draftStore.ts @@ -13,6 +13,7 @@ export interface EditorContext { disabled: boolean; isLoading: boolean; isCloud: boolean; + isOffline: boolean; } interface DraftState { @@ -71,6 +72,7 @@ export const useDraftStore = create()( disabled: context.disabled ?? existing?.disabled ?? false, isLoading: context.isLoading ?? existing?.isLoading ?? false, isCloud: context.isCloud ?? existing?.isCloud ?? false, + isOffline: context.isOffline ?? existing?.isOffline ?? false, }; }), diff --git a/apps/array/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts b/apps/array/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts index 3486e304..25c4c81a 100644 --- a/apps/array/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/apps/array/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts @@ -1,4 +1,5 @@ import { toast } from "@renderer/utils/toast"; +import { useConnectivityStore } from "@stores/connectivityStore"; import { useEditor } from "@tiptap/react"; import { useCallback, useRef, useState } from "react"; import { usePromptHistoryStore } from "../stores/promptHistoryStore"; @@ -45,6 +46,8 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { onEmptyChange, } = options; + const isOffline = useConnectivityStore((s) => !s.isOnline); + const { fileMentions = true, commands = true, @@ -215,6 +218,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const submit = useCallback(() => { if (!editor) return; + if (isOffline) return; const text = editor.getText().trim(); if (!text) return; @@ -234,7 +238,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { editor.commands.clearContent(); prevBashModeRef.current = false; draft.clearDraft(); - }, [editor, isCloud, draft]); + }, [editor, isCloud, isOffline, draft]); submitRef.current = submit; diff --git a/apps/array/src/renderer/features/sessions/components/SessionView.tsx b/apps/array/src/renderer/features/sessions/components/SessionView.tsx index d1b4222c..1deb78f5 100644 --- a/apps/array/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/array/src/renderer/features/sessions/components/SessionView.tsx @@ -28,6 +28,7 @@ interface SessionViewProps { repoPath?: string | null; isCloud?: boolean; hasError?: boolean; + isOffline?: boolean; } export function SessionView({ @@ -41,6 +42,7 @@ export function SessionView({ repoPath, isCloud = false, hasError = false, + isOffline = false, }: SessionViewProps) { const showRawLogs = useShowRawLogs(); const { setShowRawLogs } = useSessionViewActions(); @@ -53,6 +55,7 @@ export function SessionView({ disabled: !isRunning, isLoading: isPromptPending, isCloud, + isOffline, }); useHotkeys("escape", onCancelPrompt, { enabled: isPromptPending }, [ diff --git a/apps/array/src/renderer/features/sessions/stores/sessionStore.ts b/apps/array/src/renderer/features/sessions/stores/sessionStore.ts index 23187330..f83d08ea 100644 --- a/apps/array/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/array/src/renderer/features/sessions/stores/sessionStore.ts @@ -459,7 +459,11 @@ const useStore = create()( }); if (result) { - updateSession(taskRunId, { status: "connected" }); + // Set isPromptPending true - the resumed agent may still be generating + updateSession(taskRunId, { + status: "connected", + isPromptPending: true, + }); } else { unsubscribeFromChannel(taskRunId); removeSession(taskRunId); @@ -894,3 +898,36 @@ useAuthStore.subscribe( } }, ); + +// Listen for connectivity changes via the connectivity store +// This runs after the store is initialized in App.tsx +import("@renderer/stores/connectivityStore").then( + ({ useConnectivityStore }) => { + useConnectivityStore.subscribe( + (state) => state.isOnline, + (isOnline, wasOnline) => { + if (!isOnline && wasOnline) { + // Going offline - mark sessions as disconnected + log.info("Connectivity lost - marking sessions as disconnected"); + + useStore.setState((state) => { + for (const session of Object.values(state.sessions)) { + if ( + session.status === "connected" || + session.status === "connecting" + ) { + session.status = "disconnected"; + session.isPromptPending = false; + } + } + }); + } else if (isOnline && !wasOnline) { + // Coming back online - log the event + // TaskLogsPanel will detect disconnected sessions and reconnect them + // via its useEffect that depends on isOnline and session status + log.info("Connectivity restored - sessions can be reconnected"); + } + }, + ); + }, +); diff --git a/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx index 56cfb5b6..69458ca9 100644 --- a/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx @@ -1,6 +1,7 @@ import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { useConnectivity } from "@hooks/useConnectivity"; import { useRepositoryIntegration } from "@hooks/useIntegrations"; import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; import { Flex } from "@radix-ui/themes"; @@ -39,6 +40,7 @@ export function TaskInput() { const [editorIsEmpty, setEditorIsEmpty] = useState(true); const { githubIntegration } = useRepositoryIntegration(); + const { isOnline } = useConnectivity(); useEffect(() => { if (view.folderId) { @@ -143,6 +145,7 @@ export function TaskInput() { onSubmit={handleSubmit} hasDirectory={!!selectedDirectory} onEmptyChange={setEditorIsEmpty} + isOffline={!isOnline} /> diff --git a/apps/array/src/renderer/features/task-detail/components/TaskInputEditor.tsx b/apps/array/src/renderer/features/task-detail/components/TaskInputEditor.tsx index fd95261c..9c8d87f6 100644 --- a/apps/array/src/renderer/features/task-detail/components/TaskInputEditor.tsx +++ b/apps/array/src/renderer/features/task-detail/components/TaskInputEditor.tsx @@ -23,6 +23,7 @@ interface TaskInputEditorProps { onSubmit: () => void; hasDirectory: boolean; onEmptyChange?: (isEmpty: boolean) => void; + isOffline?: boolean; } export const TaskInputEditor = forwardRef< @@ -41,6 +42,7 @@ export const TaskInputEditor = forwardRef< onSubmit, hasDirectory, onEmptyChange, + isOffline = false, }, ref, ) => { @@ -89,6 +91,7 @@ export const TaskInputEditor = forwardRef< const getSubmitTooltip = () => { if (isCreatingTask) return "Creating task..."; + if (isOffline) return "You're offline"; if (isEmpty) return "Enter a task description"; if (!hasDirectory) return "Select a folder first"; if (!canSubmit) return "Missing required fields"; diff --git a/apps/array/src/renderer/features/task-detail/components/TaskLogsPanel.tsx b/apps/array/src/renderer/features/task-detail/components/TaskLogsPanel.tsx index 5ca317d2..434a45ab 100644 --- a/apps/array/src/renderer/features/task-detail/components/TaskLogsPanel.tsx +++ b/apps/array/src/renderer/features/task-detail/components/TaskLogsPanel.tsx @@ -12,6 +12,7 @@ import { useWorkspaceStore, } from "@features/workspace/stores/workspaceStore"; import { Box } from "@radix-ui/themes"; +import { useConnectivity } from "@renderer/hooks/useConnectivity"; import { logger } from "@renderer/lib/logger"; import { useNavigationStore } from "@renderer/stores/navigationStore"; import { trpcVanilla } from "@renderer/trpc/client"; @@ -34,6 +35,7 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) { const { connectToTask, sendPrompt, cancelPrompt } = useSessionActions(); const markActivity = useTaskViewedStore((state) => state.markActivity); const markAsViewed = useTaskViewedStore((state) => state.markAsViewed); + const { isOnline } = useConnectivity(); const isRunning = session?.status === "connected" || session?.status === "connecting"; @@ -47,6 +49,7 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) { useEffect(() => { if (!repoPath) return; if (isConnecting.current) return; + if (!isOnline) return; // Don't reconnect if already connected, connecting, or in error state if ( @@ -81,7 +84,7 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) { }).finally(() => { isConnecting.current = false; }); - }, [task, repoPath, session, connectToTask, markActivity]); + }, [task, repoPath, session, connectToTask, markActivity, isOnline]); const handleSendPrompt = useCallback( async (text: string) => { @@ -151,6 +154,7 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) { repoPath={repoPath} isCloud={session?.isCloud ?? false} hasError={hasError} + isOffline={!isOnline} /> diff --git a/apps/array/src/renderer/features/task-detail/hooks/useTaskCreation.ts b/apps/array/src/renderer/features/task-detail/hooks/useTaskCreation.ts index edbf5da5..ea5879ce 100644 --- a/apps/array/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ b/apps/array/src/renderer/features/task-detail/hooks/useTaskCreation.ts @@ -3,6 +3,7 @@ import type { MessageEditorHandle } from "@features/message-editor/components/Me import type { EditorContent } from "@features/message-editor/utils/content"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { useCreateTask } from "@features/tasks/hooks/useTasks"; +import { useConnectivity } from "@hooks/useConnectivity"; import { get } from "@renderer/di/container"; import { RENDERER_TOKENS } from "@renderer/di/tokens"; import { logger } from "@renderer/lib/logger"; @@ -119,11 +120,13 @@ export function useTaskCreation({ const { isAuthenticated } = useAuthStore(); const { autoRunTasks } = useSettingsStore(); const { invalidateTasks } = useCreateTask(); + const { isOnline } = useConnectivity(); const isCloudMode = workspaceMode === "cloud"; const canSubmit = !!editorRef.current && isAuthenticated && + isOnline && (isCloudMode ? !!selectedRepository : !!selectedDirectory) && !isCreatingTask && !editorIsEmpty; diff --git a/apps/array/src/renderer/hooks/useConnectivity.ts b/apps/array/src/renderer/hooks/useConnectivity.ts new file mode 100644 index 00000000..dfce2ff5 --- /dev/null +++ b/apps/array/src/renderer/hooks/useConnectivity.ts @@ -0,0 +1,11 @@ +import { useConnectivityStore } from "@stores/connectivityStore"; + +export function useConnectivity() { + const isOnline = useConnectivityStore((s) => s.isOnline); + const isChecking = useConnectivityStore((s) => s.isChecking); + const showPrompt = useConnectivityStore((s) => s.showPrompt); + const check = useConnectivityStore((s) => s.check); + const dismiss = useConnectivityStore((s) => s.dismissPrompt); + + return { isOnline, isChecking, showPrompt, check, dismiss }; +} diff --git a/apps/array/src/renderer/stores/connectivityStore.ts b/apps/array/src/renderer/stores/connectivityStore.ts new file mode 100644 index 00000000..6b2f4d83 --- /dev/null +++ b/apps/array/src/renderer/stores/connectivityStore.ts @@ -0,0 +1,105 @@ +import { logger } from "@renderer/lib/logger"; +import { trpcVanilla } from "@renderer/trpc/client"; +import { create } from "zustand"; +import { subscribeWithSelector } from "zustand/middleware"; + +const log = logger.scope("connectivity-store"); + +interface ConnectivityState { + isOnline: boolean; + isChecking: boolean; + showPrompt: boolean; + + // Actions + setOnline: (isOnline: boolean) => void; + check: () => Promise; + dismissPrompt: () => void; +} + +export const useConnectivityStore = create()( + subscribeWithSelector((set, get) => ({ + isOnline: true, // Assume online initially + isChecking: false, + showPrompt: false, + + setOnline: (isOnline: boolean) => { + const wasOnline = get().isOnline; + + // Show prompt when transitioning online -> offline + if (wasOnline && !isOnline) { + set({ isOnline, showPrompt: true }); + } else if (isOnline) { + // Auto-dismiss when back online + set({ isOnline, showPrompt: false }); + } else { + set({ isOnline }); + } + }, + + check: async () => { + set({ isChecking: true }); + try { + const result = await trpcVanilla.connectivity.checkNow.mutate(); + const wasOnline = get().isOnline; + + if (wasOnline && !result.isOnline) { + set({ + isOnline: result.isOnline, + showPrompt: true, + isChecking: false, + }); + } else if (result.isOnline) { + set({ + isOnline: result.isOnline, + showPrompt: false, + isChecking: false, + }); + } else { + set({ isOnline: result.isOnline, isChecking: false }); + } + } catch (error) { + log.error("Failed to check connectivity", { error }); + set({ isChecking: false }); + } + }, + + dismissPrompt: () => { + set({ showPrompt: false }); + }, + })), +); + +// Initialize: fetch initial status and subscribe to changes +export function initializeConnectivityStore() { + // Get initial status + trpcVanilla.connectivity.getStatus + .query() + .then((status) => { + useConnectivityStore.getState().setOnline(status.isOnline); + }) + .catch((error) => { + log.error("Failed to get initial connectivity status", { error }); + }); + + // Subscribe to status changes + const subscription = trpcVanilla.connectivity.onStatusChange.subscribe( + undefined, + { + onData: (status) => { + log.info("Connectivity status changed", { isOnline: status.isOnline }); + useConnectivityStore.getState().setOnline(status.isOnline); + }, + onError: (error) => { + log.error("Connectivity subscription error", { error }); + }, + }, + ); + + return () => { + subscription.unsubscribe(); + }; +} + +// Convenience selectors +export const getConnectivityState = () => useConnectivityStore.getState(); +export const getIsOnline = () => useConnectivityStore.getState().isOnline;