diff --git a/apps/desktop/src/main/__tests__/expected-failure-copy.test.ts b/apps/desktop/src/main/__tests__/expected-failure-copy.test.ts new file mode 100644 index 0000000000..855de0ff10 --- /dev/null +++ b/apps/desktop/src/main/__tests__/expected-failure-copy.test.ts @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { + getProviderSettingsCopy, + providerPanelActionErrorMessage, +} from '../../renderer/features/connection-settings/index.js'; +import { + getSettingsProjectsCopy, + runtimeHostManagementErrorMessage, +} from '../../renderer/locales/settings-projects-copy.js'; + +test('Runtime Host management codes render per locale and unknown codes fall back', () => { + const rendered = (code: string) => ({ + zh: runtimeHostManagementErrorMessage(code, 'zh'), + en: runtimeHostManagementErrorMessage(code, 'en'), + }); + assert.deepEqual(rendered('active_tasks'), { + zh: 'Runtime Host 正在执行任务,请稍后再试', + en: 'Runtime Host still owns active work. Try again later.', + }); + assert.deepEqual(rendered('linger_disabled'), { + zh: '请先为当前用户启用 systemd linger,服务才能在登出后继续运行', + en: 'Enable systemd linger for this user so the service keeps running after logout.', + }); + assert.deepEqual(rendered('package_integrity_mismatch'), { + zh: '更新包校验失败', + en: 'The update package failed its integrity check.', + }); + // Commit-outcome-unknown codes fold into the generic log-pointer (managed-deployment.ts, update-policy-store.ts). + const unknownFallback = { + zh: '请查看服务日志了解详情', + en: 'Check the service logs for details.', + }; + for (const code of [ + 'deployment_commit_unknown', + 'update_policy_commit_outcome_unknown', + 'some_future_code', + ]) { + assert.deepEqual(rendered(code), unknownFallback); + } +}); + +function extractErrorCodes(source: string, className: string): string[] { + const classIndex = source.indexOf(`class ${className}`); + assert.ok(classIndex !== -1, `expected ${className} in CLI source`); + const classBlock = source.slice(classIndex); + const codeIndex = classBlock.indexOf('readonly code:'); + const messageIndex = classBlock.indexOf('message: string'); + assert.ok(codeIndex !== -1 && messageIndex !== -1 && codeIndex < messageIndex); + const codes = classBlock + .slice(codeIndex, messageIndex) + .match(/'([a-z_]+)'/gu) + ?.map((literal) => literal.slice(1, -1)); + assert.ok(codes && codes.length > 0, `expected the ${className} code union`); + return codes; +} + +function readCliSource(fileName: string): string { + return readFileSync( + new URL(`../../../../../packages/cli/src/${fileName}`, import.meta.url), + 'utf8', + ); +} + +// [source file, error class] pairs presented distinctly. The two inline codes +// below have no error class (reconciliation, update-command). +const CLI_ERROR_SOURCES = [ + ['runtime-host-service-manager.ts', 'RuntimeHostServiceManagerError'], + ['runtime-host-registry-update.ts', 'RuntimeHostUpdateDiscoveryError'], + ['runtime-host-update-package.ts', 'RuntimeHostUpdatePackageError'], + ['runtime-host-update-policy-store.ts', 'RuntimeHostUpdatePolicyError'], +] as const; + +test('presenter maps every management code the CLI can emit', () => { + const extracted = CLI_ERROR_SOURCES.flatMap(([file, className]) => + extractErrorCodes(readCliSource(file), className), + ); + const codes = [...new Set([...extracted, 'update_policy_changed', 'update_not_admitted'])]; + assert.ok(codes.length >= 20, `expected the CLI code set, got ${codes.length}`); + const presented = Object.keys(getSettingsProjectsCopy('en').runtimeHost.managementError); + assert.deepEqual(codes.filter((code) => !presented.includes(code)), [ + 'update_policy_commit_outcome_unknown', + ]); + // Deployment-internal codes (managed-deployment.ts, …) intentionally fall back to unknown. +}); + +test('provider action errors never echo a raw Chinese message', () => { + const error = new Error('连接失败,请稍后重试'); + assert.equal( + providerPanelActionErrorMessage(error, 'zh'), + getProviderSettingsCopy('zh').shared.actionFallback, + ); + assert.equal( + providerPanelActionErrorMessage(error, 'en'), + getProviderSettingsCopy('en').shared.actionFallback, + ); +}); diff --git a/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts b/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts index 92dc3a9231..21afef3eff 100644 --- a/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts @@ -37,9 +37,6 @@ export function providerPanelActionErrorMessage(error: unknown, locale: UiLocale const cleaned = redactSecrets(cleanErrorMessage(error)).trim(); const known = (shared.lastTest as Readonly>)[cleaned.toLowerCase()]; if (known) return known; - // Main-process handlers throw display-ready Chinese copy; keep it instead - // of flattening it into a coarser classification or the generic fallback. - if (/[\u3400-\u9fff]/.test(cleaned)) return cleaned; if (/connection_stale|Unable to delete Connection: connection_stale/i.test(cleaned)) { return locale === 'zh' ? '连接状态已更新,请刷新列表后再删除。' diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx index c81f06ce6e..1706c636fc 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx @@ -634,8 +634,7 @@ function artifactActionErrorMessage(error: unknown, locale: UiLocale, copy: Arti const classified = locale === 'zh' ? generalizedErrorMessageChinese(new Error(raw), '') : generalizedErrorMessage(new Error(raw), ''); - if (classified) return classified; - return locale === 'zh' && /[\u4e00-\u9fff]/.test(raw) ? raw : copy.pane.actionFailed; + return classified || copy.pane.actionFailed; } function KindIcon(props: { kind: ArtifactKind }) { diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts index 3666a18ddd..8eb798c975 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -19,6 +19,35 @@ import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +// The operator CLI keeps code an open string for version skew; this is the subset Desktop presents distinctly. +export type RuntimeHostManagementErrorCode = + | 'active_tasks' + | 'not_installed' + | 'unsupported_platform' + | 'service_manager_unavailable' + | 'linger_disabled' + | 'invalid_config' + | 'invalid_launch' + | 'target_mismatch' + | 'configuration_changed' + | 'configuration_incomplete' + | 'retirement_failed' + | 'update_requires_retirement' + | 'update_incomplete' + | 'service_manager_operation_failed' + | 'uninstall_incomplete' + | 'deployment_io_failed' + | 'target_unavailable' + | 'registry_unavailable' + | 'invalid_registry_metadata' + | 'package_download_failed' + | 'package_integrity_mismatch' + | 'invalid_package' + | 'invalid_update_policy' + | 'update_policy_write_failed' + | 'update_policy_changed' + | 'update_not_admitted'; + export type SettingsProjectsCopy = { runtimeHost: { title: string; @@ -234,6 +263,7 @@ export type SettingsProjectsCopy = { uninstallConfirm: string; uninstallRetained(path: string): string; managementActionFailed: string; + managementError: Record; managementReconnectFailed: string; manageAccess: string; accessTitle: string; @@ -553,6 +583,35 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { uninstallConfirm: '卸载服务', uninstallRetained: (path: string) => `服务已卸载,数据保留在 ${path}`, managementActionFailed: '无法管理 Runtime Host 服务', + managementError: { + active_tasks: 'Runtime Host 正在执行任务,请稍后再试', + not_installed: '此 Runtime Host 服务尚未安装', + unsupported_platform: '当前系统不支持受管理的 Runtime Host 服务', + service_manager_unavailable: '系统服务管理器(systemd、launchd 或 OpenRC)不可用', + linger_disabled: '请先为当前用户启用 systemd linger,服务才能在登出后继续运行', + invalid_config: 'Runtime Host 服务配置无效', + invalid_launch: 'Runtime Host 服务启动参数无效,请重新安装', + target_mismatch: '服务已被其他安装接管,请刷新后重试', + configuration_changed: '服务配置已在别处修改,请刷新后重试', + configuration_incomplete: '服务配置不完整,请重新安装', + retirement_failed: '无法安全停止当前 Runtime Host', + update_requires_retirement: '更新前需要先停止当前 Runtime Host', + update_incomplete: '更新未完成,请查看服务日志', + service_manager_operation_failed: '系统服务管理器操作失败,请查看服务日志', + uninstall_incomplete: '卸载未完成,请重试', + deployment_io_failed: '无法写入 Runtime Host 部署文件', + target_unavailable: '找不到所选版本', + registry_unavailable: '无法连接更新源,请检查网络', + invalid_registry_metadata: '更新源返回了无效的版本信息', + package_download_failed: '更新包下载失败', + package_integrity_mismatch: '更新包校验失败', + invalid_package: '更新包无效', + invalid_update_policy: '更新策略无效', + update_policy_write_failed: '无法保存更新策略', + update_policy_changed: '更新策略已变化,请刷新后重试', + update_not_admitted: '当前版本不允许此更新', + unknown: '请查看服务日志了解详情', + }, managementReconnectFailed: '更改已应用,但 Desktop 未能重新连接', manageAccess: '管理访问权限', accessTitle: '访问权限', @@ -872,6 +931,38 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { uninstallConfirm: 'Uninstall service', uninstallRetained: (path: string) => `Service uninstalled. Data was retained at ${path}`, managementActionFailed: 'Unable to manage the Runtime Host service', + managementError: { + active_tasks: 'Runtime Host still owns active work. Try again later.', + not_installed: 'This Runtime Host service is not installed.', + unsupported_platform: 'Managed Runtime Host services are not supported on this platform.', + service_manager_unavailable: + 'The system service manager (systemd, launchd, or OpenRC) is unavailable.', + linger_disabled: + 'Enable systemd linger for this user so the service keeps running after logout.', + invalid_config: 'The Runtime Host service configuration is invalid.', + invalid_launch: 'The Runtime Host service launch definition is invalid. Reinstall the service.', + target_mismatch: 'Another installation now owns this service. Refresh and try again.', + configuration_changed: 'The service configuration changed elsewhere. Refresh and try again.', + configuration_incomplete: 'The service configuration is incomplete. Reinstall the service.', + retirement_failed: 'The current Runtime Host could not be stopped safely.', + update_requires_retirement: 'Stop the current Runtime Host before updating.', + update_incomplete: 'The update did not complete. Check the service logs.', + service_manager_operation_failed: + 'The system service manager operation failed. Check the service logs.', + uninstall_incomplete: 'The uninstall did not complete. Try again.', + deployment_io_failed: 'Runtime Host deployment files could not be written.', + target_unavailable: 'The selected version is unavailable.', + registry_unavailable: 'The update registry is unreachable. Check the network.', + invalid_registry_metadata: 'The update registry returned invalid version metadata.', + package_download_failed: 'The update package could not be downloaded.', + package_integrity_mismatch: 'The update package failed its integrity check.', + invalid_package: 'The update package is invalid.', + invalid_update_policy: 'The update policy is invalid.', + update_policy_write_failed: 'The update policy could not be saved.', + update_policy_changed: 'The update policy changed. Refresh and try again.', + update_not_admitted: 'This update is not permitted for the installed version.', + unknown: 'Check the service logs for details.', + }, managementReconnectFailed: 'Change applied, but Desktop could not reconnect', manageAccess: 'Manage access', accessTitle: 'Access', @@ -948,3 +1039,8 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { export function getSettingsProjectsCopy(locale: UiLocale): SettingsProjectsCopy { return SETTINGS_PROJECTS_COPY_BY_LOCALE[locale]; } + +export function runtimeHostManagementErrorMessage(code: string, locale: UiLocale): string { + const messages = getSettingsProjectsCopy(locale).runtimeHost.managementError; + return (messages as Record)[code] ?? messages.unknown; +} diff --git a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx index 14e8316f7d..778243896d 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx @@ -48,7 +48,10 @@ import type { DesktopRuntimeHostUpdateReconciliationOutcome, DesktopRuntimeHostUpdateReconciliationResponse, } from '../../preload/bridge-contract.js'; -import { getSettingsProjectsCopy } from '../locales/settings-projects-copy.js'; +import { + getSettingsProjectsCopy, + runtimeHostManagementErrorMessage, +} from '../locales/settings-projects-copy.js'; import { canonicalProjectDirectoryRoots, projectDirectoryRootsValid, @@ -188,7 +191,9 @@ export function RuntimeHostManagementDialog(props: { reconcileDirectoryPolicy(response.service); shouldLoadUpdatePolicy = response.service.state !== 'not_installed'; } - else if (response.kind === 'error') setError(response.error.message); + else if (response.kind === 'error') { + setError(runtimeHostManagementErrorMessage(response.error.code, locale)); + } else setUninstalledRoot(response.retainedStateRoot); } catch (failure) { if (!disposed) setError(settingsActionErrorMessage(failure, locale)); @@ -229,6 +234,12 @@ export function RuntimeHostManagementDialog(props: { if (logs) logs.scrollTop = logs.scrollHeight; }, [result]); + function reportManagementError(code: string): string { + const message = runtimeHostManagementErrorMessage(code, locale); + toast.error(copy.managementActionFailed, message); + return message; + } + async function run( action: DesktopRuntimeHostManagementAction, allowInterruptActiveTasks = false, @@ -256,8 +267,7 @@ export function RuntimeHostManagementDialog(props: { return; } setUpdatePolicy(undefined); - setError(response.error.message); - toast.error(copy.managementActionFailed, response.error.message); + setError(reportManagementError(response.error.code)); return; } if (response.kind === 'uninstalled') { @@ -375,8 +385,7 @@ export function RuntimeHostManagementDialog(props: { ); if (response.kind === 'error') { setUpdatePolicy(undefined); - setError(response.error.message); - toast.error(copy.managementActionFailed, response.error.message); + setError(reportManagementError(response.error.code)); return; } if (response.kind === 'uninstalled') { @@ -461,8 +470,7 @@ export function RuntimeHostManagementDialog(props: { allowInterruptActiveTasks, ); if (response.kind === 'error') { - setError(response.error.message); - toast.error(copy.managementActionFailed, response.error.message); + setError(reportManagementError(response.error.code)); return; } if (response.kind === 'uninstalled' || response.action !== 'configure') { @@ -545,8 +553,7 @@ export function RuntimeHostManagementDialog(props: { const response = await window.maka.runtimeHostManagement.reconcileUpdate(target.id); if (response.kind === 'error') { setUpdatePolicy(undefined); - setUpdatePolicyError(response.error.message); - toast.error(copy.managementActionFailed, response.error.message); + setUpdatePolicyError(reportManagementError(response.error.code)); return; } setLastUpdateOutcome(response.reconciliation); @@ -584,12 +591,13 @@ export function RuntimeHostManagementDialog(props: { } function applyReconnectWarning( - reconnectError: { readonly message: string } | undefined, + reconnectError: { readonly code: string; readonly message: string } | undefined, ): void { - setReconnectWarning(reconnectError?.message); - if (reconnectError) { - toast.warning(copy.managementReconnectFailed, reconnectError.message); - } + setReconnectWarning(reconnectError ? copy.managementReconnectFailed : undefined); + if (!reconnectError) return; + // Raw operator detail goes to diagnostics only; users see the catalog line. + console.error('[runtime-host] reconnect failed', reconnectError); + toast.warning(copy.managementReconnectFailed); } async function revokeCredential(): Promise { @@ -669,8 +677,7 @@ export function RuntimeHostManagementDialog(props: { {reconnectWarning ? ( ) : null} {confirmation?.kind === 'configureDirectories' ? ( diff --git a/packages/ui/src/__tests__/search-modal-source.test.ts b/packages/ui/src/__tests__/search-modal-source.test.ts index 01c45b0672..0f8299e37c 100644 --- a/packages/ui/src/__tests__/search-modal-source.test.ts +++ b/packages/ui/src/__tests__/search-modal-source.test.ts @@ -21,7 +21,8 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { SearchResult } from '@maka/core/search'; -import { createThreadSearchSource } from '../search-modal.js'; +import { createThreadSearchSource, searchErrorText } from '../search-modal.js'; +import { getShellControlsCopy } from '../shell-controls-copy.js'; function result(sessionId: string): SearchResult { return { source: 'thread', @@ -66,7 +67,6 @@ function createHarness() { onItemsChange: (items) => { visibleItemIds = items.map((item) => item.id); }, - thrownErrorMessage: () => 'Thrown error', }); return { source, @@ -124,3 +124,20 @@ describe('thread search source', () => { assert.deepEqual(harness.getVisibleItemIds(), ['current-session::0']); }); }); + +describe('search error copy', () => { + it('maps the reasons thread search emits per locale and falls back for the rest', () => { + const zh = getShellControlsCopy('zh').search; + const en = getShellControlsCopy('en').search; + const mapped = ['incognito_active', 'invalid_query', 'aborted', 'disabled', 'provider_error']; + assert.deepEqual(Object.keys(zh.errorByReason).sort(), [...mapped].sort()); + assert.deepEqual(Object.keys(en.errorByReason).sort(), [...mapped].sort()); + assert.equal(searchErrorText('incognito_active', zh), '关闭隐私模式后可以继续按关键词查找历史任务。'); + assert.equal(searchErrorText('invalid_query', zh), '搜索词包含凭据内容,无法搜索。'); + assert.equal(searchErrorText('disabled', zh), '搜索当前不可用。'); + assert.equal(searchErrorText('aborted', en), 'Search was canceled.'); + assert.equal(searchErrorText('provider_error', en), 'Search failed. Try again.'); + assert.equal(searchErrorText('timeout', en), 'Search needs to be refreshed. Try again.'); + assert.equal(searchErrorText('timeout', zh), '搜索服务需要刷新,请重试。'); + }); +}); diff --git a/packages/ui/src/search-modal.tsx b/packages/ui/src/search-modal.tsx index ac13374e1a..76009efeda 100644 --- a/packages/ui/src/search-modal.tsx +++ b/packages/ui/src/search-modal.tsx @@ -19,8 +19,6 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core/search'; -import type { UiLocale } from '@maka/core/ui-locale'; -import { generalizedErrorMessage, generalizedErrorMessageChinese } from '@maka/core/redaction'; import { CommandPalette as AstryxCommandPalette, CommandPaletteFooter, @@ -59,7 +57,6 @@ interface ThreadSearchSourceInput { error: { reason: SearchErrorReason; message: string } | null, ): void; onItemsChange(items: SearchItem[]): void; - thrownErrorMessage(error: unknown): string; } export function createThreadSearchSource( @@ -112,9 +109,10 @@ export function createThreadSearchSource( return items; } catch (caught) { if (generation !== requestGeneration) return []; + console.error('[search] thread search failed', caught); input.onErrorChange({ reason: 'provider_error', - message: input.thrownErrorMessage(caught), + message: caught instanceof Error ? caught.message : String(caught), }); input.onItemsChange([]); return []; @@ -123,14 +121,11 @@ export function createThreadSearchSource( }; } -function searchModalThrownErrorMessage( - error: unknown, - locale: UiLocale, - fallback: string, +export function searchErrorText( + reason: SearchErrorReason, + copy: ReturnType['search'], ): string { - return locale === 'zh' - ? generalizedErrorMessageChinese(error, fallback) - : generalizedErrorMessage(error, fallback); + return (copy.errorByReason as Record)[reason] ?? copy.errorFallback; } /** @@ -193,26 +188,12 @@ export function SearchModal(props: { items.map((item) => [item.id, item]), ); }, - thrownErrorMessage: (caught) => - searchModalThrownErrorMessage( - caught, - locale, - copy.errorFallback, - ), }), - [ - copy.errorFallback, - copy.resultsLabel, - locale, - props.deps, - props.onNavigateToSession, - ], + [copy.resultsLabel, props.deps, props.onNavigateToSession], ); const emptySearchText = error - ? error.reason === 'incognito_active' - ? copy.privacyDetail - : error.message + ? searchErrorText(error.reason, copy) : copy.empty; return ( diff --git a/packages/ui/src/shell-controls-copy.ts b/packages/ui/src/shell-controls-copy.ts index c1d184544a..e9019e9de0 100644 --- a/packages/ui/src/shell-controls-copy.ts +++ b/packages/ui/src/shell-controls-copy.ts @@ -17,8 +17,14 @@ * under the License. */ +import type { SearchErrorReason } from '@maka/core/search'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +export type ThreadSearchErrorReason = Extract< + SearchErrorReason, + 'incognito_active' | 'invalid_query' | 'aborted' | 'disabled' | 'provider_error' +>; + type ShellControlsCopy = { shared: { close: string; @@ -41,7 +47,7 @@ type ShellControlsCopy = { statusRegionLabel: string; unavailable: string; privacyTitle: string; - privacyDetail: string; + errorByReason: Record; errorTitle: string; errorFallback: string; introduction: string; @@ -74,7 +80,13 @@ const SHELL_CONTROLS_COPY_BY_LOCALE = { statusRegionLabel: '搜索状态和结果', unavailable: '当前环境无法连接搜索后端,请稍后重试。', privacyTitle: '隐私模式已关闭搜索。', - privacyDetail: '关闭隐私模式后可以继续按关键词查找历史任务。', + errorByReason: { + incognito_active: '关闭隐私模式后可以继续按关键词查找历史任务。', + invalid_query: '搜索词包含凭据内容,无法搜索。', + aborted: '搜索已取消。', + disabled: '搜索当前不可用。', + provider_error: '搜索服务出错,请重试。', + }, errorTitle: '搜索暂时无法完成。', errorFallback: '搜索服务需要刷新,请重试。', introduction: '开始输入以按关键词查找历史任务。结果只包含任务标题和内容文本,不进入网络。', @@ -105,7 +117,13 @@ const SHELL_CONTROLS_COPY_BY_LOCALE = { statusRegionLabel: 'Search status and results', unavailable: 'Search is unavailable in the current environment. Try again later.', privacyTitle: 'Search is disabled in privacy mode.', - privacyDetail: 'Turn off privacy mode to search previous tasks by keyword.', + errorByReason: { + incognito_active: 'Turn off privacy mode to search previous tasks by keyword.', + invalid_query: 'The query contains credential material and cannot be searched.', + aborted: 'Search was canceled.', + disabled: 'Search is unavailable right now.', + provider_error: 'Search failed. Try again.', + }, errorTitle: 'Search could not be completed.', errorFallback: 'Search needs to be refreshed. Try again.', introduction: