Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/architecture/renderer-interaction-performance/plan.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 43 additions & 0 deletions docs/architecture/renderer-interaction-performance/spec.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions docs/architecture/renderer-interaction-performance/tasks.md
Original file line number Diff line number Diff line change
@@ -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`.
14 changes: 14 additions & 0 deletions src/main/presenter/mcpPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,20 @@ export class McpPresenter implements IMCPPresenter {
return false
}

async listInstalledServerIds(source: string, sourceIds: string[]): Promise<string[]> {
const requestedIds = new Set(sourceIds)
if (requestedIds.size === 0) return []

const installedIds = new Set<string>()
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<void> {
const servers = await this.configPresenter.getMcpServers()
const updates: Array<{ name: string; config: Partial<MCPServerConfig> }> = []
Expand Down
9 changes: 9 additions & 0 deletions src/main/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ import {
mcpRouterGetApiKeyRoute,
mcpRouterInstallServerRoute,
mcpRouterIsServerInstalledRoute,
mcpRouterListInstalledServerIdsRoute,
mcpRouterListServersRoute,
mcpRouterSetApiKeyRoute,
mcpRouterUpdateServersAuthRoute,
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions src/renderer/api/McpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
mcpRouterGetApiKeyRoute,
mcpRouterInstallServerRoute,
mcpRouterIsServerInstalledRoute,
mcpRouterListInstalledServerIdsRoute,
mcpRouterListServersRoute,
mcpRouterSetApiKeyRoute,
mcpRouterUpdateServersAuthRoute,
Expand Down Expand Up @@ -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 })
}
Expand Down Expand Up @@ -317,6 +326,7 @@ export function createMcpClient(bridge: DeepchatBridge = getDeepchatBridge()) {
getMcpRouterApiKey,
setMcpRouterApiKey,
isServerInstalled,
listInstalledServerIds,
updateMcpRouterServersAuth,
onServerStarted,
onServerStopped,
Expand Down
51 changes: 45 additions & 6 deletions src/renderer/settings/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
<div
class="w-full h-9 window-drag-region shrink-0 justify-end flex flex-row relative border border-b-0 border-window-inner-border box-border rounded-t-[10px]"
:class="[
isMacOS ? '' : ' ounded-t-none',
isMacOS ? '' : 'rounded-t-none',
isMacOS ? 'bg-window-background' : 'bg-window-background/10'
]"
>
<div class="absolute bottom-0 left-0 w-full h-[1px] bg-border z-10"></div>
<Button
v-if="!isMacOS"
class="window-no-drag-region shrink-0 w-12 bg-transparent shadow-none rounded-none hover:bg-red-700/80 hover:text-white text-xs font-medium text-foreground flex items-center justify-center transition-all duration-200 group"
:title="t('common.close')"
:aria-label="t('common.close')"
@click="closeWindow"
>
<CloseIcon class="h-3! w-3!" />
Expand All @@ -41,11 +43,21 @@
:data-testid="getSettingsTabTestId(setting.name)"
:class="[
'flex w-full min-w-0 flex-row items-center gap-2 rounded-md px-2 py-2 text-start transition-colors hover:bg-accent',
route.name === setting.name ? 'bg-accent text-accent-foreground' : ''
route.name === setting.name ? 'bg-accent text-accent-foreground' : '',
pendingRouteName === setting.name ? 'cursor-wait' : ''
]"
@click="handleClick(setting.path)"
:aria-busy="pendingRouteName === setting.name"
@pointerenter="prefetchSetting(setting.name)"
@focus="prefetchSetting(setting.name)"
@click="handleClick(setting)"
>
<Icon :icon="setting.icon" class="size-4 shrink-0 text-muted-foreground" />
<Icon
:icon="pendingRouteName === setting.name ? 'lucide:loader-2' : setting.icon"
:class="[
'size-4 shrink-0 text-muted-foreground',
pendingRouteName === setting.name ? 'animate-spin' : ''
]"
/>
<span class="min-w-0 truncate text-sm font-medium">{{ t(setting.title) }}</span>
</button>
</div>
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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<string | null>(null)

const logSettingsStartup = (phase: string) => {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now()
Expand Down Expand Up @@ -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<string, string> = {
Expand Down
Loading
Loading