diff --git a/docs/architecture/renderer-interaction-performance/plan.md b/docs/architecture/renderer-interaction-performance/plan.md new file mode 100644 index 0000000000..5f70347a97 --- /dev/null +++ b/docs/architecture/renderer-interaction-performance/plan.md @@ -0,0 +1,29 @@ +# Implementation Plan + +## MCP Marketplace + +- Add a typed batch route accepting a source and unique source IDs and returning installed IDs. +- Resolve the batch with one configuration read in `McpPresenter`; keep the existing single-item + method unchanged. +- Query only the newly returned marketplace page in the renderer, merge installed IDs into local + state, and track installation by server key. +- Replace overscroll-based retry with explicit error and retry UI while retaining near-bottom + pagination. + +## Renderer Motion And Access + +- Commit side-panel layout width once, animate only the panel surface with transform and opacity, + and remove the orphaned workspace-nav width-motion CSS. +- Convert tool-call and error disclosure triggers to buttons with `aria-expanded` and controlled + body IDs. Keep bodies mounted through collapse motion before unmounting expensive content. +- Reveal message toolbars on `focus-within`, enlarge action hit areas, and keep toolbar actions visible + for coarse pointers. +- Track a pending settings route around `router.push`, show a row-level spinner, and prefetch a route + component from pointer or keyboard focus. + +## Compatibility And Validation + +- No stored-data migration is required. +- Extend route-dispatcher, renderer-client, and component tests around the new behavior. +- Run formatting, generated i18n types, lint, typecheck, and focused renderer/main tests before the + full relevant test pass. diff --git a/docs/architecture/renderer-interaction-performance/spec.md b/docs/architecture/renderer-interaction-performance/spec.md new file mode 100644 index 0000000000..6282d4cf74 --- /dev/null +++ b/docs/architecture/renderer-interaction-performance/spec.md @@ -0,0 +1,43 @@ +# Renderer Interaction Performance + +## Problem + +Several renderer interactions still combine avoidable main-process round trips, layout-bound motion, +or hidden controls without keyboard-visible feedback. The largest confirmed cases are the MCP Router +marketplace, chat side panel motion, expandable message details, message toolbars, and lazy settings +navigation. + +## Goal + +Make these existing interactions responsive and predictable without redesigning the application or +replacing the chat rendering architecture. + +## Acceptance Criteria + +- MCP Router marketplace pages query installation state once per newly fetched batch and expose an + explicit retry action after load failure. +- A marketplace item cannot start duplicate installs and communicates its pending state. +- Opening or closing the chat side panel does not animate layout width on every frame; persisted + widths and drag resizing continue to work. +- Tool-call and error details use keyboard-operable disclosure controls with synchronized height, + opacity, and chevron motion. +- Message toolbar actions become visible for keyboard focus and retain a practical desktop hit area. +- Lazy settings navigation acknowledges a click immediately and clears pending feedback on success + or failure. +- Existing reduced-motion behavior applies to all new motion. +- Focused tests cover the new contracts, pending states, keyboard semantics, and motion classes. + +## Constraints + +- Keep the Presenter, typed route contract, and renderer client boundaries for MCP operations. +- Preserve the existing single-server installation query for compatibility. +- Preserve chat message windowing, scroll ownership, side-panel width persistence, and manual resize. +- Use existing motion tokens and visual components; add no new UI dependency. +- Keep all user-facing text in vue-i18n. + +## Non-Goals + +- Rewriting the chat renderer, message windowing, or Markdown renderer. +- Broadly splitting large renderer components without runtime profiling evidence. +- Removing all backdrop blur or changing the product's visual direction. +- Adding telemetry, a benchmark service, or a GitHub issue. diff --git a/docs/architecture/renderer-interaction-performance/tasks.md b/docs/architecture/renderer-interaction-performance/tasks.md new file mode 100644 index 0000000000..1a00f4d5aa --- /dev/null +++ b/docs/architecture/renderer-interaction-performance/tasks.md @@ -0,0 +1,13 @@ +# Tasks + +- [x] Add MCP batch installation-status contract, Presenter method, route dispatch, client method, + and contract tests. +- [x] Make MCP marketplace pagination incremental with pending install and explicit retry states. +- [x] Replace side-panel width motion with one-step layout commits and remove orphaned width-motion + CSS. +- [x] Make message disclosures and toolbar actions keyboard-visible and motion-consistent. +- [x] Add pending feedback and prefetch behavior to lazy settings navigation. +- [x] Fix the settings window class typo and close-button accessible label. +- [x] Add regression tests for contracts, pagination, pending states, semantics, and motion. +- [x] Run format, i18n, lint, typecheck, and tests. +- [x] Commit, push, and open a PR against `dev`. diff --git a/src/main/presenter/mcpPresenter/index.ts b/src/main/presenter/mcpPresenter/index.ts index 5dd1b9e4f3..ab5b10f203 100644 --- a/src/main/presenter/mcpPresenter/index.ts +++ b/src/main/presenter/mcpPresenter/index.ts @@ -393,6 +393,20 @@ export class McpPresenter implements IMCPPresenter { return false } + async listInstalledServerIds(source: string, sourceIds: string[]): Promise { + const requestedIds = new Set(sourceIds) + if (requestedIds.size === 0) return [] + + const installedIds = new Set() + const servers = await this.configPresenter.getMcpServers() + for (const config of Object.values(servers)) { + if (config.source === source && config.sourceId && requestedIds.has(config.sourceId)) { + installedIds.add(config.sourceId) + } + } + return [...installedIds] + } + async updateMcpRouterServersAuth(apiKey: string): Promise { const servers = await this.configPresenter.getMcpServers() const updates: Array<{ name: string; config: Partial }> = [] diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index 5bc832b6d4..449f6a66f2 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -167,6 +167,7 @@ import { mcpRouterGetApiKeyRoute, mcpRouterInstallServerRoute, mcpRouterIsServerInstalledRoute, + mcpRouterListInstalledServerIdsRoute, mcpRouterListServersRoute, mcpRouterSetApiKeyRoute, mcpRouterUpdateServersAuthRoute, @@ -3921,6 +3922,14 @@ export async function dispatchDeepchatRoute( }) } + case mcpRouterListInstalledServerIdsRoute.name: { + const input = mcpRouterListInstalledServerIdsRoute.input.parse(rawInput) + return mcpRouterListInstalledServerIdsRoute.output.parse({ + installedSourceIds: + (await runtime.mcpPresenter.listInstalledServerIds?.(input.source, input.sourceIds)) ?? [] + }) + } + case mcpRouterUpdateServersAuthRoute.name: { const input = mcpRouterUpdateServersAuthRoute.input.parse(rawInput) await runtime.mcpPresenter.updateMcpRouterServersAuth?.(input.apiKey) diff --git a/src/renderer/api/McpClient.ts b/src/renderer/api/McpClient.ts index b7ff08218f..fb04ddb0b4 100644 --- a/src/renderer/api/McpClient.ts +++ b/src/renderer/api/McpClient.ts @@ -33,6 +33,7 @@ import { mcpRouterGetApiKeyRoute, mcpRouterInstallServerRoute, mcpRouterIsServerInstalledRoute, + mcpRouterListInstalledServerIdsRoute, mcpRouterListServersRoute, mcpRouterSetApiKeyRoute, mcpRouterUpdateServersAuthRoute, @@ -217,6 +218,14 @@ export function createMcpClient(bridge: DeepchatBridge = getDeepchatBridge()) { return result.installed } + async function listInstalledServerIds(source: string, sourceIds: string[]) { + const result = await bridge.invoke(mcpRouterListInstalledServerIdsRoute.name, { + source, + sourceIds + }) + return result.installedSourceIds + } + async function updateMcpRouterServersAuth(apiKey: string) { await bridge.invoke(mcpRouterUpdateServersAuthRoute.name, { apiKey }) } @@ -317,6 +326,7 @@ export function createMcpClient(bridge: DeepchatBridge = getDeepchatBridge()) { getMcpRouterApiKey, setMcpRouterApiKey, isServerInstalled, + listInstalledServerIds, updateMcpRouterServersAuth, onServerStarted, onServerStopped, diff --git a/src/renderer/settings/App.vue b/src/renderer/settings/App.vue index 3b4240201b..7fd8054a0a 100644 --- a/src/renderer/settings/App.vue +++ b/src/renderer/settings/App.vue @@ -7,7 +7,7 @@
@@ -15,6 +15,8 @@
@@ -116,6 +128,7 @@ import { } from '@shared/settingsNavigation' import type { SettingsNavigationPayload } from '@shared/settingsNavigation' import { useStartupWorkloadStore } from '@/stores/startupWorkloadStore' +import { preloadSettingsRoute } from './settingsRouteComponents' const DATABASE_REPAIR_SECTION = 'database-repair' const SETTINGS_SECTION_EVENT = 'deepchat:settings-section' @@ -173,6 +186,7 @@ const pendingProviderImportToken = computed(() => providerDeeplinkImportStore.pr const isProcessingProviderPreview = ref(false) const startupTimeOrigin = typeof performance !== 'undefined' ? performance.now() : Date.now() const hasLoggedFirstRouteResolved = ref(false) +const pendingRouteName = ref(null) const logSettingsStartup = (phase: string) => { const now = typeof performance !== 'undefined' ? performance.now() : Date.now() @@ -517,8 +531,33 @@ watch( { immediate: true } ) -const handleClick = (path: string) => { - router.push(path) +type SettingsNavigationItem = { + name: string + path: string +} + +const handleClick = async (setting: SettingsNavigationItem) => { + if (pendingRouteName.value || route.path === setting.path) return + + pendingRouteName.value = setting.name + try { + await router.push(setting.path) + } catch (error) { + console.error(`[Settings] Failed to navigate to ${setting.name}:`, error) + } finally { + if (pendingRouteName.value === setting.name) { + pendingRouteName.value = null + } + } +} + +const prefetchSetting = (routeName: string) => { + const preload = preloadSettingsRoute(routeName) + if (preload) { + void preload.catch((error) => { + console.debug(`[Settings] Failed to prefetch ${routeName}:`, error) + }) + } } const SETTINGS_TAB_TEST_IDS: Record = { diff --git a/src/renderer/settings/components/McpBuiltinMarket.vue b/src/renderer/settings/components/McpBuiltinMarket.vue index aff8148a8b..87be12c972 100644 --- a/src/renderer/settings/components/McpBuiltinMarket.vue +++ b/src/renderer/settings/components/McpBuiltinMarket.vue @@ -31,7 +31,14 @@ :placeholder="t('mcp.market.apiKeyPlaceholder')" class="w-64" /> - + @@ -81,7 +88,9 @@ @@ -108,17 +128,25 @@ {{ t('common.loading') }} -
- {{ t('mcp.market.pullDownToLoad') }} +
+ {{ t('common.error.operationFailed') }} +
{{ t('mcp.market.noMore') }}
{{ t('mcp.market.empty') }} @@ -174,11 +202,12 @@ const limit = ref(20) const loading = ref(false) const hasMore = ref(true) const scrollContainer = ref(null) -const showPullToLoad = ref(false) -const canPullMore = ref(false) const installedServers = ref>(new Set()) +const installingServerKeys = ref>(new Set()) +const loadError = ref(null) const apiKeyInput = ref('') +const savingApiKey = ref(false) const loadApiKey = async () => { try { @@ -188,6 +217,8 @@ const loadApiKey = async () => { } const saveApiKey = async () => { + if (savingApiKey.value) return + savingApiKey.value = true try { const newKey = apiKeyInput.value.trim() await mcpClient.setMcpRouterApiKey(newKey) @@ -204,6 +235,8 @@ const saveApiKey = async () => { description: String(e), variant: 'destructive' }) + } finally { + savingApiKey.value = false } } @@ -211,56 +244,41 @@ const openHowToGetKey = () => { window.open('https://mcprouter.co/settings/keys', '_blank') } -const checkInstalledServers = async () => { - const installed = new Set() - for (const item of items.value) { - try { - // 使用 server_key 作为 sourceId 检查安装状态,因为这是我们在安装时保存的标识符 - const isInstalled = await mcpClient.isServerInstalled('mcprouter', item.server_key) - if (isInstalled) { - installed.add(item.server_key) - } - } catch (e) { - console.error('Failed to check installation status:', e) - } +const mergeInstalledServers = async (marketItems: MarketItem[]) => { + const sourceIds = [...new Set(marketItems.map((item) => item.server_key))] + if (sourceIds.length === 0) return + + try { + const installedIds = await mcpClient.listInstalledServerIds('mcprouter', sourceIds) + installedServers.value = new Set([...installedServers.value, ...installedIds]) + } catch (error) { + console.error('Failed to check MCP Router installation status:', error) } - installedServers.value = installed } -const fetchPage = async (forcePull = false) => { - if (loading.value || (!hasMore.value && !forcePull)) return +const fetchPage = async () => { + if (loading.value || !hasMore.value) return loading.value = true - showPullToLoad.value = false + loadError.value = null try { const data = await mcpClient.listMcpRouterServers(page.value, limit.value) const list = data?.servers || [] if (list.length === 0) { hasMore.value = false - canPullMore.value = false return } + await mergeInstalledServers(list) items.value.push(...list) page.value += 1 - - // 检查安装状态 - await checkInstalledServers() - - // 如果是强制拉取且成功获取到数据,重新启用拉取功能 - if (forcePull) { - hasMore.value = true - canPullMore.value = true - } + hasMore.value = list.length >= limit.value } catch (e) { + loadError.value = e toast({ title: t('settings.provider.operationFailed'), description: String(e), variant: 'destructive' }) - // 错误时重置状态 - if (forcePull) { - canPullMore.value = false - } } finally { loading.value = false } @@ -277,30 +295,18 @@ const onScroll = () => { // 正常滚动加载 if (hasMore.value && nearBottom) { - fetchPage() - return - } - - // 检测过度滚动(内容不足一屏或已滚动到底部且没有更多内容) - if (!hasMore.value) { - const atBottom = scrollTop + clientHeight >= scrollHeight - 50 - const overScroll = scrollTop + clientHeight > scrollHeight - const contentTooShort = scrollHeight <= clientHeight - - // 启用强制拉取模式 - if ((atBottom || overScroll || contentTooShort) && !canPullMore.value) { - canPullMore.value = true - showPullToLoad.value = true - } - - // 检测强制拉取触发条件 - if (canPullMore.value && (overScroll || (contentTooShort && scrollTop > 0))) { - fetchPage(true) - } + void fetchPage() } } const install = async (item: MarketItem) => { + if ( + installedServers.value.has(item.server_key) || + installingServerKeys.value.has(item.server_key) + ) { + return + } + try { if (!apiKeyInput.value.trim()) { toast({ @@ -310,35 +316,26 @@ const install = async (item: MarketItem) => { }) return } + installingServerKeys.value = new Set([...installingServerKeys.value, item.server_key]) await mcpClient.setMcpRouterApiKey(apiKeyInput.value.trim()) const ok = await mcpClient.installMcpRouterServer(item.server_key) if (ok) { toast({ title: t('mcp.market.installSuccess') }) - // 更新安装状态 - 使用 server_key 作为标识符 - installedServers.value.add(item.server_key) + installedServers.value = new Set([...installedServers.value, item.server_key]) } else { toast({ title: t('mcp.market.installFailed'), variant: 'destructive' }) } } catch (e) { toast({ title: t('mcp.market.installFailed'), description: String(e), variant: 'destructive' }) + } finally { + const nextInstalling = new Set(installingServerKeys.value) + nextInstalling.delete(item.server_key) + installingServerKeys.value = nextInstalling } } onMounted(async () => { - await loadApiKey() - await fetchPage() - - // 初始加载后检查是否需要启用强制拉取模式 - setTimeout(() => { - const el = scrollContainer.value - if (el && !hasMore.value) { - const contentTooShort = el.scrollHeight <= el.clientHeight - if (contentTooShort && items.value.length > 0) { - canPullMore.value = true - showPullToLoad.value = true - } - } - }, 100) + await Promise.all([loadApiKey(), fetchPage()]) }) diff --git a/src/renderer/settings/main.ts b/src/renderer/settings/main.ts index 791280d67b..65d37d29ac 100644 --- a/src/renderer/settings/main.ts +++ b/src/renderer/settings/main.ts @@ -10,34 +10,12 @@ import locales, { pluralRules } from '@/i18n' import { getSettingsRouteItems } from '@shared/settingsNavigation' import { preloadIcons } from '../src/lib/iconLoader' import { getRuntimeArch, getRuntimePlatform } from '@api/runtime' +import { settingsRouteComponents } from './settingsRouteComponents' const runtimePlatform = getRuntimePlatform() const runtimeArch = getRuntimeArch() const settingsRouteItems = getSettingsRouteItems(runtimePlatform, runtimeArch) -const settingsRouteComponents = { - 'settings-overview': () => import('./components/SettingsOverview.vue'), - 'settings-common': () => import('./components/CommonSettings.vue'), - 'settings-display': () => import('./components/DisplaySettings.vue'), - 'settings-environments': () => import('./components/EnvironmentsSettings.vue'), - 'settings-provider': () => import('./components/ModelProviderSettings.vue'), - 'settings-dashboard': () => import('./components/SettingsOverview.vue'), - 'settings-mcp': () => import('./components/McpSettings.vue'), - 'settings-deepchat-agents': () => import('./components/DeepChatAgentsSettings.vue'), - 'settings-acp': () => import('./components/AcpSettings.vue'), - 'settings-remote': () => import('./components/RemoteSettings.vue'), - 'settings-notifications-hooks': () => import('./components/NotificationsHooksSettings.vue'), - 'settings-scheduled-tasks': () => import('./components/CronJobsSettings.vue'), - 'settings-plugins': () => import('./components/PluginsSettings.vue'), - 'settings-skills': () => import('./components/skills/SkillsSettings.vue'), - 'settings-prompt': () => import('./components/PromptSetting.vue'), - 'settings-memory': () => import('./components/MemorySettings.vue'), - 'settings-knowledge-base': () => import('./components/KnowledgeBaseSettings.vue'), - 'settings-database': () => import('./components/DataSettings.vue'), - 'settings-shortcut': () => import('./components/ShortcutSettings.vue'), - 'settings-about': () => import('./components/AboutUsSettings.vue') -} as const - // Create i18n instance const i18n = createI18n({ locale: 'zh-CN', diff --git a/src/renderer/settings/settingsRouteComponents.ts b/src/renderer/settings/settingsRouteComponents.ts new file mode 100644 index 0000000000..01951357c9 --- /dev/null +++ b/src/renderer/settings/settingsRouteComponents.ts @@ -0,0 +1,27 @@ +export const settingsRouteComponents = { + 'settings-overview': () => import('./components/SettingsOverview.vue'), + 'settings-common': () => import('./components/CommonSettings.vue'), + 'settings-display': () => import('./components/DisplaySettings.vue'), + 'settings-environments': () => import('./components/EnvironmentsSettings.vue'), + 'settings-provider': () => import('./components/ModelProviderSettings.vue'), + 'settings-dashboard': () => import('./components/SettingsOverview.vue'), + 'settings-mcp': () => import('./components/McpSettings.vue'), + 'settings-deepchat-agents': () => import('./components/DeepChatAgentsSettings.vue'), + 'settings-acp': () => import('./components/AcpSettings.vue'), + 'settings-remote': () => import('./components/RemoteSettings.vue'), + 'settings-notifications-hooks': () => import('./components/NotificationsHooksSettings.vue'), + 'settings-scheduled-tasks': () => import('./components/CronJobsSettings.vue'), + 'settings-plugins': () => import('./components/PluginsSettings.vue'), + 'settings-skills': () => import('./components/skills/SkillsSettings.vue'), + 'settings-prompt': () => import('./components/PromptSetting.vue'), + 'settings-memory': () => import('./components/MemorySettings.vue'), + 'settings-knowledge-base': () => import('./components/KnowledgeBaseSettings.vue'), + 'settings-database': () => import('./components/DataSettings.vue'), + 'settings-shortcut': () => import('./components/ShortcutSettings.vue'), + 'settings-about': () => import('./components/AboutUsSettings.vue') +} as const + +export function preloadSettingsRoute(routeName: string): Promise | null { + const loader = settingsRouteComponents[routeName as keyof typeof settingsRouteComponents] + return loader?.() ?? null +} diff --git a/src/renderer/src/components/message/MessageBlockError.vue b/src/renderer/src/components/message/MessageBlockError.vue index 4480a02b06..6131759034 100644 --- a/src/renderer/src/components/message/MessageBlockError.vue +++ b/src/renderer/src/components/message/MessageBlockError.vue @@ -7,22 +7,35 @@ {{ t(block.content || '') }}
-
- {{ t('common.error.requestFailed') - }} -
+ aria-hidden="true" + /> +
- {{ t(block.content || '') }} +
+
+ {{ t(block.content || '') }} +
+
{{ t('common.error.causeOfError') }} {{ t(errorExplanation) }} @@ -33,7 +46,7 @@ diff --git a/src/renderer/src/components/sidepanel/ChatSidePanel.vue b/src/renderer/src/components/sidepanel/ChatSidePanel.vue index 1888e89c4f..20d5335c7b 100644 --- a/src/renderer/src/components/sidepanel/ChatSidePanel.vue +++ b/src/renderer/src/components/sidepanel/ChatSidePanel.vue @@ -325,9 +325,6 @@ onBeforeUnmount(() => { diff --git a/src/renderer/src/views/ChatTabView.vue b/src/renderer/src/views/ChatTabView.vue index 1f415c801a..ace6dc4c06 100644 --- a/src/renderer/src/views/ChatTabView.vue +++ b/src/renderer/src/views/ChatTabView.vue @@ -1,8 +1,6 @@