From fb2f04aa4e04900c5e2350c2150e90e6dae3c2bb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:05:16 -0700 Subject: [PATCH 001/227] fix(renderer): recover from failed lazy chunk imports instead of crashing (#5803) Lazy chunk import() rejections (stale/corrupt chunk -> native SyntaxError, e.g. "Unexpected token ']'", re-thrown by React.lazy) now self-heal via lazyWithRetry: retry with backoff, one sessionStorage-guarded reload, then fall through to the error boundary (loop-safe). Adopted at every renderer lazy site. Fixes the recurring terminal.workbench / right-sidebar / page.mobile react-error-boundary crash. --- src/renderer/src/App.tsx | 2 +- src/renderer/src/app-startup-routing.test.ts | 2 +- .../src/components/GitHubItemDialog.tsx | 2 +- .../src/components/PullRequestPage.tsx | 2 +- src/renderer/src/components/Terminal.tsx | 3 +- .../crash-report/CrashReportDialog.tsx | 3 +- .../src/components/editor/ChangesModeView.tsx | 3 +- .../src/components/editor/DiffSectionBody.tsx | 3 +- .../src/components/editor/EditorContent.tsx | 3 +- .../FloatingTerminalPanel.tsx | 12 +- .../right-sidebar-panel-content.tsx | 3 +- src/renderer/src/components/sidebar/index.tsx | 9 +- .../src/components/status-bar/StatusBar.tsx | 9 +- .../components/tab-group/TabGroupPanel.tsx | 3 +- src/renderer/src/lib/lazy-with-retry.test.ts | 194 ++++++++++++++++++ src/renderer/src/lib/lazy-with-retry.ts | 114 ++++++++++ src/renderer/src/web/main.tsx | 3 +- 17 files changed, 340 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/lib/lazy-with-retry.test.ts create mode 100644 src/renderer/src/lib/lazy-with-retry.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 138405745fa..acff1427db2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,6 +1,5 @@ /* eslint-disable max-lines */ import { - lazy, Suspense, useCallback, useEffect, @@ -10,6 +9,7 @@ import { useState, type SetStateAction } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { ArrowLeft, diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 9cf833d458e..fa5802dbd57 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -161,7 +161,7 @@ describe('renderer startup runtime routing', () => { expect(appSource).toContain('boundaryId="modal.confirm-add-project-from-folder"') expect(appSource).toContain('boundaryId="modal.project-added"') expect(appSource).toContain('setTimeout(() =>') - expect(sidebarSource).toContain("React.lazy(() => import('./WorktreeMetaDialog'))") + expect(sidebarSource).toContain("lazyWithRetry(() => import('./WorktreeMetaDialog'))") expect(sidebarSource).not.toContain("from './AddRepoDialog'") expect(sidebarSource).not.toContain("React.lazy(() => import('./AddRepoDialog'))") expect(sidebarSource).not.toContain("React.lazy(() => import('./NonGitFolderDialog'))") diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index c0522afddcf..f2c12955447 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -1,7 +1,6 @@ /* eslint-disable max-lines -- Why: the GH item dialog keeps its header, conversation, files, and checks tabs co-located so the read-only PR/Issue surface stays in one place while this view evolves. */ import React, { Suspense, - lazy, useCallback, useEffect, useLayoutEffect, @@ -10,6 +9,7 @@ import React, { useState, useSyncExternalStore } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { useVirtualizer } from '@tanstack/react-virtual' import { useShallow } from 'zustand/react/shallow' import type { editor as monacoEditor } from 'monaco-editor' diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 4154b4e482e..053ff6265e1 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -1,7 +1,6 @@ /* eslint-disable max-lines -- Why: duplicated from GitHubItemDialog so the dedicated PR full-page surface can evolve its Primer-styled header without destabilizing the issue dialog; planned to refactor shared parts out later. */ import React, { Suspense, - lazy, useCallback, useEffect, useLayoutEffect, @@ -10,6 +9,7 @@ import React, { useState, useSyncExternalStore } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { useVirtualizer } from '@tanstack/react-virtual' import { useShallow } from 'zustand/react/shallow' import type { editor as monacoEditor } from 'monaco-editor' diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 1c41f158c1e..905d18c3bbe 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1,6 +1,7 @@ /* eslint-disable max-lines */ -import React, { useEffect, useCallback, useMemo, useRef, useState, lazy, Suspense } from 'react' +import React, { useEffect, useCallback, useMemo, useRef, useState, Suspense } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { createPortal } from 'react-dom' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' diff --git a/src/renderer/src/components/crash-report/CrashReportDialog.tsx b/src/renderer/src/components/crash-report/CrashReportDialog.tsx index de66ea79796..fe2ba9b9024 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialog.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialog.tsx @@ -1,4 +1,5 @@ -import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react' +import { Suspense, useCallback, useEffect, useRef, useState } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { useMountedRef } from '@/hooks/useMountedRef' import { REACT_ERROR_BOUNDARY_REPORT_AVAILABLE_EVENT, diff --git a/src/renderer/src/components/editor/ChangesModeView.tsx b/src/renderer/src/components/editor/ChangesModeView.tsx index aa472de07aa..27d2ce943b3 100644 --- a/src/renderer/src/components/editor/ChangesModeView.tsx +++ b/src/renderer/src/components/editor/ChangesModeView.tsx @@ -1,4 +1,5 @@ -import React, { lazy } from 'react' +import React from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import type { OpenFile } from '@/store/slices/editor' import type { GitDiffResult, GitStatusEntry } from '../../../../shared/types' import { ConflictBanner } from './ConflictComponents' diff --git a/src/renderer/src/components/editor/DiffSectionBody.tsx b/src/renderer/src/components/editor/DiffSectionBody.tsx index 0c3200c1841..7792e902e17 100644 --- a/src/renderer/src/components/editor/DiffSectionBody.tsx +++ b/src/renderer/src/components/editor/DiffSectionBody.tsx @@ -1,4 +1,5 @@ -import { lazy, type RefObject } from 'react' +import { type RefObject } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { AlertCircle, RefreshCw } from 'lucide-react' import { DiffEditor, type DiffOnMount } from '@monaco-editor/react' import { cn } from '@/lib/utils' diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index e045f77189a..bc6d64e5a58 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -4,7 +4,8 @@ now Changes view mode). Keeping the mode-selection branches colocated is easier to reason about than scattering the switch across per-mode wrappers. Individual renderers (MonacoEditor, DiffViewer, ChangesModeView, MarkdownPreview, etc.) already live in their own modules. */ -import React, { lazy } from 'react' +import React from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { AlertCircle, RefreshCw } from 'lucide-react' import { detectLanguage } from '@/lib/language-detect' import { joinPath } from '@/lib/path' diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 88197dbce8a..d4919c0b55f 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -2,16 +2,8 @@ * resizing, orchestration setup, and mixed terminal/browser/editor tab * handling in one surface so the floating worktree does not drift from the * main tab model while still keeping the DOM-mounted panes local. */ -import { - lazy, - Suspense, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState -} from 'react' +import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { FileText, Globe, Minus, TerminalSquare } from 'lucide-react' import { toast } from 'sonner' import BrowserPane from '@/components/browser-pane/BrowserPane' diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx index 8e1e6c21894..f26f427152e 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx @@ -1,4 +1,5 @@ -import { lazy, Suspense } from 'react' +import { Suspense } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import type { ActiveRightSidebarTab } from '@/store/slices/editor' const FileExplorer = lazy(() => import('./FileExplorer')) diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index fd6801d27b2..a980f9cf8f8 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -15,11 +15,12 @@ import { useSidebarProjectDrop } from './useSidebarProjectDrop' import { useWorkspaceBoardPanel } from './useWorkspaceBoardPanel' import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance' import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark' +import { lazyWithRetry } from '@/lib/lazy-with-retry' -const WorktreeMetaDialog = React.lazy(() => import('./WorktreeMetaDialog')) -const RemoveFolderDialog = React.lazy(() => import('./RemoveFolderDialog')) -const WorktreeVisibilityDialog = React.lazy(() => import('./WorktreeVisibilityDialog')) -const OrcaYamlTrustDialog = React.lazy(() => import('./OrcaYamlTrustDialog')) +const WorktreeMetaDialog = lazyWithRetry(() => import('./WorktreeMetaDialog')) +const RemoveFolderDialog = lazyWithRetry(() => import('./RemoveFolderDialog')) +const WorktreeVisibilityDialog = lazyWithRetry(() => import('./WorktreeVisibilityDialog')) +const OrcaYamlTrustDialog = lazyWithRetry(() => import('./OrcaYamlTrustDialog')) const MIN_WIDTH = 220 const MAX_WIDTH = 500 diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index b67aa4cfa7d..6a4083f4bc0 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -14,6 +14,7 @@ import { Server } from 'lucide-react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { lazyWithRetry } from '@/lib/lazy-with-retry' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { @@ -70,18 +71,18 @@ type StatusBarProps = { floatingTerminalOpen: boolean } -const PetStatusSegment = React.lazy(() => +const PetStatusSegment = lazyWithRetry(() => import('./PetStatusSegment').then((module) => ({ default: module.PetStatusSegment })) ) -const ResourceUsageStatusSegment = React.lazy(() => +const ResourceUsageStatusSegment = lazyWithRetry(() => import('./ResourceUsageStatusSegment').then((module) => ({ default: module.ResourceUsageStatusSegment })) ) -const PortsStatusSegment = React.lazy(() => +const PortsStatusSegment = lazyWithRetry(() => import('./PortsStatusSegment').then((module) => ({ default: module.PortsStatusSegment })) ) -const SshStatusSegment = React.lazy(() => +const SshStatusSegment = lazyWithRetry(() => import('./SshStatusSegment').then((module) => ({ default: module.SshStatusSegment })) ) diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 4ad1eeaf382..668fbb8da92 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -1,4 +1,5 @@ -import { lazy, Suspense, useMemo } from 'react' +import { Suspense, useMemo } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { useDroppable } from '@dnd-kit/core' import { Columns2, Ellipsis, Rows2, X } from 'lucide-react' import { useAppStore } from '../../store' diff --git a/src/renderer/src/lib/lazy-with-retry.test.ts b/src/renderer/src/lib/lazy-with-retry.test.ts new file mode 100644 index 00000000000..7ef2f26fdec --- /dev/null +++ b/src/renderer/src/lib/lazy-with-retry.test.ts @@ -0,0 +1,194 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ComponentType } from 'react' + +import { loadLazyWithRetry } from './lazy-with-retry' + +const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' +const Comp: ComponentType = () => null +const chunkParseError = (): SyntaxError => new SyntaxError("Unexpected token ']'") + +function spyOnReload(): ReturnType { + const reload = vi.fn() + // happy-dom's location.reload is a no-op that would otherwise log; replace it. + vi.spyOn(window.location, 'reload').mockImplementation(reload) + return reload +} + +function stubCrashReportsBreadcrumb(): ReturnType { + const recordBreadcrumb = vi.fn() + Object.assign(window, { api: { crashReports: { recordBreadcrumb } } }) + return recordBreadcrumb +} + +// Why: happy-dom's Storage is a Proxy that vi.spyOn cannot reliably restore, so +// override window.sessionStorage with a throwing getter and restore the saved +// descriptor in afterEach. +let savedSessionStorageDescriptor: PropertyDescriptor | undefined + +function makeSessionStorageThrow(): void { + savedSessionStorageDescriptor = Object.getOwnPropertyDescriptor(window, 'sessionStorage') + Object.defineProperty(window, 'sessionStorage', { + configurable: true, + get() { + throw new Error('storage blocked') + } + }) +} + +beforeEach(() => { + vi.useFakeTimers() + window.sessionStorage.clear() +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + vi.useRealTimers() + if (savedSessionStorageDescriptor) { + Object.defineProperty(window, 'sessionStorage', savedSessionStorageDescriptor) + savedSessionStorageDescriptor = undefined + } + try { + delete (window as unknown as { api?: unknown }).api + window.sessionStorage.clear() + } catch { + // ignore — environment without storage + } +}) + +describe('loadLazyWithRetry', () => { + it('retries with exponential backoff (250ms, 500ms) and then resolves', async () => { + const reload = spyOnReload() + const factory = vi + .fn() + .mockRejectedValueOnce(chunkParseError()) + .mockRejectedValueOnce(chunkParseError()) + .mockResolvedValueOnce({ default: Comp }) + + const loaded = loadLazyWithRetry(factory, { retries: 2, baseDelayMs: 250 }) + expect(factory).toHaveBeenCalledTimes(1) // first attempt runs synchronously + + await vi.advanceTimersByTimeAsync(200) + expect(factory).toHaveBeenCalledTimes(1) // still inside the 250ms backoff + await vi.advanceTimersByTimeAsync(100) + expect(factory).toHaveBeenCalledTimes(2) // 250ms elapsed -> 2nd attempt + + await vi.advanceTimersByTimeAsync(400) + expect(factory).toHaveBeenCalledTimes(2) // still inside the 500ms backoff + await vi.advanceTimersByTimeAsync(100) + expect(factory).toHaveBeenCalledTimes(3) // 500ms elapsed -> 3rd attempt + + await expect(loaded).resolves.toEqual({ default: Comp }) + expect(reload).not.toHaveBeenCalled() + }) + + it('performs exactly one guarded reload after retries are exhausted', async () => { + const reload = spyOnReload() + const factory = vi.fn(() => Promise.reject(chunkParseError())) + + const loaded = loadLazyWithRetry(factory, { retries: 2, baseDelayMs: 250 }) + let settled = false + void loaded.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await vi.advanceTimersByTimeAsync(5000) + + expect(factory).toHaveBeenCalledTimes(3) + expect(reload).toHaveBeenCalledTimes(1) + expect(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBe('1') + // The load promise must suspend (never settle) while the page reloads, so the + // error boundary never flashes. + expect(settled).toBe(false) + }) + + it('does NOT reload twice — re-throws once the guard is already set', async () => { + const reload = spyOnReload() + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const error = chunkParseError() + const factory = vi.fn(() => Promise.reject(error)) + + const loaded = loadLazyWithRetry(factory, { retries: 2, baseDelayMs: 250 }) + const assertion = expect(loaded).rejects.toBe(error) + await vi.advanceTimersByTimeAsync(5000) + await assertion + + expect(reload).not.toHaveBeenCalled() + }) + + it('fails closed (re-throws, never reloads) when sessionStorage reads throw', async () => { + const reload = spyOnReload() + // Private-mode / sandboxed storage makes reads throw. The guard must treat + // this as "already reloaded" so a broken chunk can NEVER cause a reload loop. + makeSessionStorageThrow() + const error = chunkParseError() + const factory = vi.fn(() => Promise.reject(error)) + + const loaded = loadLazyWithRetry(factory, { retries: 1, baseDelayMs: 100 }) + const assertion = expect(loaded).rejects.toBe(error) + await vi.advanceTimersByTimeAsync(5000) + await assertion + + expect(reload).not.toHaveBeenCalled() + }) + + it('records a lazy_chunk_reload breadcrumb (with reloadKey) before reloading', async () => { + const reload = spyOnReload() + const recordBreadcrumb = stubCrashReportsBreadcrumb() + const factory = vi.fn(() => Promise.reject(chunkParseError())) + + const loaded = loadLazyWithRetry(factory, { retries: 0, reloadKey: 'right-sidebar' }) + let settled = false + void loaded.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await vi.advanceTimersByTimeAsync(5000) + + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + expect(recordBreadcrumb).toHaveBeenCalledWith({ + name: 'lazy_chunk_reload', + data: { reloadKey: 'right-sidebar', message: "Unexpected token ']'" } + }) + // The breadcrumb must land before window.location.reload() tears the page down. + expect(recordBreadcrumb.mock.invocationCallOrder[0]).toBeLessThan( + reload.mock.invocationCallOrder[0] + ) + expect(settled).toBe(false) + }) + + it('re-throws without reloading when there is no window (SSR / node)', async () => { + vi.stubGlobal('window', undefined) + const error = chunkParseError() + const factory = vi.fn(() => Promise.reject(error)) + + const loaded = loadLazyWithRetry(factory, { retries: 1, baseDelayMs: 100 }) + const assertion = expect(loaded).rejects.toBe(error) + await vi.advanceTimersByTimeAsync(5000) + await assertion + + expect(factory).toHaveBeenCalledTimes(2) + }) + + it('keeps the reload guard set across a successful load (no second reload in one session)', async () => { + const reload = spyOnReload() + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const factory = vi.fn(() => Promise.resolve({ default: Comp })) + + await loadLazyWithRetry(factory) + + // The guard must survive a healthy load — otherwise a sibling chunk's success + // would re-arm the reload and an auto-mounted corrupt chunk would loop. + expect(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBe('1') + expect(reload).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/lazy-with-retry.ts b/src/renderer/src/lib/lazy-with-retry.ts new file mode 100644 index 00000000000..5a09f6b2a95 --- /dev/null +++ b/src/renderer/src/lib/lazy-with-retry.ts @@ -0,0 +1,114 @@ +import { lazy, type ComponentType, type LazyExoticComponent } from 'react' + +/** + * Resilient replacement for React.lazy. + * + * Why: a stale, corrupt, or truncated lazy chunk parses as invalid JavaScript and + * rejects its dynamic import() with a native SyntaxError (e.g. "Unexpected token + * ']'"). React.lazy permanently caches that rejection, so the error boundary's + * "Retry" — which just re-renders the same Lazy — can never recover it; the + * surface stays dead and reports a react-error-boundary crash. This wrapper first + * retries transient fetch failures, then performs ONE guarded full reload to + * refetch fresh chunk bytes and rebuild the ES module map, before finally falling + * through to the error boundary. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirror React.lazy's own ComponentType constraint so every existing call site type-checks unchanged. +type AnyComponent = ComponentType + +type LazyFactory = () => Promise<{ default: T }> + +export type LazyWithRetryOptions = { + retries?: number + baseDelayMs?: number + /** Label surfaced in the reload breadcrumb for triage; not used for control flow. */ + reloadKey?: string +} + +// One recovery reload per session. The guard survives the reload itself (so we +// never loop) but resets when the window/app closes, so a later launch — e.g. +// after an update ships fresh chunks — can earn another reload. sessionStorage +// (not localStorage) gives exactly that lifetime; it is never cleared mid-session, +// otherwise a sibling chunk's healthy load would re-arm the reload and an +// auto-mounted corrupt chunk would loop. +const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' +const DEFAULT_RETRIES = 2 +const DEFAULT_BASE_DELAY_MS = 250 + +function hasAttemptedChunkReload(): boolean { + try { + return window.sessionStorage.getItem(RELOAD_GUARD_KEY) === '1' + } catch { + // Why: when sessionStorage is unavailable (private mode / sandboxed), fail + // closed (treat as already-reloaded) so we never risk an infinite reload loop. + return true + } +} + +function markChunkReloadAttempted(): void { + try { + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + } catch { + // Best-effort; if writing throws, hasAttemptedChunkReload() also fails closed. + } +} + +function recordReloadBreadcrumb(reloadKey: string, message: string): void { + // Inlined rather than importing crash-diagnostics so this low-level recovery + // primitive stays free of the renderer/webview module graph (keeps it SSR- and + // unit-test-friendly). Mirrors crash-diagnostics' best-effort breadcrumb call. + try { + const api = (window as Window & { api?: Window['api'] }).api + api?.crashReports.recordBreadcrumb({ name: 'lazy_chunk_reload', data: { reloadKey, message } }) + } catch { + // Crash evidence is best-effort and must never mask the original failure. + } +} + +const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +// Suspends the React.lazy boundary while window.location.reload() tears the page +// down, so the error fallback never flashes in the moment before the reload lands. +const SUSPEND_UNTIL_RELOAD = new Promise(() => undefined) + +export async function loadLazyWithRetry( + factory: LazyFactory, + options: LazyWithRetryOptions = {} +): Promise<{ default: T }> { + const retries = options.retries ?? DEFAULT_RETRIES + const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS + + let lastError: unknown + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + return await factory() + } catch (error) { + lastError = error + if (attempt < retries) { + // Exponential backoff absorbs transient fetch hiccups (HTTP / relay / SSH). + await wait(baseDelayMs * 2 ** attempt) + } + } + } + + if (typeof window !== 'undefined' && !hasAttemptedChunkReload()) { + markChunkReloadAttempted() + recordReloadBreadcrumb( + options.reloadKey ?? 'unknown', + lastError instanceof Error ? lastError.message : String(lastError) + ) + window.location.reload() + return SUSPEND_UNTIL_RELOAD + } + + // Already reloaded once this session, or no window (SSR / node): re-throw so + // RecoverableRenderErrorBoundary catches and reports it instead of looping. + throw lastError +} + +export function lazyWithRetry( + factory: LazyFactory, + options?: LazyWithRetryOptions +): LazyExoticComponent { + return lazy(() => loadLazyWithRetry(factory, options)) +} diff --git a/src/renderer/src/web/main.tsx b/src/renderer/src/web/main.tsx index 7cfd3abec91..da0cda067d5 100644 --- a/src/renderer/src/web/main.tsx +++ b/src/renderer/src/web/main.tsx @@ -1,6 +1,7 @@ import '../assets/main.css' -import { lazy, Suspense, useMemo, useState } from 'react' +import { Suspense, useMemo, useState } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import ReactDOM from 'react-dom/client' import { useTranslation } from 'react-i18next' import WebConnect from './WebConnect' From bef62a73a53441f00567ff722c7c2eea090492af Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:26:16 -0700 Subject: [PATCH 002/227] fix(mobile-emulator): keep "+" menu open on intro actions and auto-dismiss hidden toast (#5800) The Mobile Emulator intro callout in the new-tab ("+") menu closed the whole dropdown when Keep/Hide/Dismiss was clicked, and the "Mobile Emulator hidden" toast used duration: Infinity so it never went away. - Drop the onAction/runAndNotify menu-close wiring so the callout actions leave the dropdown open; removing the prop makes reintroducing the close a type error. - Auto-dismiss the toast after 30s (still dismissible). - Make the new-tab dropdown modal={false} so the toast's "Settings > Mobile Emulator" re-enable link stays clickable while the menu remains open. Tests: callout button wiring + pointer-down guard + compile-time no-prop guard; toast duration unit test; updated intro-actions test asserts the 30s duration. --- .../MobileEmulatorTabIntroCallout.test.tsx | 102 ++++++++++++++++++ .../MobileEmulatorTabIntroCallout.tsx | 22 ++-- .../mobile-emulator-hidden-toast.test.tsx | 37 +++++++ .../mobile-emulator-hidden-toast.tsx | 7 +- ...mobile-emulator-tab-intro-actions.test.tsx | 2 +- .../src/components/tab-bar/TabBar.tsx | 11 +- 6 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.test.tsx create mode 100644 src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.test.tsx diff --git a/src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.test.tsx b/src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.test.tsx new file mode 100644 index 00000000000..f5ccbbd2019 --- /dev/null +++ b/src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileEmulatorTabIntroCallout } from './MobileEmulatorTabIntroCallout' + +const { keepIntro, hideIntro, dismissIntro } = vi.hoisted(() => ({ + keepIntro: vi.fn(), + hideIntro: vi.fn(), + dismissIntro: vi.fn() +})) + +vi.mock('./use-mobile-emulator-tab-intro-actions', () => ({ + useMobileEmulatorTabIntroActions: () => ({ keepIntro, hideIntro, dismissIntro }) +})) + +// Why: the Tooltip primitive needs a provider/portal we don't care about here; +// render its trigger children directly so the test stays focused on wiring. +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => children, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => children, + TooltipContent: () => null +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +beforeEach(async () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) +}) + +afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + vi.clearAllMocks() +}) + +function click(matcher: string): void { + const button = Array.from(container?.querySelectorAll('button') ?? []).find( + (el) => el.textContent?.trim() === matcher || el.getAttribute('aria-label') === matcher + ) + if (!button) { + throw new Error(`button not found: ${matcher}`) + } + act(() => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +describe('MobileEmulatorTabIntroCallout', () => { + it('runs keepIntro when Keep is clicked', () => { + click('Keep') + expect(keepIntro).toHaveBeenCalledTimes(1) + expect(hideIntro).not.toHaveBeenCalled() + expect(dismissIntro).not.toHaveBeenCalled() + }) + + it('runs hideIntro when Hide is clicked', () => { + click('Hide') + expect(hideIntro).toHaveBeenCalledTimes(1) + expect(keepIntro).not.toHaveBeenCalled() + expect(dismissIntro).not.toHaveBeenCalled() + }) + + it('runs dismissIntro when the X is clicked', () => { + click('Dismiss') + expect(dismissIntro).toHaveBeenCalledTimes(1) + expect(keepIntro).not.toHaveBeenCalled() + expect(hideIntro).not.toHaveBeenCalled() + }) + + it('exposes no menu-closing prop, so reintroducing Issue 1 is a compile error', () => { + // Why: Issue 1's root cause was a parent-supplied close callback + // (onAction={() => setNewTabMenuOpen(false)}). The callout must accept no + // such prop — re-adding one fails this typechecked file's @ts-expect-error. + // @ts-expect-error - MobileEmulatorTabIntroCallout intentionally takes no props + const reintroduced = undefined} /> + expect(reintroduced).toBeDefined() + }) + + it('prevents the default pointer-down so the dropdown menu stays open', () => { + const callout = container?.querySelector('.mobile-emulator-tab-intro-callout--menu') + expect(callout).not.toBeNull() + const event = new MouseEvent('pointerdown', { bubbles: true, cancelable: true }) + act(() => { + callout?.dispatchEvent(event) + }) + expect(event.defaultPrevented).toBe(true) + }) +}) diff --git a/src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.tsx b/src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.tsx index 2d1924737c6..e97bdaab1da 100644 --- a/src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.tsx +++ b/src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.tsx @@ -4,25 +4,15 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { translate } from '@/i18n/i18n' import { useMobileEmulatorTabIntroActions } from './use-mobile-emulator-tab-intro-actions' -type MobileEmulatorTabIntroCalloutProps = { - onAction?: () => void -} - -export function MobileEmulatorTabIntroCallout({ - onAction -}: MobileEmulatorTabIntroCalloutProps): React.JSX.Element { +export function MobileEmulatorTabIntroCallout(): React.JSX.Element { const { keepIntro, hideIntro, dismissIntro } = useMobileEmulatorTabIntroActions() - const runAndNotify = (action: () => void): void => { - action() - onAction?.() - } - return (
event.preventDefault()} >

@@ -37,7 +27,7 @@ export function MobileEmulatorTabIntroCallout({ size="sm" variant="outline" className="h-6 px-2 text-[11px]" - onClick={() => runAndNotify(keepIntro)} + onClick={keepIntro} > {translate( 'auto.components.emulator.pane.MobileEmulatorTabIntroCallout.8014b4b80b', @@ -49,7 +39,7 @@ export function MobileEmulatorTabIntroCallout({ size="sm" variant="ghost" className="h-6 px-2 text-[11px] text-muted-foreground" - onClick={() => runAndNotify(hideIntro)} + onClick={hideIntro} > {translate( 'auto.components.emulator.pane.MobileEmulatorTabIntroCallout.6e051a40b7', @@ -67,7 +57,7 @@ export function MobileEmulatorTabIntroCallout({ 'Dismiss' )} className="size-6 text-muted-foreground" - onClick={() => runAndNotify(dismissIntro)} + onClick={dismissIntro} > diff --git a/src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.test.tsx b/src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.test.tsx new file mode 100644 index 00000000000..9ec3335571c --- /dev/null +++ b/src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import type { AppState } from '@/store/types' +import { showMobileEmulatorHiddenToast } from './mobile-emulator-hidden-toast' + +vi.mock('sonner', () => ({ + toast: { + dismiss: vi.fn(), + info: vi.fn() + } +})) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('showMobileEmulatorHiddenToast', () => { + it('auto-dismisses after 30s while staying dismissible', () => { + showMobileEmulatorHiddenToast({ + openSettingsPage: vi.fn() as unknown as AppState['openSettingsPage'], + openSettingsTarget: vi.fn() as unknown as AppState['openSettingsTarget'] + }) + + const infoMock = vi.mocked(toast.info) + expect(infoMock).toHaveBeenCalledTimes(1) + const options = infoMock.mock.calls[0]?.[1] + // Why: a finite duration is the fix for the toast lingering forever; assert + // the exact value so a regression to Infinity is caught. + expect(options).toMatchObject({ + id: 'mobile-emulator-hidden', + duration: 30_000, + dismissible: true + }) + }) +}) diff --git a/src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.tsx b/src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.tsx index d73a0ccdc68..d62b44f9f2f 100644 --- a/src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.tsx +++ b/src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.tsx @@ -3,6 +3,9 @@ import type { AppState } from '@/store/types' import { translate } from '@/i18n/i18n' const MOBILE_EMULATOR_HIDDEN_TOAST_ID = 'mobile-emulator-hidden' +// Why: auto-dismiss the nudge after 30s so it can't linger forever; it stays +// dismissible early and the Settings re-enable link is reachable until then. +const MOBILE_EMULATOR_HIDDEN_TOAST_DURATION_MS = 30_000 type MobileEmulatorHiddenToastDeps = { openSettingsPage: AppState['openSettingsPage'] @@ -10,8 +13,6 @@ type MobileEmulatorHiddenToastDeps = { } export function showMobileEmulatorHiddenToast(deps: MobileEmulatorHiddenToastDeps): void { - // Why: matches other one-time opt-out nudges — stay on screen until the user - // dismisses it so the Settings re-enable path is easy to find. toast.info( translate( 'auto.components.emulator.pane.mobile.emulator.hidden.toast.e8f098a870', @@ -42,7 +43,7 @@ export function showMobileEmulatorHiddenToast(deps: MobileEmulatorHiddenToastDep .

), - duration: Infinity, + duration: MOBILE_EMULATOR_HIDDEN_TOAST_DURATION_MS, dismissible: true } ) diff --git a/src/renderer/src/components/emulator-pane/use-mobile-emulator-tab-intro-actions.test.tsx b/src/renderer/src/components/emulator-pane/use-mobile-emulator-tab-intro-actions.test.tsx index f83485ff173..137c20f63bc 100644 --- a/src/renderer/src/components/emulator-pane/use-mobile-emulator-tab-intro-actions.test.tsx +++ b/src/renderer/src/components/emulator-pane/use-mobile-emulator-tab-intro-actions.test.tsx @@ -121,7 +121,7 @@ describe('useMobileEmulatorTabIntroActions', () => { expect(closeUnifiedTab).toHaveBeenCalledWith('simulator-tab') expect(toast.info).toHaveBeenCalledWith( 'Mobile Emulator hidden', - expect.objectContaining({ id: 'mobile-emulator-hidden' }) + expect.objectContaining({ id: 'mobile-emulator-hidden', duration: 30_000 }) ) expect(toast.error).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 5f5982f4968..fc7008ae672 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -769,7 +769,7 @@ function TabBarInner({ isMacOs && mobileEmulatorEnabled && onNewSimulatorTab ? ( - setNewTabMenuOpen(false)} /> + ) : null const standardCreateMenuItems = newTabMenuOrder === 'markdown-first' ? ( @@ -1193,7 +1193,14 @@ function TabBarInner({ ) : null} - +
- {!isCloud && isReady && !isActive ? ( - + {deletePending ? ( + + ) : ( + + )} + ) : !isCloud && !isReady && !isDownloading ? ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 25c6577d3f9..be7d84706ff 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6277,6 +6277,7 @@ "c2c64b24d0": "Reset" }, "VoicePane": { + "6fa734ed95": "Delete {{value0}}", "68de13f72c": "Failed to delete model.", "1ba81c0ff0": "recommended", "cfde55c7b0": "Failed to download model.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0866938eb9d..a0a53a4a9a7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -6268,7 +6268,8 @@ "ad5d036ecc": "No se pudo solicitar permiso del micrófono. El dictado de voz no estaba habilitado.", "f9a9cf6928": "Se requiere permiso del micrófono antes de habilitar el dictado de voz.", "1eac933202": "Se abrió Privacidad y seguridad de macOS. Habilite el dictado nuevamente después de otorgar acceso.", - "cd9fe37556": "Permiso de micrófono concedido" + "cd9fe37556": "Permiso de micrófono concedido", + "6fa734ed95": "Delete {{value0}}" }, "WorktreeSymlinksSection": { "1c1e35b219": "Quitar {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 50f47f04714..e7ce8ab1d16 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -6290,7 +6290,8 @@ "ad5d036ecc": "マイクの許可を要求できませんでした。音声ディクテーションが有効になっていませんでした。", "f9a9cf6928": "音声ディクテーションを有効にする前に、マイクの許可が必要です。", "1eac933202": "macOS のプライバシーとセキュリティを開きました。アクセスを許可した後、ディクテーションを再度有効にします。", - "cd9fe37556": "マイクの許可が与えられました" + "cd9fe37556": "マイクの許可が与えられました", + "6fa734ed95": "Delete {{value0}}" }, "WorktreeSymlinksSection": { "1c1e35b219": "{{value0}} を削除", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 919609547f4..aafb68a9a77 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6253,7 +6253,8 @@ "ad5d036ecc": "마이크 권한을 요청할 수 없습니다. 음성 받아쓰기가 활성화되지 않았습니다.", "f9a9cf6928": "음성 받아쓰기를 활성화하려면 마이크 권한이 필요합니다.", "1eac933202": "macOS 개인 정보 보호 및 보안을 열었습니다. 액세스 권한을 부여한 후 받아쓰기를 다시 활성화하세요.", - "cd9fe37556": "마이크 권한이 부여되었습니다." + "cd9fe37556": "마이크 권한이 부여되었습니다.", + "6fa734ed95": "Delete {{value0}}" }, "WorktreeSymlinksSection": { "1c1e35b219": "{{value0}} 제거", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1b1569758cd..1cea91547dc 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6253,7 +6253,8 @@ "ad5d036ecc": "无法请求麦克风权限。未启用语音听写。", "f9a9cf6928": "启用语音听写之前需要麦克风许可。", "1eac933202": "打开 macOS 隐私和安全。授予访问权限后再次启用听写。", - "cd9fe37556": "已授予麦克风权限" + "cd9fe37556": "已授予麦克风权限", + "6fa734ed95": "Delete {{value0}}" }, "WorktreeSymlinksSection": { "1c1e35b219": "删除 {{value0}}", From d90366f7203831b3fd370077b668add543639c7c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:33:17 -0700 Subject: [PATCH 006/227] fix(tab-bar): search agents by prefix and make the open-entry list keyboard-navigable (#5817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Open any file, URL, agent, ..." new-tab entry had two gaps: 1. Agents were not really searchable. findMatchingTabAgentLaunchOptions only matched on exact alias equality, so a partial name ("gemin", "clau") surfaced no agent. It now scores prefix/token matches (exact alias wins; otherwise every query token must prefix an alias token; mid-string and single-char hits are rejected so agents — which rank above file results — don't noisily hijack the list). The tokenizer is shared with the create-menu matcher via query-token-match.ts. 2. Arrow keys didn't navigate the list. Navigation only ran when there were >1 result rows, and the just-opened (empty-query) state trapped focus in the input so the static create/agent list below was unreachable. Arrows now move the highlight for any result count, and with no result rows focus routes into the enclosing Radix menu; ArrowUp on the first item returns to the search box so the trip isn't one-way. Also adds combobox/listbox + aria-activedescendant semantics so the highlight is exposed to assistive tech, and splits the row presentation and option types into focused modules to keep the component under the line cap. --- .../TabBarCreateEntry.keyboard.test.tsx | 220 ++++++++++++++++ .../components/tab-bar/TabBarCreateEntry.tsx | 244 +++++++----------- .../tab-bar/TabBarCreateEntryRow.tsx | 131 ++++++++++ .../tab-bar/query-token-match.test.ts | 31 +++ .../components/tab-bar/query-token-match.ts | 51 ++++ .../tab-bar/tab-agent-launch-options.test.ts | 44 ++++ .../tab-bar/tab-agent-launch-options.ts | 55 +++- .../tab-bar/tab-create-entry-active-option.ts | 37 +++ .../tab-bar/tab-create-menu-options.ts | 52 +--- 9 files changed, 656 insertions(+), 209 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx create mode 100644 src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx create mode 100644 src/renderer/src/components/tab-bar/query-token-match.test.ts create mode 100644 src/renderer/src/components/tab-bar/query-token-match.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-active-option.ts diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx new file mode 100644 index 00000000000..ffd81e96c09 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx @@ -0,0 +1,220 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TabEntryOption } from './tab-create-entry-action' +import type { TabAgentLaunchOption } from './tab-agent-launch-options' + +// Why: the real entry-action module pulls in runtime IPC + the app store; the +// keyboard behavior under test only needs a controllable option list. +const entryOptionsMock = vi.hoisted(() => ({ options: [] as TabEntryOption[] })) +vi.mock('./tab-create-entry-action', () => ({ + getTabEntryOptions: () => entryOptionsMock.options +})) +vi.mock('../quick-open-file-list', () => ({ + useRuntimeFileListForWorktree: () => ({ files: [], loading: false, loadError: null }) +})) +vi.mock('@/lib/agent-catalog', () => ({ + getAgentCatalog: () => [], + AgentIcon: () => null +})) + +import TabBarCreateEntry from './TabBarCreateEntry' + +// Why: opt into React's act environment so state updates flush synchronously. +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const fileOption = (relativePath: string): TabEntryOption => ({ + id: `existing-file:${relativePath}`, + classification: { kind: 'existing-file', matchKind: 'fuzzy', relativePath } +}) + +let container: HTMLDivElement +let root: Root + +function mount(node: React.JSX.Element): void { + act(() => { + root.render(node) + }) +} + +function pressKey(target: Element, key: string): KeyboardEvent { + const event = new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) + act(() => { + target.dispatchEvent(event) + }) + return event +} + +function setQuery(value: string): void { + const input = container.querySelector('input') + if (!input) { + throw new Error('input not found') + } + // Why: React patches the input value setter to track changes; bypass it with + // the native setter so the synthetic onChange actually fires. + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set + act(() => { + nativeSetter?.call(input, value) + input.dispatchEvent(new window.Event('input', { bubbles: true })) + }) +} + +function submitForm(): void { + const form = container.querySelector('form') + if (!form) { + throw new Error('form not found') + } + act(() => { + form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })) + }) +} + +beforeEach(() => { + entryOptionsMock.options = [] + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() +}) + +describe('TabBarCreateEntry keyboard navigation', () => { + it('intercepts ArrowDown on a single-option list so it does not leak (guards >0 vs >1)', () => { + entryOptionsMock.options = [fileOption('src/only-match.ts')] + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + mount() + + // The pre-fix `> 1` guard left a single result unhandled: the arrow leaked to + // the input cursor / Radix. Now it must be consumed (preventDefault) and Enter + // still opens the row. + const event = pressKey(container.querySelector('input')!, 'ArrowDown') + expect(event.defaultPrevented).toBe(true) + + submitForm() + expect(onOpenEntry).toHaveBeenCalledTimes(1) + expect(onOpenEntry.mock.calls[0][0].classification).toMatchObject({ + kind: 'existing-file', + relativePath: 'src/only-match.ts' + }) + }) + + it('iterates a multi-option result list with ArrowDown and selects the highlighted row', () => { + entryOptionsMock.options = [fileOption('a.ts'), fileOption('b.ts'), fileOption('c.ts')] + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + mount() + + const input = container.querySelector('input')! + pressKey(input, 'ArrowDown') + pressKey(input, 'ArrowDown') + submitForm() + + expect(onOpenEntry.mock.calls[0][0].classification).toMatchObject({ relativePath: 'c.ts' }) + }) + + it('wraps from the first row to the last with ArrowUp', () => { + entryOptionsMock.options = [fileOption('a.ts'), fileOption('b.ts')] + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + mount() + + pressKey(container.querySelector('input')!, 'ArrowUp') + submitForm() + + expect(onOpenEntry.mock.calls[0][0].classification).toMatchObject({ relativePath: 'b.ts' }) + }) + + it('launches a matched agent when its highlighted row is selected', () => { + const agentOptions: TabAgentLaunchOption[] = [ + { agent: 'gemini', aliases: ['gemini'], label: 'Gemini' } + ] + const onLaunchAgent = vi.fn() + mount( + + ) + + // A partial query surfaces the agent (issue #1); it is the top row, so Enter + // launches it. + setQuery('gem') + submitForm() + + expect(onLaunchAgent).toHaveBeenCalledWith('gemini') + }) + + it('exposes the highlighted row to assistive tech via aria-activedescendant', () => { + entryOptionsMock.options = [fileOption('a.ts'), fileOption('b.ts'), fileOption('c.ts')] + mount( + + ) + + const input = container.querySelector('input')! + expect(input.getAttribute('role')).toBe('combobox') + expect(input.getAttribute('aria-activedescendant')).toBe('tab-create-entry-result-0') + + pressKey(input, 'ArrowDown') + expect(input.getAttribute('aria-activedescendant')).toBe('tab-create-entry-result-1') + const active = document.getElementById('tab-create-entry-result-1') + expect(active?.getAttribute('role')).toBe('option') + expect(active?.getAttribute('aria-selected')).toBe('true') + }) + + it('routes ArrowDown into the enclosing menu when there are no result rows', () => { + mount( +
+ + + +
+ ) + + const firstItem = container.querySelector('[role="menuitem"]') as HTMLButtonElement + pressKey(container.querySelector('input')!, 'ArrowDown') + + expect(document.activeElement).toBe(firstItem) + }) + + it('returns focus to the input on ArrowUp from the first menu item (no dead-end)', () => { + mount( +
+ + + +
+ ) + + const input = container.querySelector('input')! + const firstItem = container.querySelector('[role="menuitem"]') as HTMLButtonElement + act(() => firstItem.focus()) + expect(document.activeElement).toBe(firstItem) + + pressKey(firstItem, 'ArrowUp') + expect(document.activeElement).toBe(input) + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx index f32a94ff1c6..619e906df18 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -1,15 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' -import { FilePlus, FileText, Globe, Loader2, Smartphone, TerminalSquare } from 'lucide-react' import { Input } from '@/components/ui/input' -import { AgentIcon } from '@/lib/agent-catalog' -import { cn } from '@/lib/utils' import { useRuntimeFileListForWorktree } from '../quick-open-file-list' -import { - getTabEntryOptions, - type TabCreateEntryArgs, - type TabEntryActionClassification, - type TabEntryOption -} from './tab-create-entry-action' +import { getTabEntryOptions, type TabCreateEntryArgs } from './tab-create-entry-action' import { findMatchingTabAgentLaunchOptions, type TabAgentLaunchOption @@ -18,6 +10,17 @@ import { findMatchingTabCreateMenuOptions, type TabCreateMenuOption } from './tab-create-menu-options' +import { + getActiveOptionId, + isActiveEntryOption, + type ActiveOption +} from './tab-create-entry-active-option' +import { + EntryActionRow, + EntryStatusRow, + RESULT_LISTBOX_ID, + resultOptionDomId +} from './TabBarCreateEntryRow' import type { TuiAgent } from '../../../../shared/types' import { translate } from '@/i18n/i18n' @@ -60,6 +63,35 @@ export default function TabBarCreateEntry({ const inputRef = useRef(null) const fileList = useRuntimeFileListForWorktree({ enabled: menuOpen, worktreeId }) + // Why: once ArrowDown moves focus into the static menu list, ArrowUp on the + // first item should return to the search box so the keyboard trip isn't + // one-way. Capture phase beats Radix's roving-focus handler. + useEffect(() => { + if (!menuOpen) { + return + } + const input = inputRef.current + const menu = input?.closest('[role="menu"]') + if (!input || !menu) { + return + } + const handleMenuKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'ArrowUp') { + return + } + const firstItem = menu.querySelector( + '[role="menuitem"]:not([data-disabled]):not([aria-disabled="true"])' + ) + if (firstItem && document.activeElement === firstItem) { + event.preventDefault() + event.stopPropagation() + input.focus() + } + } + menu.addEventListener('keydown', handleMenuKeyDown, true) + return () => menu.removeEventListener('keydown', handleMenuKeyDown, true) + }, [menuOpen]) + useEffect(() => { if (!menuOpen) { return @@ -185,14 +217,26 @@ export default function TabBarCreateEntry({ submitOption() }} onKeyDown={(event) => { - if (activeOptions.length > 1 && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { - event.preventDefault() - event.stopPropagation() - setSelectedIndex((current) => { - const delta = event.key === 'ArrowDown' ? 1 : -1 - return (current + delta + activeOptions.length) % activeOptions.length - }) - return + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (activeOptions.length > 0) { + event.preventDefault() + event.stopPropagation() + setSelectedIndex((current) => { + const delta = event.key === 'ArrowDown' ? 1 : -1 + return (current + delta + activeOptions.length) % activeOptions.length + }) + return + } + // Why: with no result rows the static create/agent items render below; + // move focus into that Radix menu list so it stays keyboard-navigable + // from the search box instead of trapping focus in the input. + if ( + focusMenuItemAtEdge(event.currentTarget, event.key === 'ArrowDown' ? 'first' : 'last') + ) { + event.preventDefault() + event.stopPropagation() + return + } } if (event.key !== 'Escape') { event.stopPropagation() @@ -209,6 +253,13 @@ export default function TabBarCreateEntry({ setError(null) }} disabled={disabled} + role="combobox" + aria-expanded={activeOptions.length > 0} + aria-controls={RESULT_LISTBOX_ID} + aria-autocomplete="list" + aria-activedescendant={ + activeOptions.length > 0 && !error ? resultOptionDomId(activeSelectedIndex) : undefined + } aria-label={translate( 'auto.components.tab.bar.TabBarCreateEntry.39676a184c', 'Open any file, URL, agent, ...' @@ -222,13 +273,18 @@ export default function TabBarCreateEntry({ /> {error || activeOptions.length > 0 || hasQuery ? ( -
+
0 && !error ? 'listbox' : undefined} + > {error ? ( ) : activeOptions.length > 0 ? ( activeOptions.map((option, index) => ( submitOption(option)} @@ -243,146 +299,20 @@ export default function TabBarCreateEntry({ ) } -type ActiveEntryOption = TabEntryOption & { - classification: TabEntryActionClassification -} - -type ActiveOption = - | { - kind: 'agent' - option: TabAgentLaunchOption - } - | { - kind: 'entry' - option: ActiveEntryOption - } - | { - kind: 'menu' - option: TabCreateMenuOption - } - -function isActiveEntryOption(option: TabEntryOption): option is ActiveEntryOption { - return option.classification.kind !== 'empty' && option.classification.kind !== 'blocked' -} - -function getActiveOptionId(option: ActiveOption): string { - if (option.kind === 'agent') { - return `agent:${option.option.agent}` +// Moves keyboard focus to the first/last enabled item of the enclosing Radix +// menu so the static create/agent list stays navigable from the search input. +function focusMenuItemAtEdge(fromElement: HTMLElement, edge: 'first' | 'last'): boolean { + const menu = fromElement.closest('[role="menu"]') + if (!menu) { + return false } - if (option.kind === 'menu') { - return `menu:${option.option.id}` - } - return option.option.id -} - -function EntryStatusRow({ - loading = false, - message -}: { - loading?: boolean - message: string -}): React.JSX.Element { - return ( -
- {loading ?
+ const items = menu.querySelectorAll( + '[role="menuitem"]:not([data-disabled]):not([aria-disabled="true"])' ) -} - -function EntryActionRow({ - onClick, - option, - selected -}: { - onClick: () => void - option: ActiveOption - selected: boolean -}): React.JSX.Element { - const presentation = getActionPresentation(option) - - return ( - - ) -} - -function getActionPresentation(option: ActiveOption): { - detail: string - icon: React.ReactNode - label: string - showDetail: boolean -} { - if (option.kind === 'menu') { - const icon = - option.option.kind === 'new-browser' ? ( -