From b95bbacd1fb2139f1fbe0e7045c6763c9de089d2 Mon Sep 17 00:00:00 2001 From: Agastya Darma Date: Wed, 15 Apr 2026 07:57:51 +0900 Subject: [PATCH 1/3] feat: add network error detection and automatic retry logic - Detect network errors (ENOTFOUND, ECONNREFUSED, ETIMEDOUT, etc.) separately from auth errors - Implement exponential backoff retry strategy (up to 3 attempts) for network failures - Add retrying event type to communicate retry attempts to UI - Show offline banner and disable network operations when no connectivity - Add useOnlineStatus hook for online/offline state management --- .../__tests__/errorClassifier.test.ts | 47 ++++++++++- src/main/services/agentTypes.ts | 3 +- src/main/services/agentWorker/core.ts | 69 ++++++++++++++++ .../services/agentWorker/errorClassifier.ts | 34 +++++++- src/renderer/App.tsx | 2 + src/renderer/components/Center/ChatHeader.tsx | 4 +- src/renderer/components/Center/ChatInput.tsx | 16 +++- src/renderer/components/Center/ChatView.tsx | 5 +- .../components/Center/NetworkErrorPrompt.tsx | 52 ++++++++++++ src/renderer/components/Right/ChangesView.tsx | 7 +- .../components/Right/ChecksSections.tsx | 11 ++- src/renderer/components/Right/ChecksView.tsx | 9 ++- src/renderer/components/Right/PushBanner.tsx | 19 +++-- .../components/shared/OfflineBanner.tsx | 79 +++++++++++++++++++ src/renderer/lib/online.ts | 45 ++++++++++- src/renderer/locales/en/center.json | 8 +- src/renderer/locales/en/common.json | 8 +- src/renderer/locales/en/right.json | 3 +- src/renderer/locales/id/center.json | 8 +- src/renderer/locales/id/common.json | 8 +- src/renderer/locales/id/right.json | 3 +- src/renderer/locales/ja/center.json | 8 +- src/renderer/locales/ja/common.json | 8 +- src/renderer/locales/ja/right.json | 3 +- .../sessions/__tests__/draftActions.test.ts | 2 + src/renderer/store/sessions/eventHandler.ts | 7 ++ .../store/sessions/handlers/handleWaiting.ts | 10 +++ .../sessions/handlers/networkErrorActions.ts | 41 ++++++++++ src/renderer/store/sessions/store.ts | 2 + src/renderer/store/sessions/storeTypes.ts | 4 + src/renderer/styles/chat-input.css | 4 + src/renderer/styles/checks.css | 4 + src/renderer/styles/index.css | 1 + src/renderer/styles/offline-banner.css | 65 +++++++++++++++ src/renderer/types/session.ts | 1 + 35 files changed, 562 insertions(+), 38 deletions(-) create mode 100644 src/renderer/components/Center/NetworkErrorPrompt.tsx create mode 100644 src/renderer/components/shared/OfflineBanner.tsx create mode 100644 src/renderer/store/sessions/handlers/networkErrorActions.ts create mode 100644 src/renderer/styles/offline-banner.css diff --git a/src/main/services/__tests__/errorClassifier.test.ts b/src/main/services/__tests__/errorClassifier.test.ts index e2a4e99..0bb537e 100644 --- a/src/main/services/__tests__/errorClassifier.test.ts +++ b/src/main/services/__tests__/errorClassifier.test.ts @@ -31,10 +31,55 @@ describe('classifyError', () => { expect(classifyError('Error: please run /login to authenticate')).toBe('auth') }) + it('detects network errors - ENOTFOUND', () => { + expect(classifyError('getaddrinfo ENOTFOUND api.anthropic.com')).toBe('network') + }) + + it('detects network errors - ECONNREFUSED', () => { + expect(classifyError('connect ECONNREFUSED 127.0.0.1:443')).toBe('network') + }) + + it('detects network errors - ECONNRESET', () => { + expect(classifyError('socket hang up ECONNRESET')).toBe('network') + }) + + it('detects network errors - ETIMEDOUT', () => { + expect(classifyError('connect ETIMEDOUT 104.18.6.224:443')).toBe('network') + }) + + it('detects network errors - ENETUNREACH', () => { + expect(classifyError('connect ENETUNREACH ::1:443')).toBe('network') + }) + + it('detects network errors - socket hang up', () => { + expect(classifyError('socket hang up')).toBe('network') + }) + + it('detects network errors - fetch failed', () => { + expect(classifyError('TypeError: fetch failed')).toBe('network') + }) + + it('detects network errors - Failed to fetch', () => { + expect(classifyError('Failed to fetch')).toBe('network') + }) + + it('detects network errors - request timed out', () => { + expect(classifyError('request timed out')).toBe('network') + }) + + it('detects network errors - EAI_AGAIN', () => { + expect(classifyError('getaddrinfo EAI_AGAIN api.anthropic.com')).toBe('network') + }) + + it('prioritizes network over auth for connection-level failures', () => { + // ECONNREFUSED to auth endpoint is a network issue, not auth + expect(classifyError('connect ECONNREFUSED to auth server')).toBe('network') + }) + it('returns generic for unrelated errors', () => { - expect(classifyError('Network timeout')).toBe('generic') expect(classifyError('SDK import failed')).toBe('generic') expect(classifyError('Session process exited (code 1)')).toBe('generic') + expect(classifyError('Some random error occurred')).toBe('generic') }) }) diff --git a/src/main/services/agentTypes.ts b/src/main/services/agentTypes.ts index 1fc3dd7..7e26050 100644 --- a/src/main/services/agentTypes.ts +++ b/src/main/services/agentTypes.ts @@ -55,7 +55,8 @@ export type WorkerEvent = } | { type: 'slash_commands'; sessionId: string; commands: SlashCommand[] } | { type: 'done'; sessionId: string } - | { type: 'error'; sessionId: string; message: string; errorKind?: 'auth' | 'generic'; authType?: 'oauth' | 'api_key' | 'unknown' } + | { type: 'error'; sessionId: string; message: string; errorKind?: 'auth' | 'network' | 'generic'; authType?: 'oauth' | 'api_key' | 'unknown' } + | { type: 'retrying'; sessionId: string; attempt: number; maxAttempts: number; delayMs: number } | { type: 'waiting_input' sessionId: string diff --git a/src/main/services/agentWorker/core.ts b/src/main/services/agentWorker/core.ts index 2e39c32..b0a5a44 100644 --- a/src/main/services/agentWorker/core.ts +++ b/src/main/services/agentWorker/core.ts @@ -22,6 +22,19 @@ import { createCanUseTool, fetchSlashCommands } from './tools' import { classifyError, classifyAuthType } from './errorClassifier' import type { SessionState, SlashCommand, WorkerEvent, AgentSettings } from '../agentTypes' +// ── Network retry constants ────────────────────────────────────────────────── +const MAX_NETWORK_RETRIES = 3 +const RETRY_BASE_MS = 2000 + +/** Sleep that respects an AbortSignal. Rejects if aborted. */ +function abortableSleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { reject(new Error('Aborted')); return } + const timer = setTimeout(resolve, ms) + signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')) }, { once: true }) + }) +} + export class AgentWorker { private sessions = new Map() private pendingUserInput = new Map) => void }>() @@ -238,6 +251,7 @@ export class AgentWorker { } this.log(sessionId, `Done. ${msgCount} messages total. sdkSessionId=${state.sdkSessionId}`) + this.networkRetryCount.delete(sessionId) this.emit({ type: 'done', sessionId }) } catch (err: unknown) { if (abortController.signal.aborted) { @@ -249,6 +263,16 @@ export class AgentWorker { this.log(sessionId, 'ERROR:', message) if (err instanceof Error && err.stack) this.log(sessionId, 'Stack:', err.stack) const errorKind = classifyError(message) + + // Auto-retry on network errors with exponential backoff + if (errorKind === 'network') { + const retried = await this.retryOnNetwork(sessionId, message, abortController.signal) + if (retried) { + await this.startSession(sessionId, worktreeId, projectName, worktreePath, prompt, model, thinking, planMode, sessionName, settings, images, additionalDirectories, linkedWorktreeContext, connectedDeviceId, mobileFramework) + return + } + } + this.emit({ type: 'error', sessionId, message, errorKind, ...(errorKind === 'auth' ? { authType: classifyAuthType(message) } : {}) @@ -388,6 +412,7 @@ export class AgentWorker { } this.log(sessionId, `Resume done. ${msgCount} messages.`) + this.networkRetryCount.delete(sessionId) this.emit({ type: 'done', sessionId }) } catch (err: unknown) { if (abortController.signal.aborted) { @@ -414,6 +439,16 @@ export class AgentWorker { } const errorKind = classifyError(errMsg) + + // Auto-retry on network errors with exponential backoff + if (errorKind === 'network') { + const retried = await this.retryOnNetwork(sessionId, errMsg, abortController.signal) + if (retried) { + await this.sendMessage(sessionId, message, sdkSessionId, cwd, model, planMode, sessionName, settings, images, additionalDirectories, linkedWorktreeContext, connectedDeviceId, mobileFramework) + return + } + } + this.emit({ type: 'error', sessionId, message: errMsg, errorKind, ...(errorKind === 'auth' ? { authType: classifyAuthType(errMsg) } : {}) @@ -421,10 +456,43 @@ export class AgentWorker { } } + /** Per-session retry counter for network errors (reset on success or new message). */ + private networkRetryCount = new Map() + + /** + * Attempt to retry after a network error with exponential backoff. + * Returns true if a retry should proceed, false if retries are exhausted. + */ + private async retryOnNetwork(sessionId: string, errorMsg: string, signal: AbortSignal): Promise { + const count = this.networkRetryCount.get(sessionId) ?? 0 + if (count >= MAX_NETWORK_RETRIES) { + this.log(sessionId, `Network retries exhausted (${MAX_NETWORK_RETRIES})`) + this.networkRetryCount.delete(sessionId) + return false + } + + const attempt = count + 1 + const delay = RETRY_BASE_MS * Math.pow(2, count) + this.log(sessionId, `Network error, retrying ${attempt}/${MAX_NETWORK_RETRIES} in ${delay}ms: ${errorMsg}`) + this.networkRetryCount.set(sessionId, attempt) + this.emit({ type: 'retrying', sessionId, attempt, maxAttempts: MAX_NETWORK_RETRIES, delayMs: delay }) + + try { + await abortableSleep(delay, signal) + } catch { + // Aborted during sleep - user stopped the session + this.networkRetryCount.delete(sessionId) + return false + } + + return true + } + async stopSession(sessionId: string): Promise { this.log(sessionId, 'stopSession') this.pendingUserInput.delete(sessionId) this.pendingElicitation.delete(sessionId) + this.networkRetryCount.delete(sessionId) const state = this.sessions.get(sessionId) if (state?.abortController) { state.abortController.abort() @@ -436,6 +504,7 @@ export class AgentWorker { this.log(sessionId, 'closeSession') this.pendingUserInput.delete(sessionId) this.pendingElicitation.delete(sessionId) + this.networkRetryCount.delete(sessionId) const state = this.sessions.get(sessionId) if (state?.abortController) state.abortController.abort() this.sessions.delete(sessionId) diff --git a/src/main/services/agentWorker/errorClassifier.ts b/src/main/services/agentWorker/errorClassifier.ts index c8326d6..e49fa21 100644 --- a/src/main/services/agentWorker/errorClassifier.ts +++ b/src/main/services/agentWorker/errorClassifier.ts @@ -1,10 +1,30 @@ // --------------------------------------------------------------------------- -// Error classification — detects auth errors from SDK error messages +// Error classification — detects auth, network, and generic errors from SDK +// error messages // --------------------------------------------------------------------------- -export type ErrorKind = 'auth' | 'generic' +export type ErrorKind = 'auth' | 'network' | 'generic' export type AuthErrorType = 'oauth' | 'api_key' | 'unknown' +const NETWORK_PATTERNS = [ + /ENOTFOUND/i, + /ECONNREFUSED/i, + /ECONNRESET/i, + /ETIMEDOUT/i, + /ENETUNREACH/i, + /EHOSTUNREACH/i, + /EHOSTDOWN/i, + /socket hang up/i, + /network.*error/i, + /fetch failed/i, + /Failed to fetch/i, + /getaddrinfo/i, + /connect EHOSTDOWN/i, + /request.*timed?\s*out/i, + /EPIPE/i, + /EAI_AGAIN/i, +] + const AUTH_PATTERNS = [ /authentication_error/i, /API Error: 401/, @@ -29,9 +49,15 @@ const API_KEY_PATTERNS = [ /API key/i, ] -/** Classify whether an error is an authentication failure. */ +/** + * Classify an error as auth, network, or generic. + * Network patterns are checked first since some network errors + * (e.g. ECONNREFUSED to auth endpoint) should be treated as connectivity issues. + */ export function classifyError(message: string): ErrorKind { - return AUTH_PATTERNS.some((p) => p.test(message)) ? 'auth' : 'generic' + if (NETWORK_PATTERNS.some((p) => p.test(message))) return 'network' + if (AUTH_PATTERNS.some((p) => p.test(message))) return 'auth' + return 'generic' } /** For auth errors, determine whether it's OAuth, API key, or unknown. */ diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0924c0b..2c01b3e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -19,6 +19,7 @@ import { MissionControl } from '@/components/MissionControl/MissionControl' import { WebAppOverlay } from '@/components/Center/WebAppOverlay' import { ToastContainer } from '@/components/shared/ToastContainer' import { FlashToastContainer } from '@/components/shared/FlashToastContainer' +import { OfflineBanner } from '@/components/shared/OfflineBanner' import { OnboardingOverlay } from '@/components/Onboarding/OnboardingOverlay' import { FeatureTour } from '@/components/Onboarding/FeatureTour' import { SimulatorTour } from '@/components/Onboarding/SimulatorTour' @@ -274,6 +275,7 @@ export default function App() { return ( +
diff --git a/src/renderer/components/Center/ChatHeader.tsx b/src/renderer/components/Center/ChatHeader.tsx index 5d4d20d..207f4f1 100644 --- a/src/renderer/components/Center/ChatHeader.tsx +++ b/src/renderer/components/Center/ChatHeader.tsx @@ -16,6 +16,7 @@ import { IconImage, IconClock, IconArrowUp, } from '@/components/shared/icons' import { useTranslation } from 'react-i18next' +import { useOnlineStatus } from '@/lib/online' export type ChatHeaderVariant = 'default' | 'diff' @@ -42,6 +43,7 @@ export function ChatHeader({ onSend, onAddImages, variant = 'default', }: ChatHeaderProps) { const { t } = useTranslation('center') + const online = useOnlineStatus() const updateModel = useSessionsStore((s) => s.updateModel) const updateThinking = useSessionsStore((s) => s.updateThinking) const updatePlanMode = useSessionsStore((s) => s.updatePlanMode) @@ -146,7 +148,7 @@ export function ChatHeader({ {/* Send / Queue button */}
- +
- {!(isRunning && queuedMessage !== null) && !isWaitingInput && !input && ( + {!online && !isRunning && !isWaitingInput && ( + {t('offlineHint')} + )} + {online && !(isRunning && queuedMessage !== null) && !isWaitingInput && !input && ( {t('focusHint')} )}
diff --git a/src/renderer/components/Center/ChatView.tsx b/src/renderer/components/Center/ChatView.tsx index 56f73cc..868dbbf 100644 --- a/src/renderer/components/Center/ChatView.tsx +++ b/src/renderer/components/Center/ChatView.tsx @@ -25,6 +25,7 @@ import { Spinner } from '@/components/ui' import { flash } from '@/store/flash' import { useTranslation } from 'react-i18next' import { useMentionAutocomplete } from './useMentionAutocomplete' +import { useOnlineStatus } from '@/lib/online' import { BranchBar } from './BranchBar' const DiffReviewView = lazy(() => import('./DiffReviewView').then((m) => ({ default: m.DiffReviewView }))) @@ -141,6 +142,7 @@ export function ChatView({ worktreePath = '' }: ChatViewProps) { ) const { t } = useTranslation('center') + const online = useOnlineStatus() const setDraftInputForMention = useCallback((value: string) => { if (activeSession) setDraftInput(activeSession.id, value) @@ -354,7 +356,8 @@ export function ChatView({ worktreePath = '' }: ChatViewProps) { ) } - const canSend = (input.trim().length > 0 || attachedImages.length > 0 || mention.attachedFiles.length > 0 || diffCommentCount > 0) && + const canSend = online && + (input.trim().length > 0 || attachedImages.length > 0 || mention.attachedFiles.length > 0 || diffCommentCount > 0) && !(isRunning && queuedMessage !== null) && !isWaitingInput return ( diff --git a/src/renderer/components/Center/NetworkErrorPrompt.tsx b/src/renderer/components/Center/NetworkErrorPrompt.tsx new file mode 100644 index 0000000..966fc26 --- /dev/null +++ b/src/renderer/components/Center/NetworkErrorPrompt.tsx @@ -0,0 +1,52 @@ +/** + * NetworkErrorPrompt - shown in chat input when a network error occurs. + * + * Offers retry and dismiss actions, similar to AuthErrorPrompt. + */ + +import { useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { useOnlineStatus } from '@/lib/online' +import { IconGlobe } from '@/components/shared/icons' + +interface Props { + onRetry: () => void + onDismiss: () => void +} + +export function NetworkErrorPrompt({ onRetry, onDismiss }: Props) { + const { t } = useTranslation('center') + const promptRef = useRef(null) + const online = useOnlineStatus() + + useEffect(() => { + promptRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + }, []) + + return ( +
+
+ + {t('networkError')} +
+ +
+

{t('networkErrorMessage')}

+
+ +
+ + +
+
+ ) +} diff --git a/src/renderer/components/Right/ChangesView.tsx b/src/renderer/components/Right/ChangesView.tsx index 466a5ae..ed27ce7 100644 --- a/src/renderer/components/Right/ChangesView.tsx +++ b/src/renderer/components/Right/ChangesView.tsx @@ -11,7 +11,7 @@ import { PushBanner } from './PushBanner' import { ChangeFileList } from './ChangeFileList' import { useChangesActions } from './useChangesActions' import { useUIStore } from '@/store/ui' -import { isOnline, onOnline } from '@/lib/online' +import { isOnline, onOnline, useOnlineStatus } from '@/lib/online' interface Props { worktreePath: string @@ -19,6 +19,7 @@ interface Props { export function ChangesView({ worktreePath }: Props) { const { t } = useTranslation('right') + const online = useOnlineStatus() const [state, dispatch] = useReducer(changesReducer, initialState, (base) => ({ ...base, ...restoreCommitDraft(worktreePath), @@ -110,11 +111,11 @@ export function ChangesView({ worktreePath }: Props) {
{showPullBtn && ( - + diff --git a/src/renderer/components/Right/ChecksSections.tsx b/src/renderer/components/Right/ChecksSections.tsx index e563a7b..82b55ae 100644 --- a/src/renderer/components/Right/ChecksSections.tsx +++ b/src/renderer/components/Right/ChecksSections.tsx @@ -16,6 +16,7 @@ import { IconCheckCircle, IconXCircleStatus, IconSkipCircle, IconSpinner, IconDeployment, } from '@/components/shared/icons' import { SectionHeader, StatusDot } from '@/components/ui' +import { useOnlineStatus } from '@/lib/online' import { JiraSection } from './JiraSection' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -121,6 +122,7 @@ export function GitStatusSection({ markingReady: boolean; onMarkReady: () => void }) { const { t } = useTranslation('right') + const online = useOnlineStatus() const rows: Array<{ label: string @@ -132,7 +134,7 @@ export function GitStatusSection({ label: pr.isDraft ? t('prIsDraft') : t('readyForReview'), state: pr.isDraft ? 'pending' : 'success', action: pr.isDraft - ? { label: markingReady ? t('markingReady') : t('markReady'), onClick: onMarkReady, variant: 'primary', disabled: markingReady } + ? { label: markingReady ? t('markingReady') : t('markReady'), onClick: onMarkReady, variant: 'primary', disabled: !online || markingReady } : undefined, }) @@ -148,7 +150,7 @@ export function GitStatusSection({ rows.push({ label: t('commitsBehind', { count: sync.behindCount, baseBranch: sync.baseBranch ?? 'main' }), state: 'pending', - action: { label: pulling ? t('pulling') : t('pull'), onClick: onPull, disabled: pulling }, + action: { label: pulling ? t('pulling') : t('pull'), onClick: onPull, disabled: !online || pulling }, }) } @@ -156,7 +158,7 @@ export function GitStatusSection({ rows.push({ label: t('commitsAhead', { count: sync.aheadCount, baseBranch: sync.baseBranch ?? 'main' }), state: 'pending', - action: { label: pushing ? t('pushing') : t('push'), onClick: onPush, disabled: pushing }, + action: { label: pushing ? t('pushing') : t('push'), onClick: onPush, disabled: !online || pushing }, }) } @@ -338,6 +340,7 @@ export function ChecksNoPr({ creatingPr, onCreatePr, jiraResult }: { jiraResult: JiraResult | null | 'error' }) { const { t } = useTranslation('right') + const online = useOnlineStatus() return (
@@ -348,7 +351,7 @@ export function ChecksNoPr({ creatingPr, onCreatePr, jiraResult }: { label={creatingPr ? t('creatingPr') : t('createPr')} onClick={onCreatePr} variant="primary" - disabled={creatingPr} + disabled={!online || creatingPr} />
diff --git a/src/renderer/components/Right/ChecksView.tsx b/src/renderer/components/Right/ChecksView.tsx index 476c9a0..95bb969 100644 --- a/src/renderer/components/Right/ChecksView.tsx +++ b/src/renderer/components/Right/ChecksView.tsx @@ -18,7 +18,7 @@ import { usePrCacheStore, type PrStatus } from '@/store/prCache' import { useProjectsStore } from '@/store/projects' import { IconRefresh } from '@/components/shared/icons' import { DEFAULT_PR_PROMPT } from '@/lib/prPrompt' -import { isOnline, onOnline } from '@/lib/online' +import { isOnline, onOnline, useOnlineStatus } from '@/lib/online' import { JiraSection } from './JiraSection' import { PushErrorPanel } from './PushErrorPanel' import { @@ -132,6 +132,7 @@ export function ChecksView({ worktreePath, worktreeId, isActive = true }: Props) const lastOwnFetchRef = useRef(0) const { t } = useTranslation('right') + const online = useOnlineStatus() // Current upstream tracking branch used as the sync-status base branch const upstream = useProjectsStore((s) => { @@ -407,8 +408,10 @@ export function ChecksView({ worktreePath, worktreeId, isActive = true }: Props) {lastUpdated && (
- - {t('updatedAt', { time: lastUpdated.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) })} + + {!online + ? t('cachedData', { ns: 'common' }) + : t('updatedAt', { time: lastUpdated.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) })}
)} diff --git a/src/renderer/components/Right/PushBanner.tsx b/src/renderer/components/Right/PushBanner.tsx index e0b0e0e..aa7debf 100644 --- a/src/renderer/components/Right/PushBanner.tsx +++ b/src/renderer/components/Right/PushBanner.tsx @@ -1,4 +1,6 @@ import { useTranslation } from 'react-i18next' +import { useOnlineStatus } from '@/lib/online' +import { Tooltip } from '@/components/shared/Tooltip' import { PushErrorPanel } from './PushErrorPanel' interface PushBannerProps { @@ -12,6 +14,7 @@ interface PushBannerProps { export function PushBanner({ aheadCount, upstream, pushState, onPush, errorMessage, onDismissError }: PushBannerProps) { const { t } = useTranslation('right') + const online = useOnlineStatus() const pushLabel = pushState === 'pushing' ? t('pushing') : @@ -31,13 +34,15 @@ export function PushBanner({ aheadCount, upstream, pushState, onPush, errorMessa {t('commitsAhead', { count: aheadCount, baseBranch: upstream ?? 'origin' })} - + + +
{errorMessage && onDismissError && ( diff --git a/src/renderer/components/shared/OfflineBanner.tsx b/src/renderer/components/shared/OfflineBanner.tsx new file mode 100644 index 0000000..a896d82 --- /dev/null +++ b/src/renderer/components/shared/OfflineBanner.tsx @@ -0,0 +1,79 @@ +/** + * OfflineBanner - persistent banner shown when the app has no internet. + * + * Shows an amber warning bar at the top of the viewport. When connectivity + * is restored, briefly shows "Back online" for 2s before sliding away. + */ + +import { useReducer, useEffect } from 'react' +import { useOnlineStatus } from '@/lib/online' +import { useTranslation } from 'react-i18next' + +type Phase = 'hidden' | 'offline' | 'reconnected' | 'dismissing' +type Action = { type: 'went_offline' } | { type: 'went_online' } | { type: 'dismiss' } + +function reducer(state: Phase, action: Action): Phase { + switch (action.type) { + case 'went_offline': return 'offline' + case 'went_online': return state === 'offline' ? 'reconnected' : state + case 'dismiss': return 'dismissing' + default: return state + } +} + +export function OfflineBanner() { + const { t } = useTranslation('common') + const online = useOnlineStatus() + const [phase, dispatch] = useReducer(reducer, 'hidden') + + // Track online/offline transitions + useEffect(() => { + if (!online) { + dispatch({ type: 'went_offline' }) + } else if (online) { + dispatch({ type: 'went_online' }) + } + }, [online]) + + // Auto-dismiss after showing "reconnected" for 2s + useEffect(() => { + if (phase !== 'reconnected') return + const timer = setTimeout(() => dispatch({ type: 'dismiss' }), 2000) + return () => clearTimeout(timer) + }, [phase]) + + // Clean up dismissing state after animation completes + useEffect(() => { + if (phase !== 'dismissing') return + const timer = setTimeout(() => dispatch({ type: 'dismiss' }), 300) + return () => clearTimeout(timer) + }, [phase]) + + if (phase === 'hidden' || (phase === 'dismissing')) { + // Render during dismissing for slide-up animation + if (phase === 'dismissing') { + return ( +
+ + {t('offline.reconnected')} +
+ ) + } + return null + } + + const isReconnected = phase === 'reconnected' + + return ( +
+ {isReconnected ? '\u2713' : '\u26A0'} + + {isReconnected ? t('offline.reconnected') : t('offline.banner')} + +
+ ) +} diff --git a/src/renderer/lib/online.ts b/src/renderer/lib/online.ts index 60ca6a3..28751eb 100644 --- a/src/renderer/lib/online.ts +++ b/src/renderer/lib/online.ts @@ -5,21 +5,34 @@ * can skip IPC calls that would spawn `gh`/`acli` processes only to * timeout after 15s. * - * Also provides `onOnline()` to register callbacks that fire when - * connectivity is restored (e.g. trigger an immediate refresh). + * Provides `onOnline()` / `onOffline()` to register callbacks that fire + * on connectivity transitions, and `useOnlineStatus()` for reactive React UI. */ +import { useSyncExternalStore } from 'react' + let _online = typeof navigator !== 'undefined' ? navigator.onLine : true const _onlineCallbacks = new Set<() => void>() +const _offlineCallbacks = new Set<() => void>() + +/** Subscribers for useSyncExternalStore */ +const _storeListeners = new Set<() => void>() + +function notifyStoreListeners(): void { + for (const cb of _storeListeners) cb() +} if (typeof window !== 'undefined') { window.addEventListener('online', () => { _online = true + notifyStoreListeners() for (const cb of _onlineCallbacks) cb() }) window.addEventListener('offline', () => { _online = false + notifyStoreListeners() + for (const cb of _offlineCallbacks) cb() }) } @@ -29,10 +42,36 @@ export function isOnline(): boolean { } /** - * Register a callback that fires when the browser transitions from offline → online. + * Register a callback that fires when the browser transitions from offline -> online. * Returns an unsubscribe function. */ export function onOnline(cb: () => void): () => void { _onlineCallbacks.add(cb) return () => { _onlineCallbacks.delete(cb) } } + +/** + * Register a callback that fires when the browser transitions from online -> offline. + * Returns an unsubscribe function. + */ +export function onOffline(cb: () => void): () => void { + _offlineCallbacks.add(cb) + return () => { _offlineCallbacks.delete(cb) } +} + +function subscribeOnlineStore(listener: () => void): () => void { + _storeListeners.add(listener) + return () => { _storeListeners.delete(listener) } +} + +function getOnlineSnapshot(): boolean { + return _online +} + +/** + * React hook that returns the current online status and re-renders on change. + * Uses `useSyncExternalStore` for tear-free reads. + */ +export function useOnlineStatus(): boolean { + return useSyncExternalStore(subscribeOnlineStore, getOnlineSnapshot, () => true) +} diff --git a/src/renderer/locales/en/center.json b/src/renderer/locales/en/center.json index 8ea5998..cb8e97d 100644 --- a/src/renderer/locales/en/center.json +++ b/src/renderer/locales/en/center.json @@ -312,5 +312,11 @@ "toolSummary.todo_other": "{{count}} todos", "turnFooterFiles_one": "1 file changed", - "turnFooterFiles_other": "{{count}} files changed" + "turnFooterFiles_other": "{{count}} files changed", + + "offlineHint": "You're offline", + "offlineSendDisabled": "Can't send messages while offline", + "networkError": "Connection lost", + "networkErrorMessage": "Check your internet connection and try again.", + "networkRetry": "Retry" } diff --git a/src/renderer/locales/en/common.json b/src/renderer/locales/en/common.json index 3857bbf..1999160 100644 --- a/src/renderer/locales/en/common.json +++ b/src/renderer/locales/en/common.json @@ -142,5 +142,11 @@ "description": "Tap Boot to start a device, then Connect to stream its screen. You can also link it to your chat so Claude can see the UI." } } - } + }, + "offline": { + "banner": "You're offline - some features are unavailable", + "reconnected": "Back online", + "tooltip": "No internet connection" + }, + "cachedData": "Offline - showing cached data" } diff --git a/src/renderer/locales/en/right.json b/src/renderer/locales/en/right.json index 0216fd7..0454298 100644 --- a/src/renderer/locales/en/right.json +++ b/src/renderer/locales/en/right.json @@ -306,5 +306,6 @@ "quickOpen": "Quick Open", "quickOpenPlaceholder": "Search files by name...", "quickOpenNoResults": "No matching files", - "quickOpenOpen": "Open" + "quickOpenOpen": "Open", + "offlineDisabled": "Unavailable while offline" } diff --git a/src/renderer/locales/id/center.json b/src/renderer/locales/id/center.json index f9dba2a..ab7ead1 100644 --- a/src/renderer/locales/id/center.json +++ b/src/renderer/locales/id/center.json @@ -294,5 +294,11 @@ "toolSummary.todo": "{{count}} tugas", "turnFooterFiles_one": "1 file diubah", - "turnFooterFiles_other": "{{count}} file diubah" + "turnFooterFiles_other": "{{count}} file diubah", + + "offlineHint": "Anda sedang offline", + "offlineSendDisabled": "Tidak dapat mengirim pesan saat offline", + "networkError": "Koneksi terputus", + "networkErrorMessage": "Periksa koneksi internet Anda dan coba lagi.", + "networkRetry": "Coba lagi" } diff --git a/src/renderer/locales/id/common.json b/src/renderer/locales/id/common.json index 63e71f9..5f1f048 100644 --- a/src/renderer/locales/id/common.json +++ b/src/renderer/locales/id/common.json @@ -142,5 +142,11 @@ "description": "Ketuk Nyalakan untuk memulai perangkat, lalu Hubungkan untuk streaming layarnya. Anda juga bisa menautkannya ke chat agar Claude bisa melihat UI." } } - } + }, + "offline": { + "banner": "Anda sedang offline - beberapa fitur tidak tersedia", + "reconnected": "Kembali online", + "tooltip": "Tidak ada koneksi internet" + }, + "cachedData": "Offline - menampilkan data tersimpan" } diff --git a/src/renderer/locales/id/right.json b/src/renderer/locales/id/right.json index dce1233..bb1bae9 100644 --- a/src/renderer/locales/id/right.json +++ b/src/renderer/locales/id/right.json @@ -306,5 +306,6 @@ "quickOpen": "Buka Cepat", "quickOpenPlaceholder": "Cari file berdasarkan nama...", "quickOpenNoResults": "Tidak ada file yang cocok", - "quickOpenOpen": "Terbuka" + "quickOpenOpen": "Terbuka", + "offlineDisabled": "Tidak tersedia saat offline" } diff --git a/src/renderer/locales/ja/center.json b/src/renderer/locales/ja/center.json index f8785ee..4c30daa 100644 --- a/src/renderer/locales/ja/center.json +++ b/src/renderer/locales/ja/center.json @@ -295,5 +295,11 @@ "toolSummary.todo": "{{count}} ToDo", "turnFooterFiles_one": "1 ファイル変更", - "turnFooterFiles_other": "{{count}} ファイル変更" + "turnFooterFiles_other": "{{count}} ファイル変更", + + "offlineHint": "オフラインです", + "offlineSendDisabled": "オフライン中はメッセージを送信できません", + "networkError": "接続が切断されました", + "networkErrorMessage": "インターネット接続を確認してから再試行してください。", + "networkRetry": "再試行" } diff --git a/src/renderer/locales/ja/common.json b/src/renderer/locales/ja/common.json index 65ea3f3..15163ec 100644 --- a/src/renderer/locales/ja/common.json +++ b/src/renderer/locales/ja/common.json @@ -142,5 +142,11 @@ "description": "「起動」でデバイスを立ち上げ、「接続」で画面をストリーミング。チャットにリンクすればClaudeがUIを確認できます。" } } - } + }, + "offline": { + "banner": "オフラインです - 一部の機能が利用できません", + "reconnected": "オンラインに復帰しました", + "tooltip": "インターネット接続がありません" + }, + "cachedData": "オフライン - キャッシュデータを表示中" } diff --git a/src/renderer/locales/ja/right.json b/src/renderer/locales/ja/right.json index 637b172..ca073f2 100644 --- a/src/renderer/locales/ja/right.json +++ b/src/renderer/locales/ja/right.json @@ -306,5 +306,6 @@ "quickOpen": "クイックオープン", "quickOpenPlaceholder": "ファイル名で検索...", "quickOpenNoResults": "一致するファイルがありません", - "quickOpenOpen": "開いているファイル" + "quickOpenOpen": "開いているファイル", + "offlineDisabled": "オフライン中は利用できません" } diff --git a/src/renderer/store/sessions/__tests__/draftActions.test.ts b/src/renderer/store/sessions/__tests__/draftActions.test.ts index 6afaff7..b167d05 100644 --- a/src/renderer/store/sessions/__tests__/draftActions.test.ts +++ b/src/renderer/store/sessions/__tests__/draftActions.test.ts @@ -61,6 +61,8 @@ function makeState(overrides?: Partial): SessionsState { alwaysAllowTool: () => {}, retryAfterAuth: () => {}, dismissAuthError: () => {}, + retryAfterNetworkError: () => {}, + dismissNetworkError: () => {}, answerElicitation: () => {}, ...overrides } diff --git a/src/renderer/store/sessions/eventHandler.ts b/src/renderer/store/sessions/eventHandler.ts index efbe529..9b6f1bc 100644 --- a/src/renderer/store/sessions/eventHandler.ts +++ b/src/renderer/store/sessions/eventHandler.ts @@ -43,6 +43,13 @@ export function initAgentEventListener(): () => void { return handleWaitingInput(ctx, ev) case 'error': return handleError(ctx, ev) + case 'retrying': + // Agent is auto-retrying after a network error - update activity + updateSession(store, sessionId, () => ({ + status: 'running' as const, + activity: `Retrying (${ev.attempt}/${ev.maxAttempts})...`, + })) + return case 'user': return handleUser(ctx, ev) case 'assistant': diff --git a/src/renderer/store/sessions/handlers/handleWaiting.ts b/src/renderer/store/sessions/handlers/handleWaiting.ts index 3e5c531..29067ed 100644 --- a/src/renderer/store/sessions/handlers/handleWaiting.ts +++ b/src/renderer/store/sessions/handlers/handleWaiting.ts @@ -116,6 +116,16 @@ export function handleError(ctx: HandlerContext, ev: Record): v return } + if (ev.errorKind === 'network') { + if (!updateSession(store, sessionId, (current) => ({ + ...basePatch(current), + pendingNetworkError: true + }))) return + persistSession(sessionId) + maybeShowToast(sessionId, 'error', createNotificationDeps()) + return + } + const errorMsg: Message = { id: msgId(), role: 'system', diff --git a/src/renderer/store/sessions/handlers/networkErrorActions.ts b/src/renderer/store/sessions/handlers/networkErrorActions.ts new file mode 100644 index 0000000..7a5ec45 --- /dev/null +++ b/src/renderer/store/sessions/handlers/networkErrorActions.ts @@ -0,0 +1,41 @@ +// --------------------------------------------------------------------------- +// Network error actions - retryAfterNetworkError, dismissNetworkError +// --------------------------------------------------------------------------- + +import type { StateCreator } from 'zustand' +import { updateSession } from '../stateUtils' +import { useSessionsStore } from '../store' +import type { SessionsState } from '../storeTypes' + +export const createNetworkErrorActions: StateCreator< + SessionsState, + [], + [], + Pick +> = (_set, get) => ({ + retryAfterNetworkError: (sessionId) => { + const session = get().sessions[sessionId] + if (!session?.pendingNetworkError) return + + // Find the last user message to retry + const lastUserMsg = [...session.messages].reverse().find((m) => m.role === 'user') + if (!lastUserMsg) return + + // Clear the network error state + updateSession(useSessionsStore, sessionId, () => ({ + pendingNetworkError: undefined, + status: 'idle' as const + })) + + // Re-send the last user message + get().sendMessage(sessionId, lastUserMsg.content, lastUserMsg.images) + }, + + dismissNetworkError: (sessionId) => { + updateSession(useSessionsStore, sessionId, () => ({ + status: 'idle' as const, + activity: null, + pendingNetworkError: undefined + })) + } +}) diff --git a/src/renderer/store/sessions/store.ts b/src/renderer/store/sessions/store.ts index af78ef4..e647bc9 100644 --- a/src/renderer/store/sessions/store.ts +++ b/src/renderer/store/sessions/store.ts @@ -11,6 +11,7 @@ import { createDraftActions } from './handlers/draftActions' import { createWorktreeLinkActions } from './handlers/worktreeLinkActions' import { createUserInputActions } from './handlers/userInputActions' import { createAuthErrorActions } from './handlers/authErrorActions' +import { createNetworkErrorActions } from './handlers/networkErrorActions' import type { SessionsState } from './storeTypes' export type { SessionsState, QueuedMessage } from './storeTypes' @@ -39,6 +40,7 @@ export const useSessionsStore = create((set, get, api) => ({ ...createWorktreeLinkActions(set, get, api), ...createUserInputActions(set, get, api), ...createAuthErrorActions(set, get, api), + ...createNetworkErrorActions(set, get, api), // --------------------------------------------------------------------------- // Persistence — depends on hydratePersistedSessions directly, stays here diff --git a/src/renderer/store/sessions/storeTypes.ts b/src/renderer/store/sessions/storeTypes.ts index 004d5cb..acaf70c 100644 --- a/src/renderer/store/sessions/storeTypes.ts +++ b/src/renderer/store/sessions/storeTypes.ts @@ -68,6 +68,10 @@ export interface SessionsState { retryAfterAuth: (sessionId: string) => void /** Dismiss the auth error prompt without retrying. */ dismissAuthError: (sessionId: string) => void + /** Retry the last user message after a network error. */ + retryAfterNetworkError: (sessionId: string) => void + /** Dismiss the network error prompt without retrying. */ + dismissNetworkError: (sessionId: string) => void /** Respond to an MCP elicitation (OAuth auth or form input). */ answerElicitation: (sessionId: string, result: { action: 'accept' | 'decline' | 'cancel'; content?: Record }) => void } diff --git a/src/renderer/styles/chat-input.css b/src/renderer/styles/chat-input.css index 466596a..ba104aa 100644 --- a/src/renderer/styles/chat-input.css +++ b/src/renderer/styles/chat-input.css @@ -161,6 +161,10 @@ pointer-events: none; } +.chat-input-hint--offline { + color: var(--amber); +} + /* Bottom status bar */ .chat-bottom-bar { display: flex; diff --git a/src/renderer/styles/checks.css b/src/renderer/styles/checks.css index c7f8b02..2823d84 100644 --- a/src/renderer/styles/checks.css +++ b/src/renderer/styles/checks.css @@ -350,6 +350,10 @@ color: var(--text-muted); } +.checks-last-updated--stale { + color: var(--amber); +} + /* ─── No-PR empty state (hosts JiraSection below) ──────────────────────────── */ .checks-no-pr { diff --git a/src/renderer/styles/index.css b/src/renderer/styles/index.css index fe317ab..f2c3455 100644 --- a/src/renderer/styles/index.css +++ b/src/renderer/styles/index.css @@ -46,6 +46,7 @@ @import './mission-control.css'; @import './toasts.css'; @import './flash-toasts.css'; +@import './offline-banner.css'; @import './update-dialog.css'; @import './shortcuts.css'; @import './quick-open.css'; diff --git a/src/renderer/styles/offline-banner.css b/src/renderer/styles/offline-banner.css new file mode 100644 index 0000000..310e289 --- /dev/null +++ b/src/renderer/styles/offline-banner.css @@ -0,0 +1,65 @@ +/* --------------------------------------------------------------------------- + Offline banner - persistent amber bar at top of viewport + --------------------------------------------------------------------------- */ + +.offline-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: var(--z-sticky); + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-8); + height: var(--space-32); + background: var(--amber-subtle, rgba(245, 158, 11, 0.12)); + border-bottom: 1px solid var(--amber); + animation: offline-banner-slide-down var(--duration-fast) ease-out forwards; + -webkit-app-region: no-drag; +} + +.offline-banner--reconnected { + background: var(--olive-subtle, rgba(34, 197, 94, 0.12)); + border-bottom-color: var(--olive); +} + +.offline-banner--dismissing { + animation: offline-banner-slide-up var(--duration-fast) ease-in forwards; + background: var(--olive-subtle, rgba(34, 197, 94, 0.12)); + border-bottom-color: var(--olive); +} + +.offline-banner__icon { + font-size: var(--text-md); + line-height: 1; + flex-shrink: 0; +} + +.offline-banner:not(.offline-banner--reconnected):not(.offline-banner--dismissing) .offline-banner__icon { + color: var(--amber); +} + +.offline-banner--reconnected .offline-banner__icon, +.offline-banner--dismissing .offline-banner__icon { + color: var(--olive); +} + +.offline-banner__text { + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--text-primary); + user-select: none; +} + +/* ── Animations ──────────────────────────────────────────────────────── */ + +@keyframes offline-banner-slide-down { + from { transform: translateY(-100%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +@keyframes offline-banner-slide-up { + from { transform: translateY(0); opacity: 1; } + to { transform: translateY(-100%); opacity: 0; } +} diff --git a/src/renderer/types/session.ts b/src/renderer/types/session.ts index 4682045..f54b770 100644 --- a/src/renderer/types/session.ts +++ b/src/renderer/types/session.ts @@ -79,6 +79,7 @@ export interface AgentSession { pendingPlanApproval?: PendingPlanApproval pendingToolPermission?: PendingToolPermission pendingAuthError?: PendingAuthError + pendingNetworkError?: boolean pendingElicitation?: PendingElicitation slashCommands?: SlashCommand[] linkedWorktrees?: LinkedWorktree[] From 078bdd68cdafafdc34f51ec18ac398cb61b7ab57 Mon Sep 17 00:00:00 2001 From: Agastya Darma Date: Wed, 15 Apr 2026 09:15:26 +0900 Subject: [PATCH 2/3] fix: handle user abort during network retry backoff and offline banner positioning - Clean up abort listeners in abortableSleep to prevent memory leaks - Treat user abort during backoff as clean stop instead of error - Add 'retrying' event handler to forward network retry attempts to UI - Position offline banner below titlebar to prevent overlap - Fix offline banner dismiss animation to properly transition to hidden state --- src/main/services/agent.ts | 3 +++ src/main/services/agentWorker/core.ts | 17 +++++++++++++++-- .../components/shared/OfflineBanner.tsx | 7 ++++--- src/renderer/styles/offline-banner.css | 2 +- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/main/services/agent.ts b/src/main/services/agent.ts index 1dd8b5e..e9f79b1 100644 --- a/src/main/services/agent.ts +++ b/src/main/services/agent.ts @@ -147,6 +147,9 @@ class AgentCoordinator { this.sendEvent(event.sessionId, { type: 'error', message: event.message, errorKind: event.errorKind, authType: event.authType }) this.maybeNotify(event.sessionId, 'error', event.message) break + case 'retrying': + this.sendEvent(event.sessionId, { type: 'retrying', attempt: event.attempt, maxAttempts: event.maxAttempts, delayMs: event.delayMs }) + break case 'waiting_input': if (event.reason === 'tool_permission') { this.sendEvent(event.sessionId, { diff --git a/src/main/services/agentWorker/core.ts b/src/main/services/agentWorker/core.ts index b0a5a44..5daa633 100644 --- a/src/main/services/agentWorker/core.ts +++ b/src/main/services/agentWorker/core.ts @@ -30,8 +30,9 @@ const RETRY_BASE_MS = 2000 function abortableSleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal.aborted) { reject(new Error('Aborted')); return } - const timer = setTimeout(resolve, ms) - signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')) }, { once: true }) + const onAbort = () => { clearTimeout(timer); reject(new Error('Aborted')) } + const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve() }, ms) + signal.addEventListener('abort', onAbort, { once: true }) }) } @@ -271,6 +272,12 @@ export class AgentWorker { await this.startSession(sessionId, worktreeId, projectName, worktreePath, prompt, model, thinking, planMode, sessionName, settings, images, additionalDirectories, linkedWorktreeContext, connectedDeviceId, mobileFramework) return } + // User aborted during backoff sleep - treat as clean stop, not error + if (abortController.signal.aborted) { + this.log(sessionId, 'Session stopped by user during network retry') + this.emit({ type: 'done', sessionId }) + return + } } this.emit({ @@ -447,6 +454,12 @@ export class AgentWorker { await this.sendMessage(sessionId, message, sdkSessionId, cwd, model, planMode, sessionName, settings, images, additionalDirectories, linkedWorktreeContext, connectedDeviceId, mobileFramework) return } + // User aborted during backoff sleep - treat as clean stop, not error + if (abortController.signal.aborted) { + this.log(sessionId, 'Resume stopped by user during network retry') + this.emit({ type: 'done', sessionId }) + return + } } this.emit({ diff --git a/src/renderer/components/shared/OfflineBanner.tsx b/src/renderer/components/shared/OfflineBanner.tsx index a896d82..856d1f5 100644 --- a/src/renderer/components/shared/OfflineBanner.tsx +++ b/src/renderer/components/shared/OfflineBanner.tsx @@ -10,13 +10,14 @@ import { useOnlineStatus } from '@/lib/online' import { useTranslation } from 'react-i18next' type Phase = 'hidden' | 'offline' | 'reconnected' | 'dismissing' -type Action = { type: 'went_offline' } | { type: 'went_online' } | { type: 'dismiss' } +type Action = { type: 'went_offline' } | { type: 'went_online' } | { type: 'dismiss' } | { type: 'hide' } function reducer(state: Phase, action: Action): Phase { switch (action.type) { case 'went_offline': return 'offline' case 'went_online': return state === 'offline' ? 'reconnected' : state case 'dismiss': return 'dismissing' + case 'hide': return 'hidden' default: return state } } @@ -42,10 +43,10 @@ export function OfflineBanner() { return () => clearTimeout(timer) }, [phase]) - // Clean up dismissing state after animation completes + // Transition to hidden after dismiss animation completes useEffect(() => { if (phase !== 'dismissing') return - const timer = setTimeout(() => dispatch({ type: 'dismiss' }), 300) + const timer = setTimeout(() => dispatch({ type: 'hide' }), 300) return () => clearTimeout(timer) }, [phase]) diff --git a/src/renderer/styles/offline-banner.css b/src/renderer/styles/offline-banner.css index 310e289..1755594 100644 --- a/src/renderer/styles/offline-banner.css +++ b/src/renderer/styles/offline-banner.css @@ -4,7 +4,7 @@ .offline-banner { position: fixed; - top: 0; + top: 52px; /* below the drag-region titlebar */ left: 0; right: 0; z-index: var(--z-sticky); From c3e909708832dbad9a99f10925f58fd7ab1e1d04 Mon Sep 17 00:00:00 2001 From: Agastya Darma Date: Tue, 21 Apr 2026 23:24:58 +0900 Subject: [PATCH 3/3] test(agentWorker): add network retry behavior tests - Add afterEach to vitest imports for timer cleanup - Add comprehensive tests for startSession retry logic with exponential backoff - Add tests for sendMessage retry behavior during resume --- .../services/__tests__/agentWorker.test.ts | 202 +++++++++++++++++- 1 file changed, 201 insertions(+), 1 deletion(-) diff --git a/src/main/services/__tests__/agentWorker.test.ts b/src/main/services/__tests__/agentWorker.test.ts index 7c592e2..5500bd2 100644 --- a/src/main/services/__tests__/agentWorker.test.ts +++ b/src/main/services/__tests__/agentWorker.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest' import type { WorkerEvent, AgentSettings } from '../agentTypes' // ── SDK mock ────────────────────────────────────────────────────────── @@ -838,6 +838,206 @@ describe('AgentWorker', () => { }) }) + // ═════════════════════════════════════════════════════════════════════ + // Network retry behavior + // ═════════════════════════════════════════════════════════════════════ + + describe('network retry — startSession', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('retries on network error and succeeds on second attempt', async () => { + let callCount = 0 + mockQuery.mockImplementation(() => { + callCount++ + if (callCount === 1) throw new Error('ENOTFOUND api.anthropic.com') + return makeAsyncIterable([{ type: 'assistant', message: { content: [{ type: 'text', text: 'ok' }] } }]) + }) + + const promise = worker.startSession('s1', 'wt-1', 'test', '/tmp', 'hi', 'claude-sonnet-4-6', false, false, 'Chat', defaultSettings) + // Advance past the first retry delay (2s) + await vi.advanceTimersByTimeAsync(2500) + await promise + + const retrying = emitted.filter(e => e.type === 'retrying') + expect(retrying).toHaveLength(1) + expect(retrying[0]).toMatchObject({ attempt: 1, maxAttempts: 3 }) + + const errors = emitted.filter(e => e.type === 'error') + expect(errors).toHaveLength(0) + + const done = emitted.filter(e => e.type === 'done') + expect(done).toHaveLength(1) + }) + + it('emits error after all retries exhausted', async () => { + mockQuery.mockImplementation(() => { + throw new Error('ECONNREFUSED 127.0.0.1:443') + }) + + const promise = worker.startSession('s1', 'wt-1', 'test', '/tmp', 'hi', 'claude-sonnet-4-6', false, false, 'Chat', defaultSettings) + // Advance through all 3 retry delays: 2s + 4s + 8s + await vi.advanceTimersByTimeAsync(3000) // retry 1 + await vi.advanceTimersByTimeAsync(5000) // retry 2 + await vi.advanceTimersByTimeAsync(9000) // retry 3 + await vi.advanceTimersByTimeAsync(1000) // final attempt fails + await promise + + const retrying = emitted.filter(e => e.type === 'retrying') + expect(retrying).toHaveLength(3) + expect(retrying.map(e => (e as { attempt: number }).attempt)).toEqual([1, 2, 3]) + + const errors = emitted.filter(e => e.type === 'error') + expect(errors).toHaveLength(1) + expect(errors[0]).toMatchObject({ type: 'error', sessionId: 's1' }) + }) + + it('emits retrying events with correct delay values', async () => { + let callCount = 0 + mockQuery.mockImplementation(() => { + callCount++ + if (callCount <= 2) throw new Error('ETIMEDOUT') + return makeAsyncIterable([]) + }) + + const promise = worker.startSession('s1', 'wt-1', 'test', '/tmp', 'hi', 'claude-sonnet-4-6', false, false, 'Chat', defaultSettings) + await vi.advanceTimersByTimeAsync(3000) // retry 1 (2s delay) + await vi.advanceTimersByTimeAsync(5000) // retry 2 (4s delay) + await promise + + const retrying = emitted.filter(e => e.type === 'retrying') as Array<{ attempt: number; maxAttempts: number; delayMs: number }> + expect(retrying).toHaveLength(2) + expect(retrying[0]).toMatchObject({ attempt: 1, maxAttempts: 3, delayMs: 2000 }) + expect(retrying[1]).toMatchObject({ attempt: 2, maxAttempts: 3, delayMs: 4000 }) + }) + + it('emits done (not error) when user aborts during retry sleep', async () => { + mockQuery.mockImplementation(() => { + throw new Error('socket hang up') + }) + + const promise = worker.startSession('s1', 'wt-1', 'test', '/tmp', 'hi', 'claude-sonnet-4-6', false, false, 'Chat', defaultSettings) + // Let the first retry start (wait past the throw, into the sleep) + await vi.advanceTimersByTimeAsync(100) + // User stops the session during the backoff sleep + await worker.stopSession('s1') + await vi.advanceTimersByTimeAsync(5000) + await promise + + const errors = emitted.filter(e => e.type === 'error') + expect(errors).toHaveLength(0) + + const done = emitted.filter(e => e.type === 'done') + expect(done.length).toBeGreaterThanOrEqual(1) + }) + + it('does not retry on non-network errors', async () => { + mockQuery.mockImplementation(() => { + throw new Error('SDK exploded unexpectedly') + }) + + await worker.startSession('s1', 'wt-1', 'test', '/tmp', 'hi', 'claude-sonnet-4-6', false, false, 'Chat', defaultSettings) + + const retrying = emitted.filter(e => e.type === 'retrying') + expect(retrying).toHaveLength(0) + + const errors = emitted.filter(e => e.type === 'error') + expect(errors).toHaveLength(1) + }) + + it('resets retry count after a successful session', async () => { + let callCount = 0 + mockQuery.mockImplementation(() => { + callCount++ + if (callCount === 1) throw new Error('ENOTFOUND') + return makeAsyncIterable([]) + }) + + // First session: fails once, then succeeds + const p1 = worker.startSession('s1', 'wt-1', 'test', '/tmp', 'hi', 'claude-sonnet-4-6', false, false, 'Chat', defaultSettings) + await vi.advanceTimersByTimeAsync(3000) + await p1 + + // Second session with same ID: should start fresh retry count + callCount = 0 + emitted.length = 0 + const p2 = worker.startSession('s1', 'wt-1', 'test', '/tmp', 'hi again', 'claude-sonnet-4-6', false, false, 'Chat', defaultSettings) + await vi.advanceTimersByTimeAsync(3000) + await p2 + + // Should get retry attempt 1 again (not 2) + const retrying = emitted.filter(e => e.type === 'retrying') as Array<{ attempt: number }> + expect(retrying).toHaveLength(1) + expect(retrying[0].attempt).toBe(1) + }) + }) + + describe('network retry — sendMessage', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('retries on network error during resume and succeeds', async () => { + // First: create a session so sendMessage has state + mockQuery.mockReturnValue(makeAsyncIterable([ + { type: 'system', subtype: 'init', session_id: 'sdk-1', slash_commands: [], skills: [] } + ])) + await worker.startSession('s1', 'wt-1', 'test', '/tmp', 'hi', 'claude-sonnet-4-6', false, false, 'Chat', defaultSettings) + emitted.length = 0 + + // Now sendMessage: fails once, then succeeds + let callCount = 0 + mockQuery.mockImplementation(() => { + callCount++ + if (callCount === 1) throw new Error('fetch failed') + return makeAsyncIterable([]) + }) + + const promise = worker.sendMessage('s1', 'hello', 'sdk-1', '/tmp', 'claude-sonnet-4-6', false, 'Chat', defaultSettings) + await vi.advanceTimersByTimeAsync(3000) + await promise + + const retrying = emitted.filter(e => e.type === 'retrying') + expect(retrying).toHaveLength(1) + + const errors = emitted.filter(e => e.type === 'error') + expect(errors).toHaveLength(0) + }) + + it('emits done (not error) when user aborts during sendMessage retry', async () => { + mockQuery.mockReturnValue(makeAsyncIterable([ + { type: 'system', subtype: 'init', session_id: 'sdk-1', slash_commands: [], skills: [] } + ])) + await worker.startSession('s1', 'wt-1', 'test', '/tmp', 'hi', 'claude-sonnet-4-6', false, false, 'Chat', defaultSettings) + emitted.length = 0 + + mockQuery.mockImplementation(() => { + throw new Error('ENETUNREACH') + }) + + const promise = worker.sendMessage('s1', 'hello', 'sdk-1', '/tmp', 'claude-sonnet-4-6', false, 'Chat', defaultSettings) + await vi.advanceTimersByTimeAsync(100) + await worker.stopSession('s1') + await vi.advanceTimersByTimeAsync(5000) + await promise + + const errors = emitted.filter(e => e.type === 'error') + expect(errors).toHaveLength(0) + + const done = emitted.filter(e => e.type === 'done') + expect(done.length).toBeGreaterThanOrEqual(1) + }) + }) + describe('getSlashCommands', () => { it('returns commands from SDK discovery', async () => { const iter = makeAsyncIterable([