From e4f85dfaddcf6ee3ca05b2265ba45bc26002e84e Mon Sep 17 00:00:00 2001 From: Yi-Ting Chiu Date: Sat, 2 May 2026 22:47:22 -0700 Subject: [PATCH 1/5] feat(ui): redesign History Explorer view Promote keyword recall to the primary Explorer action while keeping mode, filter, pagination, and detail interactions visible. Add a pure optional-AI availability gate so disabled semantic and hybrid controls explain the actual release, provider, or settings blocker through the locale catalogs. --- src/lib/i18n/catalog/explorer.ts | 61 +++++ src/lib/optional-ai-availability.test.ts | 137 ++++++++++ src/lib/optional-ai-availability.ts | 104 ++++++++ src/pages/explorer/index.test.tsx | 83 +++++- src/pages/explorer/index.tsx | 72 ++++- .../explorer/query-filters-panel.test.tsx | 195 ++++++++++---- src/pages/explorer/query-filters-panel.tsx | 248 ++++++++++-------- src/styles/app/explorer.css | 99 +++++++ src/styles/app/shared-utilities.css | 13 + 9 files changed, 839 insertions(+), 173 deletions(-) create mode 100644 src/lib/optional-ai-availability.test.ts create mode 100644 src/lib/optional-ai-availability.ts diff --git a/src/lib/i18n/catalog/explorer.ts b/src/lib/i18n/catalog/explorer.ts index dae88a72..e92c34f7 100644 --- a/src/lib/i18n/catalog/explorer.ts +++ b/src/lib/i18n/catalog/explorer.ts @@ -52,6 +52,27 @@ export const explorerNamespaceCatalog = { 'Semantic and hybrid search need embeddings and a vector index, so they are disabled in v0.1. Keyword search below still works against your local archive.', optionalAiDeferredTooltip: 'Semantic and hybrid search are coming in a future update.', + optionalAiUnavailableReleaseDeferred: 'Smart search is coming in v0.2.', + optionalAiUnavailableAiDisabled: + 'Enable AI in Settings before using smart search.', + optionalAiUnavailableNoProvider: + 'Choose an embedding provider in Settings to enable smart search.', + optionalAiUnavailableProviderError: + 'The embedding provider has an error. Fix it in Settings before using smart search.', + optionalAiNoProviderTitle: 'Choose an embedding provider', + optionalAiNoProviderBody: + 'Smart search needs an embedding provider. Add one in Settings → AI to enable semantic and hybrid search.', + optionalAiDisabledTitle: 'AI is turned off', + optionalAiDisabledBody: + 'Smart search needs AI and semantic indexing enabled in Settings before semantic and hybrid search can run.', + optionalAiProviderErrorTitle: 'Embedding provider has an error', + optionalAiProviderErrorBody: + 'Check Settings → AI to fix the embedding provider before retrying smart search.', + optionalAiOpenSettings: 'Open Settings', + searchHeroEyebrow: 'SEARCH HISTORY', + searchHeroPlaceholder: 'Type to search your history…', + searchHeroLabel: 'Search mode', + recentSearchesEyebrow: 'RECENT', semanticStatusEyebrow: 'SEMANTIC STATUS', semanticRecallTitle: 'SEMANTIC RECALL', noSemanticEyebrow: 'SMART SEARCH', @@ -200,6 +221,26 @@ export const explorerNamespaceCatalog = { optionalAiDeferredBody: '语义搜索和混合搜索需要 embedding 与向量索引,所以 v0.1 暂时禁用。下方关键词搜索仍会读取你的本地存档。', optionalAiDeferredTooltip: '语义搜索和混合搜索会在后续版本开放。', + optionalAiUnavailableReleaseDeferred: '智能搜索将在 v0.2 中开放。', + optionalAiUnavailableAiDisabled: '请先在设置中启用 AI,再使用智能搜索。', + optionalAiUnavailableNoProvider: + '请在设置中选择一个向量模型来启用智能搜索。', + optionalAiUnavailableProviderError: + '当前向量模型出现错误,请先在设置中修复后再使用智能搜索。', + optionalAiNoProviderTitle: '请选择一个向量模型', + optionalAiNoProviderBody: + '智能搜索需要一个向量模型。请在「设置 → AI」中添加,以启用语义和混合搜索。', + optionalAiDisabledTitle: 'AI 已关闭', + optionalAiDisabledBody: + '智能搜索需要先在「设置」中启用 AI 与语义索引,之后才能运行语义和混合搜索。', + optionalAiProviderErrorTitle: '向量模型出现错误', + optionalAiProviderErrorBody: + '请前往「设置 → AI」修复向量模型,然后再重试智能搜索。', + optionalAiOpenSettings: '打开设置', + searchHeroEyebrow: '搜索历史', + searchHeroPlaceholder: '输入关键词搜索你的历史记录…', + searchHeroLabel: '搜索模式', + recentSearchesEyebrow: '最近搜索', semanticStatusEyebrow: '智能搜索状态', semanticRecallTitle: '智能搜索召回', noSemanticEyebrow: '智能搜索', @@ -345,6 +386,26 @@ export const explorerNamespaceCatalog = { optionalAiDeferredBody: '語義搜尋和混合搜尋需要 embedding 與向量索引,所以 v0.1 暫時停用。下方關鍵字搜尋仍會讀取你的本機封存。', optionalAiDeferredTooltip: '語義搜尋和混合搜尋會在後續版本開放。', + optionalAiUnavailableReleaseDeferred: '智慧搜尋會在 v0.2 開放。', + optionalAiUnavailableAiDisabled: '請先在設定中啟用 AI,再使用智慧搜尋。', + optionalAiUnavailableNoProvider: + '請在設定中選擇一個向量模型來啟用智慧搜尋。', + optionalAiUnavailableProviderError: + '目前的向量模型出現錯誤,請先在設定中修復後再使用智慧搜尋。', + optionalAiNoProviderTitle: '請選擇一個向量模型', + optionalAiNoProviderBody: + '智慧搜尋需要一個向量模型。請在「設定 → AI」中加入,以啟用語義與混合搜尋。', + optionalAiDisabledTitle: 'AI 已關閉', + optionalAiDisabledBody: + '智慧搜尋需要先在「設定」中啟用 AI 與語義索引,之後才能執行語義與混合搜尋。', + optionalAiProviderErrorTitle: '向量模型出現錯誤', + optionalAiProviderErrorBody: + '請前往「設定 → AI」修復向量模型,然後再重試智慧搜尋。', + optionalAiOpenSettings: '開啟設定', + searchHeroEyebrow: '搜尋歷史', + searchHeroPlaceholder: '輸入關鍵字搜尋你的歷史紀錄…', + searchHeroLabel: '搜尋模式', + recentSearchesEyebrow: '最近搜尋', semanticStatusEyebrow: '智慧搜尋狀態', semanticRecallTitle: '智慧搜尋召回', noSemanticEyebrow: '智慧搜尋', diff --git a/src/lib/optional-ai-availability.test.ts b/src/lib/optional-ai-availability.test.ts new file mode 100644 index 00000000..26e869db --- /dev/null +++ b/src/lib/optional-ai-availability.test.ts @@ -0,0 +1,137 @@ +/** + * @file optional-ai-availability.test.ts + * @description Unit coverage for the multi-condition optional-AI gate. + * @module lib/optional-ai-availability + * + * ## Responsibilities + * - Verify each gate condition is checked in priority order. + * - Verify the i18n key mapping covers every reason value. + * + * ## Not responsible for + * - Re-testing surfaces that consume the gate. + * + * ## Performance notes + * - Pure compute, runs in milliseconds. + */ + +import { describe, expect, test } from 'vitest' +import { + evaluateOptionalAiAvailability, + optionalAiUnavailableI18nKey, + type OptionalAiUnavailableReason, +} from './optional-ai-availability' + +describe('evaluateOptionalAiAvailability', () => { + test('flags release-deferred first when the release flag is off', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: false, + embeddingProviderId: 'provider-1', + aiStatusState: 'ready', + }), + ).toEqual({ available: false, reason: 'release-deferred' }) + }) + + test('flags release-deferred even when no provider is selected', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: false, + embeddingProviderId: null, + aiStatusState: 'failed', + }), + ).toEqual({ available: false, reason: 'release-deferred' }) + }) + + test('flags no-embedding-provider when release is enabled but provider is missing', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + embeddingProviderId: null, + aiStatusState: 'ready', + }), + ).toEqual({ available: false, reason: 'no-embedding-provider' }) + }) + + test('flags ai-disabled when release is enabled but AI is turned off', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + aiEnabled: false, + embeddingProviderId: 'provider-1', + aiStatusState: 'ready', + }), + ).toEqual({ available: false, reason: 'ai-disabled' }) + }) + + test('flags ai-disabled when runtime status says the index is disabled', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + embeddingProviderId: 'provider-1', + aiStatusState: 'disabled', + }), + ).toEqual({ available: false, reason: 'ai-disabled' }) + }) + + test.each(['failed', 'blocked', 'degraded'])( + 'flags embedding-provider-error when ai status is %s', + (state) => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + embeddingProviderId: 'provider-1', + aiStatusState: state, + }), + ).toEqual({ available: false, reason: 'embedding-provider-error' }) + }, + ) + + test('returns available when every condition is satisfied', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + embeddingProviderId: 'provider-1', + aiStatusState: 'ready', + }), + ).toEqual({ available: true, reason: null }) + }) + + test('treats missing or null AI state as available', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + embeddingProviderId: 'provider-1', + aiStatusState: null, + }), + ).toEqual({ available: true, reason: null }) + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + embeddingProviderId: 'provider-1', + }), + ).toEqual({ available: true, reason: null }) + }) + + test('treats unknown non-error AI states as available', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + embeddingProviderId: 'provider-1', + aiStatusState: 'rebuilding', + }), + ).toEqual({ available: true, reason: null }) + }) +}) + +describe('optionalAiUnavailableI18nKey', () => { + const cases: Array<[OptionalAiUnavailableReason, string]> = [ + ['release-deferred', 'optionalAiUnavailableReleaseDeferred'], + ['ai-disabled', 'optionalAiUnavailableAiDisabled'], + ['no-embedding-provider', 'optionalAiUnavailableNoProvider'], + ['embedding-provider-error', 'optionalAiUnavailableProviderError'], + ] + + test.each(cases)('maps %s to %s', (reason, expected) => { + expect(optionalAiUnavailableI18nKey(reason)).toBe(expected) + }) +}) diff --git a/src/lib/optional-ai-availability.ts b/src/lib/optional-ai-availability.ts new file mode 100644 index 00000000..4635738a --- /dev/null +++ b/src/lib/optional-ai-availability.ts @@ -0,0 +1,104 @@ +/** + * @file optional-ai-availability.ts + * @description Centralizes the multi-condition gate that decides whether optional AI + * surfaces (semantic / hybrid recall, assistant, MCP, etc.) are usable + * right now, and explains the specific reason when they are not. + * @module lib/optional-ai-availability + * + * ## Responsibilities + * - Combine release-flag, embedding-provider, and runtime AI-state signals into a + * single `{ available, reason }` value the UI can consume in one read. + * - Expose the reason alongside an i18n key so disabled buttons and callouts can + * tell the user the specific thing to fix instead of a generic "deferred" line. + * - Keep the gate itself release-fact aware without leaking provider config or + * queue snapshots into every consumer. + * + * ## Not responsible for + * - Owning the user-visible strings — locale catalogs remain the source of truth. + * - Triggering provider probes or kicking embedding builds; this module is pure. + * - Deciding which surface should react to which reason; consumers keep that. + * + * ## Dependencies + * - No runtime dependencies. The gate is intentionally a small pure function so + * tests stay deterministic and adoption stays cheap. + * + * ## Performance notes + * - Pure synchronous compute. Importing this module must not trigger IO or + * backend calls. + */ + +/** + * Names the specific reason optional AI is currently unavailable. + * + * The reason exists so the UI can give the user a concrete next step instead of + * a single "coming later" line that hides which dependency is the actual block. + */ +export type OptionalAiUnavailableReason = + | 'release-deferred' + | 'ai-disabled' + | 'no-embedding-provider' + | 'embedding-provider-error' + +/** + * Carries both the boolean gate and the specific reason it is closed so disabled + * buttons, tooltips, and callouts can share one source of truth. + */ +export interface OptionalAiAvailability { + available: boolean + reason: OptionalAiUnavailableReason | null +} + +/** + * The set of AI index states that should hard-block optional AI surfaces because + * the embedding pipeline is not delivering trustworthy semantic recall right + * now. + */ +const ERROR_STATES = new Set(['failed', 'blocked', 'degraded']) + +/** + * Combines the three independent signals that have to be true before optional + * AI surfaces may render their primary affordances. + * + * Order matters: callers see the most fundamental missing dependency first so a + * user who has not selected a provider is never told to "check the provider + * status" when there is no provider to check. + */ +export function evaluateOptionalAiAvailability(input: { + releaseEnabled: boolean + aiEnabled?: boolean + embeddingProviderId: string | null + aiStatusState?: string | null +}): OptionalAiAvailability { + if (!input.releaseEnabled) { + return { available: false, reason: 'release-deferred' } + } + if (input.aiEnabled === false || input.aiStatusState === 'disabled') { + return { available: false, reason: 'ai-disabled' } + } + if (!input.embeddingProviderId) { + return { available: false, reason: 'no-embedding-provider' } + } + if (input.aiStatusState && ERROR_STATES.has(input.aiStatusState)) { + return { available: false, reason: 'embedding-provider-error' } + } + return { available: true, reason: null } +} + +/** + * Maps each unavailable reason to a stable i18n key so route-level translators + * can resolve it without each surface duplicating the switch statement. + */ +export function optionalAiUnavailableI18nKey( + reason: OptionalAiUnavailableReason, +): string { + switch (reason) { + case 'release-deferred': + return 'optionalAiUnavailableReleaseDeferred' + case 'ai-disabled': + return 'optionalAiUnavailableAiDisabled' + case 'no-embedding-provider': + return 'optionalAiUnavailableNoProvider' + case 'embedding-provider-error': + return 'optionalAiUnavailableProviderError' + } +} diff --git a/src/pages/explorer/index.test.tsx b/src/pages/explorer/index.test.tsx index 4ab0adba..e5fb5bc6 100644 --- a/src/pages/explorer/index.test.tsx +++ b/src/pages/explorer/index.test.tsx @@ -23,12 +23,18 @@ import { beforeEach, describe, expect, test, vi } from 'vitest' import { ExplorerPage } from './index' const { + aiStatusMetaMock, + optionalAiFeaturesAvailableState, + selectedAiProviderMock, useExplorerDataMock, useExplorerFaviconsMock, useExplorerUrlStateMock, useProfileScopeMock, useShellDataMock, } = vi.hoisted(() => ({ + aiStatusMetaMock: vi.fn(), + optionalAiFeaturesAvailableState: { value: false }, + selectedAiProviderMock: vi.fn(), useExplorerDataMock: vi.fn(), useExplorerFaviconsMock: vi.fn(), useExplorerUrlStateMock: vi.fn(), @@ -49,8 +55,14 @@ vi.mock('../../lib/i18n', () => ({ })) vi.mock('../../lib/intelligence-ai-presentation', () => ({ - aiStatusMeta: () => ({ label: 'AI ready', tone: 'info' }), - selectedAiProvider: () => ({ id: 'provider-1', label: 'Local AI' }), + aiStatusMeta: aiStatusMetaMock, + selectedAiProvider: selectedAiProviderMock, +})) + +vi.mock('../../lib/release-capabilities', () => ({ + get optionalAiFeaturesAvailable() { + return optionalAiFeaturesAvailableState.value + }, })) vi.mock('../../lib/backend-client', () => ({ @@ -117,6 +129,12 @@ vi.mock('./timeline-bar', () => ({ describe('ExplorerPage route shell', () => { beforeEach(() => { vi.clearAllMocks() + optionalAiFeaturesAvailableState.value = false + aiStatusMetaMock.mockReturnValue({ label: 'AI ready', tone: 'info' }) + selectedAiProviderMock.mockReturnValue({ + id: 'provider-1', + label: 'Local AI', + }) useShellDataMock.mockReturnValue(defaultShellData()) useProfileScopeMock.mockReturnValue({ activeProfileId: 'chrome:Default' }) useExplorerFaviconsMock.mockReturnValue({ faviconCache: new Map() }) @@ -211,6 +229,53 @@ describe('ExplorerPage route shell', () => { expect(screen.queryByTestId('runtime-panel')).not.toBeInTheDocument() expect(screen.queryByTestId('semantic-panel')).not.toBeInTheDocument() }) + + test('shows fixable optional-AI repair copy for missing, failed, and disabled providers', () => { + optionalAiFeaturesAvailableState.value = true + selectedAiProviderMock.mockReturnValue(null) + + const { rerender } = renderExplorer() + + expect(screen.getByText('explorer.optionalAiNoProviderTitle')).toBeVisible() + expect( + screen.getByRole('link', { name: 'explorer.optionalAiOpenSettings' }), + ).toHaveAttribute('href', '/settings') + + selectedAiProviderMock.mockReturnValue({ + id: 'provider-1', + label: 'Local AI', + }) + useShellDataMock.mockReturnValue( + defaultShellData({ + snapshot: { + ...defaultShellData().snapshot, + aiStatus: { state: 'failed' }, + }, + }), + ) + rerender() + expect( + screen.getByText('explorer.optionalAiProviderErrorTitle'), + ).toBeVisible() + + useShellDataMock.mockReturnValue( + defaultShellData({ + snapshot: { + ...defaultShellData().snapshot, + config: { + ...defaultShellData().snapshot.config, + ai: { + ...defaultShellData().snapshot.config.ai, + enabled: false, + semanticIndexEnabled: true, + }, + }, + }, + }), + ) + rerender() + expect(screen.getByText('explorer.optionalAiDisabledTitle')).toBeVisible() + }) }) function renderExplorer() { @@ -225,8 +290,8 @@ function ExplorerWrapper() { ) } -function defaultShellData() { - return { +function defaultShellData(overrides: Record = {}) { + const shellData = { error: null, loading: false, refreshAppData: vi.fn(), @@ -242,13 +307,21 @@ function defaultShellData() { aiStatus: { state: 'ready' }, archiveStatus: { unlocked: true }, config: { - ai: { providers: [] }, + ai: { + enabled: true, + providers: [], + semanticIndexEnabled: true, + }, explorerBackgroundPrefetchPages: 1, initialized: true, selectedProfileIds: ['chrome:Default'], }, }, } + return { + ...shellData, + ...overrides, + } } function defaultUrlState(overrides: Record = {}) { diff --git a/src/pages/explorer/index.tsx b/src/pages/explorer/index.tsx index 08f297f1..ada98a18 100644 --- a/src/pages/explorer/index.tsx +++ b/src/pages/explorer/index.tsx @@ -28,6 +28,7 @@ import { aiStatusMeta, selectedAiProvider, } from '../../lib/intelligence-ai-presentation' +import { evaluateOptionalAiAvailability } from '../../lib/optional-ai-availability' import { optionalAiFeaturesAvailable } from '../../lib/release-capabilities' import { historyFaviconLookupKey } from './helpers' import { @@ -131,6 +132,23 @@ export function ExplorerPage() { const embeddingProvider = snapshot ? selectedAiProvider(snapshot.config.ai, 'embedding') : null + const optionalAiAvailability = useMemo( + () => + evaluateOptionalAiAvailability({ + releaseEnabled: optionalAiFeaturesAvailable, + aiEnabled: + snapshot?.config.ai.enabled && + snapshot.config.ai.semanticIndexEnabled, + embeddingProviderId: embeddingProvider?.id ?? null, + aiStatusState: snapshot?.aiStatus.state ?? null, + }), + [ + embeddingProvider?.id, + snapshot?.aiStatus.state, + snapshot?.config.ai.enabled, + snapshot?.config.ai.semanticIndexEnabled, + ], + ) const requestKey = useMemo( () => JSON.stringify({ currentQuery, refreshKey }), [currentQuery, refreshKey], @@ -188,7 +206,7 @@ export function ExplorerPage() { historyBlockedByInvalidRegex, labels, mode, - optionalAiAvailable: optionalAiFeaturesAvailable, + optionalAiAvailable: optionalAiAvailability.available, view, persistRecentSearch, refreshAppData, @@ -215,7 +233,7 @@ export function ExplorerPage() { : null const semanticLoading = archiveReady && - optionalAiFeaturesAvailable && + optionalAiAvailability.available && mode !== 'keyword' && Boolean(semanticQuery.query) && semanticState.requestKey !== semanticRequestKey @@ -274,6 +292,27 @@ export function ExplorerPage() { selectedGroupedVisitState?.key === groupedSelectionKey ? selectedGroupedVisitState.visit : null + const optionalAiReason = optionalAiAvailability.reason + const optionalAiFixableReason = + optionalAiReason === 'ai-disabled' || + optionalAiReason === 'no-embedding-provider' || + optionalAiReason === 'embedding-provider-error' + const optionalAiUnavailableTitle = + optionalAiReason === 'ai-disabled' + ? explorerT('optionalAiDisabledTitle') + : optionalAiReason === 'no-embedding-provider' + ? explorerT('optionalAiNoProviderTitle') + : optionalAiReason === 'embedding-provider-error' + ? explorerT('optionalAiProviderErrorTitle') + : explorerT('optionalAiDeferredTitle') + const optionalAiUnavailableBody = + optionalAiReason === 'ai-disabled' + ? explorerT('optionalAiDisabledBody') + : optionalAiReason === 'no-embedding-provider' + ? explorerT('optionalAiNoProviderBody') + : optionalAiReason === 'embedding-provider-error' + ? explorerT('optionalAiProviderErrorBody') + : explorerT('optionalAiDeferredBody') useEffect(() => { setHistoryPageInput(String(historyPage)) @@ -358,6 +397,7 @@ export function ExplorerPage() { explorerT={explorerT} intelligenceT={intelligenceT} mode={mode} + optionalAiAvailability={optionalAiAvailability} profileId={profileId} queryInput={queryInput} recentSearches={recentSearches} @@ -373,16 +413,34 @@ export function ExplorerPage() { visibleRecordCount={visibleTimeResults?.total ?? null} /> - {mode !== 'keyword' && !optionalAiFeaturesAvailable ? ( + {optionalAiFixableReason ? ( + + {explorerT('optionalAiOpenSettings')} + + } + /> + ) : null} + + {mode !== 'keyword' && + !optionalAiAvailability.available && + !optionalAiFixableReason ? ( ) : null} - {aiMeta && mode !== 'keyword' && optionalAiFeaturesAvailable && ( + {aiMeta && mode !== 'keyword' && optionalAiAvailability.available && ( )} - {mode !== 'keyword' && optionalAiFeaturesAvailable && ( + {mode !== 'keyword' && optionalAiAvailability.available && ( { test('wires active filter chips, mode/view controls, filters, and recent searches', async () => { const user = userEvent.setup() @@ -55,6 +67,7 @@ describe('ExplorerQueryFiltersPanel', () => { explorerT={explorerT} intelligenceT={intelligenceT} mode="keyword" + optionalAiAvailability={blockedByRelease} profileId="chrome:Default" queryInput="" recentSearches={[ @@ -97,6 +110,12 @@ describe('ExplorerQueryFiltersPanel', () => { ) expect(screen.getByLabelText(explorerT('filterEnd'))).toHaveValue('') expect(screen.queryByText('chrome:Default')).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: explorerT('modeSemantic') }), + ).toHaveAttribute( + 'title', + explorerT('optionalAiUnavailableReleaseDeferred'), + ) await user.click( screen.getByRole('button', { name: explorerT('removeFilter', { @@ -113,10 +132,10 @@ describe('ExplorerQueryFiltersPanel', () => { ) expect( screen.getByRole('button', { name: explorerT('modeSemantic') }), - ).toBeDisabled() + ).toHaveAttribute('aria-disabled', 'true') expect( screen.getByRole('button', { name: explorerT('modeHybrid') }), - ).toBeDisabled() + ).toHaveAttribute('aria-disabled', 'true') await user.click( screen.getByRole('button', { name: intelligenceT('viewModeSession') }), ) @@ -168,7 +187,7 @@ describe('ExplorerQueryFiltersPanel', () => { expect(recentParams.has('mode')).toBe(false) }) - test('renders regex validity and disabled grouped-view controls for semantic mode', async () => { + test('renders regex validity, disabled grouped-view controls, and the missing-provider tooltip for semantic mode', async () => { const user = userEvent.setup() const updateParam = vi.fn() @@ -183,6 +202,10 @@ describe('ExplorerQueryFiltersPanel', () => { explorerT={explorerT} intelligenceT={intelligenceT} mode="semantic" + optionalAiAvailability={{ + available: false, + reason: 'no-embedding-provider', + }} profileId="chrome:Default" queryInput="[" recentSearches={[]} @@ -207,6 +230,9 @@ describe('ExplorerQueryFiltersPanel', () => { screen.getByRole('button', { name: intelligenceT('viewModeSession') }), ).toBeDisabled() expect(screen.getByText(explorerT('recentFiltersEmpty'))).toBeVisible() + expect( + screen.getByRole('button', { name: explorerT('modeHybrid') }), + ).toHaveAttribute('title', explorerT('optionalAiUnavailableNoProvider')) await user.click( screen.getByRole('button', { name: explorerT('toggleRegex') }), @@ -219,60 +245,124 @@ describe('ExplorerQueryFiltersPanel', () => { expect(updateParam).toHaveBeenCalledWith('mode', null) }) - test('wires semantic and hybrid mode buttons when optional AI is release-enabled', async () => { + test('wires semantic and hybrid mode buttons when optional AI is available', async () => { const user = userEvent.setup() const updateParam = vi.fn() - vi.resetModules() - vi.doMock('../../lib/release-capabilities', () => ({ - deferredFeatureReleaseLabel: 'v0.2', - optionalAiFeaturesAvailable: true, - readableContentFetchAvailable: false, - })) - try { - const { ExplorerQueryFiltersPanel: EnabledExplorerQueryFiltersPanel } = - await import('./query-filters-panel') + render( + ''} + clearAllFilters={vi.fn()} + explicitProfileId={null} + explorerT={explorerT} + intelligenceT={intelligenceT} + mode="keyword" + optionalAiAvailability={availableNow} + profileId={null} + queryInput="" + recentSearches={[]} + regexMode={false} + regexValid={true} + searchParams={new URLSearchParams()} + selectedProfileIds={[]} + setQueryInput={vi.fn()} + setSearchParams={vi.fn()} + setView={vi.fn()} + updateParam={updateParam} + view="time" + visibleRecordCount={null} + />, + ) + + const semanticButton = screen.getByRole('button', { + name: explorerT('modeSemantic'), + }) + expect(semanticButton).not.toBeDisabled() + expect(semanticButton).not.toHaveAttribute('title') + + await user.click(semanticButton) + await user.click( + screen.getByRole('button', { name: explorerT('modeHybrid') }), + ) + + expect(updateParam).toHaveBeenCalledWith('mode', 'semantic') + expect(updateParam).toHaveBeenCalledWith('mode', 'hybrid') + }) + + test('surfaces the embedding-provider-error reason on the disabled mode chips', () => { + render( + ''} + clearAllFilters={vi.fn()} + explicitProfileId={null} + explorerT={explorerT} + intelligenceT={intelligenceT} + mode="keyword" + optionalAiAvailability={{ + available: false, + reason: 'embedding-provider-error', + }} + profileId={null} + queryInput="" + recentSearches={[]} + regexMode={false} + regexValid={true} + searchParams={new URLSearchParams()} + selectedProfileIds={[]} + setQueryInput={vi.fn()} + setSearchParams={vi.fn()} + setView={vi.fn()} + updateParam={vi.fn()} + view="time" + visibleRecordCount={null} + />, + ) - render( - ''} - clearAllFilters={vi.fn()} - explicitProfileId={null} - explorerT={explorerT} - intelligenceT={intelligenceT} - mode="keyword" - profileId={null} - queryInput="" - recentSearches={[]} - regexMode={false} - regexValid={true} - searchParams={new URLSearchParams()} - selectedProfileIds={[]} - setQueryInput={vi.fn()} - setSearchParams={vi.fn()} - setView={vi.fn()} - updateParam={updateParam} - view="time" - visibleRecordCount={null} - />, - ) + expect( + screen.getByRole('button', { name: explorerT('modeSemantic') }), + ).toHaveAttribute('title', explorerT('optionalAiUnavailableProviderError')) + }) - await user.click( - screen.getByRole('button', { name: explorerT('modeSemantic') }), - ) - await user.click( - screen.getByRole('button', { name: explorerT('modeHybrid') }), - ) + test('falls back to the release gate when no route-level optional-AI status is passed', () => { + render( + ''} + clearAllFilters={vi.fn()} + explicitProfileId={null} + explorerT={explorerT} + intelligenceT={intelligenceT} + mode="keyword" + profileId={null} + queryInput="" + recentSearches={[]} + regexMode={false} + regexValid={true} + searchParams={new URLSearchParams()} + selectedProfileIds={[]} + setQueryInput={vi.fn()} + setSearchParams={vi.fn()} + setView={vi.fn()} + updateParam={vi.fn()} + view="time" + visibleRecordCount={null} + />, + ) - expect(updateParam).toHaveBeenCalledWith('mode', 'semantic') - expect(updateParam).toHaveBeenCalledWith('mode', 'hybrid') - } finally { - vi.doUnmock('../../lib/release-capabilities') - vi.resetModules() - } + expect( + screen.getByRole('button', { name: explorerT('modeSemantic') }), + ).toHaveAttribute( + 'title', + explorerT('optionalAiUnavailableReleaseDeferred'), + ) }) test('clears optional filters and falls back to recent-search labels', async () => { @@ -291,6 +381,7 @@ describe('ExplorerQueryFiltersPanel', () => { explorerT={explorerT} intelligenceT={intelligenceT} mode="keyword" + optionalAiAvailability={blockedByRelease} profileId="chrome:Default" queryInput="sqlite" recentSearches={[ diff --git a/src/pages/explorer/query-filters-panel.tsx b/src/pages/explorer/query-filters-panel.tsx index 15a2d09d..ec22a38f 100644 --- a/src/pages/explorer/query-filters-panel.tsx +++ b/src/pages/explorer/query-filters-panel.tsx @@ -4,18 +4,24 @@ * @module pages/explorer * * ## Responsibilities - * - Render Explorer mode/view toggles and all filter inputs. - * - Render active filter chips and recent-search shortcuts. - * - Keep filter chrome mounted independently from the results loading lifecycle. + * - Render the primary keyword search affordance with the regex toggle and + * mode chips inline so search reads as the route's main job-to-be-done. + * - Render recent searches directly under the search input so re-running a + * prior recall stays one click away. + * - Render the view-by toggle and the secondary filter inputs as a distinct, + * visually quieter cluster so they do not compete with search. + * - Render the active-filter chip bar above the search hero so what is + * currently filtering the result set stays visible at all times. * * ## Not responsible for * - Owning URL state or debouncing query changes. - * - Fetching Explorer results. + * - Fetching Explorer results or computing optional-AI availability. * - Rendering results, grouped views, or AI runtime state. * * ## Dependencies * - Depends on Explorer and Intelligence translator copy, selected profile ids, - * browser labels, and recent-search label helpers from the route owner. + * browser labels, recent-search label helpers, and the optional-AI + * availability descriptor from the route owner. * * ## Performance notes * - Render-only owner so Explorer can re-use the same filter shell while @@ -24,6 +30,11 @@ import { browserLabel } from './helpers' import { profileIdLabel } from '../../lib/profile-scope-context' +import { + evaluateOptionalAiAvailability, + optionalAiUnavailableI18nKey, + type OptionalAiAvailability, +} from '../../lib/optional-ai-availability' import { optionalAiFeaturesAvailable } from '../../lib/release-capabilities' import type { ExplorerMode, @@ -52,6 +63,7 @@ interface ExplorerQueryFiltersPanelProps { explorerT: Translator intelligenceT: Translator mode: ExplorerMode + optionalAiAvailability?: OptionalAiAvailability profileId: string | null queryInput: string recentSearches: RecentSearchEntry[] @@ -83,6 +95,7 @@ export function ExplorerQueryFiltersPanel({ explorerT, intelligenceT, mode, + optionalAiAvailability, profileId, queryInput, recentSearches, @@ -97,6 +110,20 @@ export function ExplorerQueryFiltersPanel({ view, visibleRecordCount, }: ExplorerQueryFiltersPanelProps) { + // The route owner can pass a richer availability object that explains the + // specific reason optional AI is closed. When it is absent we still honor + // the release flag so the panel stays safe in isolation tests and previews. + const aiAvailability = + optionalAiAvailability ?? + evaluateOptionalAiAvailability({ + releaseEnabled: optionalAiFeaturesAvailable, + embeddingProviderId: 'panel-default', + aiStatusState: null, + }) + const aiUnavailableTitle = aiAvailability.reason + ? explorerT(optionalAiUnavailableI18nKey(aiAvailability.reason)) + : undefined + return ( <> {activeFilters.length > 0 && ( @@ -136,7 +163,7 @@ export function ExplorerQueryFiltersPanel({ )} -
+
{explorerT('queryFiltersTitle')} @@ -145,79 +172,21 @@ export function ExplorerQueryFiltersPanel({ : explorerT('waitingForQuery')}
-
-
- {(['keyword', 'semantic', 'hybrid'] as const).map((option) => { - const disabled = - option !== 'keyword' && !optionalAiFeaturesAvailable - - return ( - - ) - })} -
-
- {(['time', 'session', 'trail'] as const).map((option) => ( - - ))} -
-
-
- - {explorerT('filterKeyword')} +
+
+
+ + {explorerT('searchHeroEyebrow')} {regexMode ? [.*] : null} -
+
setQueryInput(event.target.value)} @@ -246,6 +215,101 @@ export function ExplorerQueryFiltersPanel({ ) : null}
+
+ {(['keyword', 'semantic', 'hybrid'] as const).map((option) => { + const disabled = + option !== 'keyword' && !aiAvailability.available + + return ( + + ) + })} +
+
+ +
+ + {explorerT('recentSearchesEyebrow')} + + {recentSearches.length > 0 ? ( + recentSearches.map((entry) => ( + + )) + ) : ( + + {explorerT('recentFiltersEmpty')} + + )} +
+
+ +
+
+ + {intelligenceT('viewModeLabel')} + + {(['time', 'session', 'trail'] as const).map((option) => ( + + ))} +
+ +
-
-
- {recentSearches.length > 0 ? ( - recentSearches.map((entry) => ( - - )) - ) : ( - - {explorerT('recentFiltersEmpty')} - - )} -
-
) diff --git a/src/styles/app/explorer.css b/src/styles/app/explorer.css index e5d89471..c335770a 100644 --- a/src/styles/app/explorer.css +++ b/src/styles/app/explorer.css @@ -566,3 +566,102 @@ .detail-field.half { flex: 1; } + +/* ═══ SEARCH HERO (redesign: search is the primary route action) ═══ */ +.explorer-search-hero { + display: flex; + flex-direction: column; +} + +.explorer-search-hero__body { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.explorer-search-hero__row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--space-3); +} + +.explorer-search-hero__input-wrap { + flex: 1 1 320px; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.explorer-search-hero__kicker { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.explorer-search-hero__input-row { + display: flex; + align-items: stretch; + gap: var(--space-2); +} + +.explorer-search-hero__input { + flex: 1 1 auto; + min-width: 0; + font-size: 15px; + padding: 10px var(--space-3); + background: var(--bg-elevated); + border: 1px solid var(--border); + color: var(--text); + font-family: var(--font-sans); + transition: border-color var(--transition); +} + +.explorer-search-hero__input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent-glow); +} + +.explorer-search-hero__input.input-invalid { + border-color: var(--danger, #c0392b); +} + +.explorer-search-hero__modes { + display: flex; + gap: var(--space-2); + align-self: flex-end; +} + +.explorer-search-hero__recent { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + padding-top: var(--space-2); + border-top: 1px dashed var(--border); +} + +/* ═══ SECONDARY CONTROLS (view-by + filter inputs) ═══ */ +.explorer-secondary-controls { + display: flex; + flex-direction: column; + gap: var(--space-3); + border-top: 1px solid var(--border); + padding-top: var(--space-3); +} + +.explorer-view-by { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.explorer-view-by__label { + margin-right: var(--space-2); +} + +.explorer-filters--secondary { + opacity: 0.95; +} diff --git a/src/styles/app/shared-utilities.css b/src/styles/app/shared-utilities.css index f155f0db..00abd076 100644 --- a/src/styles/app/shared-utilities.css +++ b/src/styles/app/shared-utilities.css @@ -206,6 +206,19 @@ background: var(--accent-glow); } +.chip-button--muted, +.chip-button[aria-disabled='true'] { + opacity: 0.5; + cursor: not-allowed; +} + +.chip-button--muted:hover, +.chip-button[aria-disabled='true']:hover { + border-color: var(--border); + color: var(--text-muted); + background: transparent; +} + .code-panel { display: grid; gap: var(--space-3); From 2e048cffae37e524b77038434b5f05466ea9cd10 Mon Sep 17 00:00:00 2001 From: Yi-Ting Chiu Date: Sat, 2 May 2026 22:47:44 -0700 Subject: [PATCH 2/5] feat(ui): redesign Audit Ledger view Reshape the ledger around health triage, run comparison, and artifact review without changing the underlying audit data contract. Keep health counts scoped to the active non-severity filters, expose pending/blocked states truthfully, and cover the new triage behavior in tests and locale copy. --- src/lib/i18n/catalog/audit.ts | 34 ++++++ src/pages/audit/index.test.tsx | 107 +++++++++++++++++++ src/pages/audit/index.tsx | 165 ++++++++++++++++++++++++++---- src/styles/app/audit-schedule.css | 42 +++++++- 4 files changed, 327 insertions(+), 21 deletions(-) diff --git a/src/lib/i18n/catalog/audit.ts b/src/lib/i18n/catalog/audit.ts index 9af9f2f1..6651c866 100644 --- a/src/lib/i18n/catalog/audit.ts +++ b/src/lib/i18n/catalog/audit.ts @@ -147,6 +147,18 @@ export const auditNamespaceCatalog = { repairImports: 'Check imports', repairSchedule: 'Check schedule', repairSecurity: 'Check security', + ledgerHealthClear: 'Recent runs clear: {count}', + ledgerHealthLoading: 'Severity pending: {count}', + ledgerHealthLoadingBody: + 'Run details are still loading. Health status will refresh once severities resolve.', + severityPending: 'Loading severity', + ledgerHealthClearBody: + 'No warnings or blocked runs in the visible history. You can browse the timeline below for details.', + ledgerHealthIssues: 'Needs attention: {warning} · Blocked: {blocked}', + ledgerHealthIssuesBody: + 'Open a flagged run to review what changed, or jump straight to the troubleshooting page that owns the fix.', + triageShowWarning: 'Show {count} needing attention', + triageShowBlocked: 'Show {count} blocked', }, 'zh-CN': { loadingLedger: '加载审计日志…', @@ -267,6 +279,17 @@ export const auditNamespaceCatalog = { repairImports: '检查导入', repairSchedule: '检查定时备份', repairSecurity: '检查安全设置', + ledgerHealthClear: '最近 {count} 次运行都正常', + ledgerHealthLoading: '正在检查 {count} 次运行的严重程度…', + ledgerHealthLoadingBody: '运行详情仍在加载,严重程度解析后将自动刷新。', + severityPending: '正在加载严重程度', + ledgerHealthClearBody: + '可见历史中没有警告或阻塞记录。可以在下方时间线查看详情。', + ledgerHealthIssues: '{warning} 条需关注 · {blocked} 条已阻塞', + ledgerHealthIssuesBody: + '打开被标记的运行查看变更,或直接跳到对应的排查页面修复。', + triageShowWarning: '只看 {count} 条需关注', + triageShowBlocked: '只看 {count} 条阻塞', }, 'zh-TW': { loadingLedger: '載入稽核日誌…', @@ -387,5 +410,16 @@ export const auditNamespaceCatalog = { repairImports: '檢查匯入', repairSchedule: '檢查定時備份', repairSecurity: '檢查安全設定', + ledgerHealthClear: '最近 {count} 次執行都正常', + ledgerHealthLoading: '正在檢查 {count} 次執行的嚴重程度…', + ledgerHealthLoadingBody: '執行詳情仍在載入,嚴重程度解析後會自動重新整理。', + severityPending: '正在載入嚴重程度', + ledgerHealthClearBody: + '可見紀錄中沒有警告或阻擋。你可以在下方時間線查看詳情。', + ledgerHealthIssues: '{warning} 筆需注意 · {blocked} 筆已阻擋', + ledgerHealthIssuesBody: + '開啟被標記的執行查看變更,或直接跳到對應的排查頁面修正。', + triageShowWarning: '只看 {count} 筆需注意', + triageShowBlocked: '只看 {count} 筆已阻擋', }, } as const diff --git a/src/pages/audit/index.test.tsx b/src/pages/audit/index.test.tsx index 608c8738..15da5b07 100644 --- a/src/pages/audit/index.test.tsx +++ b/src/pages/audit/index.test.tsx @@ -235,6 +235,113 @@ describe('AuditPage route owner', () => { ) }) + test('triages warning and blocked runs from the health summary without dropping other active filters', async () => { + const user = userEvent.setup() + const runs = [ + runFixture(21, { + profileScope: ['chrome:Default'], + }), + runFixture(20, { + profileScope: ['chrome:Default'], + }), + runFixture(19, { + profileScope: ['safari:Personal'], + }), + ] + const details = { + 21: detailFixture(runs[0], { + errorMessage: 'manifest write failed', + }), + 20: detailFixture(runs[1], { + warnings: ['schedule drift'], + }), + 19: detailFixture(runs[2], { + warnings: ['safari warning outside active profile'], + }), + } + + shellDataMock.mockReturnValue( + shellFixture({ + snapshot: snapshotFixture({ + recentRuns: runs, + }), + }), + ) + auditDataMock.mockReturnValue( + auditDataFixture({ + detail: details[21], + detailCache: details, + detailSeverity: 'blocked', + }), + ) + + renderPage('/audit') + + await user.selectOptions(screen.getByLabelText('audit.filterProfile'), [ + 'chrome:Default', + ]) + expect( + screen.getByText('audit.ledgerHealthIssues:{"warning":1,"blocked":1}'), + ).toBeVisible() + + await user.click( + screen.getByRole('button', { + name: 'audit.triageShowBlocked:{"count":1}', + }), + ) + expect(screen.getByRole('button', { name: /#21/ })).toBeVisible() + expect( + screen.queryByRole('button', { name: /#20/ }), + ).not.toBeInTheDocument() + + await user.click( + screen.getByRole('button', { + name: 'audit.triageShowWarning:{"count":1}', + }), + ) + expect(screen.getByRole('button', { name: /#20/ })).toBeVisible() + expect( + screen.queryByRole('button', { name: /#21/ }), + ).not.toBeInTheDocument() + }) + + test('does not render warning triage when all visible repair runs are blocked', () => { + const runs = [runFixture(30)] + const details = { + 30: detailFixture(runs[0], { + errorMessage: 'manifest write failed', + }), + } + + shellDataMock.mockReturnValue( + shellFixture({ + snapshot: snapshotFixture({ + recentRuns: runs, + }), + }), + ) + auditDataMock.mockReturnValue( + auditDataFixture({ + detail: details[30], + detailCache: details, + detailSeverity: 'blocked', + }), + ) + + renderPage('/audit') + + expect( + screen.getByRole('button', { + name: 'audit.triageShowBlocked:{"count":1}', + }), + ).toBeVisible() + expect( + screen.queryByRole('button', { + name: /audit\.triageShowWarning/, + }), + ).not.toBeInTheDocument() + }) + test('covers mixed-source fallbacks, missing details, and detail gate states', async () => { const user = userEvent.setup() const runs = [ diff --git a/src/pages/audit/index.tsx b/src/pages/audit/index.tsx index 6eb77112..e4c773fc 100644 --- a/src/pages/audit/index.tsx +++ b/src/pages/audit/index.tsx @@ -202,6 +202,51 @@ export function AuditPage() { }), [detailCache, filters, indexedRuns], ) + const healthScopeRuns = useMemo( + () => + indexedRuns.filter((run) => { + const runType = run.runType ?? 'backup' + const nextDetail = detailCache[run.id] + if (filters.runType !== 'all' && runType !== filters.runType) { + return false + } + const profileScope = nextDetail?.profileScope ?? run.profileScope ?? [] + const sourceKinds = sourceKindFromProfileScope(profileScope) + if ( + filters.sourceKind !== 'all' && + !sourceKinds.includes(filters.sourceKind) + ) { + return false + } + if (filters.profileId !== 'all') { + const matchesProfile = + profileScope.length === 0 + ? filters.profileId === 'archive-wide' + : profileScope.includes(filters.profileId) + if (!matchesProfile) { + return false + } + } + if ( + filters.artifactType !== 'all' && + (!nextDetail || + !nextDetail.artifacts.some( + (artifact) => artifact.kind === filters.artifactType, + )) + ) { + return false + } + return true + }), + [ + detailCache, + filters.artifactType, + filters.profileId, + filters.runType, + filters.sourceKind, + indexedRuns, + ], + ) const selectedRunIndex = filteredRuns.findIndex((run) => run.id === runId) const previousVisibleRun = selectedRunIndex >= 0 ? (filteredRuns[selectedRunIndex + 1] ?? null) : null @@ -231,6 +276,63 @@ export function AuditPage() { const filtersLoading = indexedRuns.length > 0 && Object.keys(detailCache).length < indexedRuns.length + const severityCounts = useMemo(() => { + const counts = { clear: 0, warning: 0, blocked: 0, unknown: 0 } + for (const run of healthScopeRuns) { + const indexedDetail = detailCache[run.id] + if (!indexedDetail) { + counts.unknown += 1 + continue + } + const severity = auditSeverity(indexedDetail) + counts[severity] += 1 + } + return counts + }, [detailCache, healthScopeRuns]) + const ledgerNeedsRepair = severityCounts.warning + severityCounts.blocked > 0 + const ledgerSeverityHydrating = + !ledgerNeedsRepair && severityCounts.unknown > 0 + const applySeverity = useCallback( + (severity: AuditFilterState['severity']) => { + setFilters((current) => ({ ...current, severity })) + }, + [], + ) + const auditHealthActions = ledgerNeedsRepair ? ( +
+ {severityCounts.blocked > 0 ? ( + + ) : null} + {severityCounts.warning > 0 ? ( + + ) : null} + + {t('audit.repairImports')} + + + {t('audit.repairSchedule')} + + + {t('audit.repairSecurity')} + +
+ ) : null /** * Explains how source label works. @@ -329,22 +431,35 @@ export function AuditPage() { return (
- - {t('audit.repairImports')} - - - {t('audit.repairSchedule')} - - - {t('audit.repairSecurity')} - - + tone={ + severityCounts.blocked > 0 + ? 'danger' + : severityCounts.warning > 0 + ? 'warning' + : ledgerSeverityHydrating + ? 'info' + : 'success' + } + title={ + ledgerNeedsRepair + ? t('audit.ledgerHealthIssues', { + warning: severityCounts.warning, + blocked: severityCounts.blocked, + }) + : ledgerSeverityHydrating + ? t('audit.ledgerHealthLoading', { + count: severityCounts.unknown, + }) + : t('audit.ledgerHealthClear', { count: healthScopeRuns.length }) } + body={ + ledgerNeedsRepair + ? t('audit.ledgerHealthIssuesBody') + : ledgerSeverityHydrating + ? t('audit.ledgerHealthLoadingBody') + : t('audit.ledgerHealthClearBody') + } + actions={auditHealthActions} />
@@ -485,7 +600,11 @@ export function AuditPage() { const indexedDetail = detailCache[run.id] const severity = indexedDetail ? auditSeverity(indexedDetail) - : 'clear' + : null + const severityClass = severity ?? 'pending' + const severityLabel = severity + ? t(auditSeverityKey(severity)) + : t('audit.severityPending') const triggerLabel = t( runTriggerKey( run.trigger ?? indexedDetail?.trigger ?? 'manual', @@ -500,20 +619,26 @@ export function AuditPage() {
)} + {visibleFailedJobsCount > 0 ? ( + { + const target = document.getElementById( + 'jobs-recent-activity', + ) + if (!target) return + event.preventDefault() + const reduceMotion = + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)') + .matches + target.scrollIntoView({ + behavior: reduceMotion ? 'auto' : 'smooth', + block: 'start', + }) + if (!target.hasAttribute('tabindex')) { + target.setAttribute('tabindex', '-1') + } + target.focus({ preventScroll: true }) + }} + > + {jobsT('jumpToFailures', { count: visibleFailedJobsCount })} + + ) : null} {showQueueToggle ? (