diff --git a/src/renderer/src/components/layout/architecture-mpa-webcontents.md b/src/renderer/src/components/layout/architecture-mpa-webcontents.md deleted file mode 100644 index 9904c961e5..0000000000 --- a/src/renderer/src/components/layout/architecture-mpa-webcontents.md +++ /dev/null @@ -1,575 +0,0 @@ -# Architecture: MPA + Multi WebContents (Process Isolation) - -> **Version**: v1.0.0 -> **Updated**: 2025-12-03 -> **Status**: Research & Analysis -> **Core Idea**: Each Tab is an independent WebContents/BrowserView, project structured as MPA with multiple Vite entry points - -## 1. Concept Overview - -### Architecture Diagram - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Main Process │ -│ ┌────────────────────────────────────────────────────────────┐ │ -│ │ Tab Manager (controls WebContents lifecycle) │ │ -│ └────────────────────────────────────────────────────────────┘ │ -├─────────────────────────────────────────────────────────────────┤ -│ BrowserWindow (Main Shell) │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Tab Bar (minimal renderer - shell.html) │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Content Area (WebContentsView container) │ │ -│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ -│ │ │ WebContents│ │ WebContents│ │ WebContents│ │ │ -│ │ │ chat.html │ │settings. │ │ notes.html │ │ │ -│ │ │ (visible) │ │ (hidden) │ │ (hidden) │ │ │ -│ │ └────────────┘ └────────────┘ └────────────┘ │ │ -│ └──────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### Core Difference from Other Solutions - -| Aspect | Multi MemoryRouter | MPA + WebContents | -|--------|-------------------|-------------------| -| **Process Model** | Single renderer process | Multiple renderer processes | -| **Isolation** | Shared memory, React context | Complete process isolation | -| **Crash Impact** | One crash affects all | One crash isolated | -| **Memory** | Shared React runtime | Each has own runtime | -| **Communication** | Direct state/props | IPC required | -| **Bundle Size** | One large bundle | Multiple smaller bundles | -| **First Paint** | Slower (load entire app) | Faster (load only needed page) | - ---- - -## 2. Vite MPA Configuration - -### Current State - -Cherry Studio already uses MPA structure: - -```typescript -// electron.vite.config.ts (existing) -renderer: { - build: { - rollupOptions: { - input: { - index: 'src/renderer/index.html', - miniWindow: 'src/renderer/miniWindow.html', - selectionToolbar: 'src/renderer/selectionToolbar.html', - selectionAction: 'src/renderer/selectionAction.html', - traceWindow: 'src/renderer/traceWindow.html', - migrationV2: 'src/renderer/migrationV2.html' - } - } - } -} -``` - -### Proposed Extension - -```typescript -// electron.vite.config.ts (extended for tabs) -renderer: { - build: { - rollupOptions: { - input: { - // Shell (minimal - just tab bar) - shell: 'src/renderer/shell.html', - - // Tab pages (each is independent) - chat: 'src/renderer/pages/chat.html', - settings: 'src/renderer/pages/settings.html', - notes: 'src/renderer/pages/notes.html', - knowledge: 'src/renderer/pages/knowledge.html', - files: 'src/renderer/pages/files.html', - - // Existing special windows - miniWindow: 'src/renderer/miniWindow.html', - selectionToolbar: 'src/renderer/selectionToolbar.html', - } - } - } -} -``` - -### Directory Structure - -``` -src/renderer/ -├── shell.html # Tab bar shell (minimal) -├── pages/ -│ ├── chat/ -│ │ ├── index.html -│ │ ├── main.tsx # Independent React app -│ │ └── App.tsx -│ ├── settings/ -│ │ ├── index.html -│ │ ├── main.tsx -│ │ └── App.tsx -│ ├── notes/ -│ │ └── ... -│ └── shared/ # Shared components/utils -│ ├── components/ -│ ├── hooks/ -│ └── stores/ -└── index.html # Legacy entry (redirect to shell) -``` - ---- - -## 3. WebContentsView Implementation - -### Main Process Tab Manager - -```typescript -// src/main/services/TabManager.ts -import { BaseWindow, WebContentsView } from 'electron' - -interface Tab { - id: string - type: string - view: WebContentsView - url: string -} - -class TabManager { - private mainWindow: BaseWindow - private tabs: Map = new Map() - private activeTabId: string | null = null - private shellView: WebContentsView - - constructor(mainWindow: BaseWindow) { - this.mainWindow = mainWindow - this.initShell() - } - - private initShell() { - // Shell only contains tab bar UI - this.shellView = new WebContentsView() - this.shellView.webContents.loadFile('out/renderer/shell.html') - this.mainWindow.contentView.addChildView(this.shellView) - this.layoutShell() - } - - createTab(type: string, initialUrl?: string): string { - const id = `tab-${Date.now()}` - const view = new WebContentsView() - - // Load the appropriate page based on type - const pageUrl = this.getPageUrl(type) - view.webContents.loadFile(pageUrl) - - // Pass initial state via postMessage after load - if (initialUrl) { - view.webContents.once('did-finish-load', () => { - view.webContents.send('init-state', { url: initialUrl }) - }) - } - - this.tabs.set(id, { id, type, view, url: initialUrl || '' }) - this.mainWindow.contentView.addChildView(view) - this.setActiveTab(id) - - return id - } - - setActiveTab(id: string) { - // Hide all tabs, show active one - this.tabs.forEach((tab, tabId) => { - if (tabId === id) { - tab.view.setBounds(this.getContentBounds()) - // Bring to front - this.mainWindow.contentView.addChildView(tab.view) - } else { - // Hide by setting zero bounds - tab.view.setBounds({ x: 0, y: 0, width: 0, height: 0 }) - } - }) - this.activeTabId = id - this.notifyShell() - } - - closeTab(id: string) { - const tab = this.tabs.get(id) - if (tab) { - this.mainWindow.contentView.removeChildView(tab.view) - tab.view.webContents.close() - this.tabs.delete(id) - - // Activate another tab - if (this.activeTabId === id) { - const remaining = Array.from(this.tabs.keys()) - if (remaining.length > 0) { - this.setActiveTab(remaining[remaining.length - 1]) - } - } - } - } - - // Native tab detach - state fully preserved! - detachTab(id: string): BaseWindow { - const tab = this.tabs.get(id) - if (!tab) throw new Error('Tab not found') - - // Remove from current window - this.mainWindow.contentView.removeChildView(tab.view) - this.tabs.delete(id) - - // Create new window and add the view - const newWindow = new BaseWindow({ width: 800, height: 600 }) - newWindow.contentView.addChildView(tab.view) - tab.view.setBounds({ x: 0, y: 0, width: 800, height: 600 }) - - // State is completely preserved: - // ✅ Scroll position - // ✅ Form inputs - // ✅ React state - // ✅ WebSocket connections - - return newWindow - } - - private getPageUrl(type: string): string { - const pageMap: Record = { - chat: 'out/renderer/pages/chat.html', - settings: 'out/renderer/pages/settings.html', - notes: 'out/renderer/pages/notes.html', - knowledge: 'out/renderer/pages/knowledge.html', - files: 'out/renderer/pages/files.html', - } - return pageMap[type] || pageMap.chat - } - - private getContentBounds() { - const bounds = this.mainWindow.getBounds() - const TAB_BAR_HEIGHT = 40 - return { - x: 0, - y: TAB_BAR_HEIGHT, - width: bounds.width, - height: bounds.height - TAB_BAR_HEIGHT - } - } - - private notifyShell() { - const tabsData = Array.from(this.tabs.values()).map(t => ({ - id: t.id, - type: t.type, - url: t.url - })) - this.shellView.webContents.send('tabs-updated', { - tabs: tabsData, - activeTabId: this.activeTabId - }) - } -} -``` - -### IPC Communication - -```typescript -// src/main/ipc/tabIpc.ts -ipcMain.handle('tab:create', (_, type: string, url?: string) => { - return tabManager.createTab(type, url) -}) - -ipcMain.handle('tab:close', (_, id: string) => { - tabManager.closeTab(id) -}) - -ipcMain.handle('tab:activate', (_, id: string) => { - tabManager.setActiveTab(id) -}) - -ipcMain.handle('tab:detach', (_, id: string) => { - return tabManager.detachTab(id) -}) - -// Cross-tab communication -ipcMain.handle('tab:broadcast', (_, channel: string, data: any) => { - tabManager.broadcastToAll(channel, data) -}) -``` - ---- - -## 4. Shared State Management - -### Challenge - -Each WebContents is a separate process - they cannot directly share memory/state. - -### Solution: State Synchronization via Main Process - -```typescript -// packages/shared/stores/syncedStore.ts -import { ipcRenderer } from 'electron' - -// Each page creates its own store instance, synced via IPC -export const createSyncedStore = (name: string, initialState: T) => { - let state = initialState - const listeners = new Set<(state: T) => void>() - - // Listen for state updates from main process - ipcRenderer.on(`store:${name}:update`, (_, newState: T) => { - state = newState - listeners.forEach(fn => fn(state)) - }) - - // Request initial state on load - ipcRenderer.invoke('store:get', name).then(s => { - if (s) { - state = s - listeners.forEach(fn => fn(state)) - } - }) - - return { - getState: () => state, - - setState: (partial: Partial) => { - // Send to main process, which broadcasts to all tabs - ipcRenderer.invoke('store:update', name, partial) - }, - - subscribe: (fn: (state: T) => void) => { - listeners.add(fn) - return () => listeners.delete(fn) - } - } -} -``` - -### Main Process State Hub - -```typescript -// src/main/services/StateHub.ts -class StateHub { - private stores: Map = new Map() - - getStore(name: string) { - return this.stores.get(name) - } - - updateStore(name: string, partial: any) { - const current = this.stores.get(name) || {} - const updated = { ...current, ...partial } - this.stores.set(name, updated) - - // Broadcast to all WebContents - tabManager.broadcastToAll(`store:${name}:update`, updated) - } -} -``` - ---- - -## 5. Performance Analysis - -### Bundle Size Comparison - -| Scenario | SPA (Current) | MPA (Proposed) | -|----------|---------------|----------------| -| **Total Bundle** | ~3-5MB (one file) | ~3-5MB (split) | -| **Initial Load** | Load all ~3-5MB | Shell ~100KB + Page ~500KB-1MB | -| **Chat Page Only** | Must load everything | Load only chat code | -| **Settings Page** | Already in memory | Load on demand | - -### First Paint Time - -``` -SPA Approach: -┌─────────────────────────────────────────────────────────┐ -│ Load index.html → Parse 3MB JS → React hydration → Ready│ -│ [=====================================] ~2-3s │ -└─────────────────────────────────────────────────────────┘ - -MPA Approach: -┌─────────────────────────────────────────────────────────┐ -│ Load shell.html → Tab bar ready │ -│ [=====] ~300ms │ -│ │ -│ Load chat.html → Parse 800KB JS → Ready │ -│ [============] ~800ms │ -└─────────────────────────────────────────────────────────┘ -Total perceived: ~1.1s (faster than SPA) -``` - -### Memory Usage Comparison - -| Tabs Open | SPA + MemoryRouter | MPA + WebContents | -|-----------|-------------------|-------------------| -| 1 tab | ~150MB | ~150MB | -| 3 tabs | ~200MB (shared) | ~350MB (3 processes) | -| 5 tabs | ~250MB (shared) | ~550MB (5 processes) | -| 10 tabs | ~350MB (shared) | **~1GB+** (10 processes) | - -**Trade-off**: MPA uses more memory but provides better isolation and faster initial load. - ---- - -## 6. Tab Detach - The Killer Feature - -### Native Support (No Serialization Needed) - -```typescript -// WebContentsView can be moved between windows natively -detachTab(id: string) { - const tab = this.tabs.get(id) - - // Remove from current window - this.mainWindow.contentView.removeChildView(tab.view) - - // Create new window - const newWindow = new BaseWindow() - - // Add existing WebContentsView - NO RELOAD! - newWindow.contentView.addChildView(tab.view) - - // Everything preserved: - // ✅ Scroll position - // ✅ Form inputs - // ✅ React component state - // ✅ WebSocket connections - // ✅ Pending requests - // ✅ Animation state -} -``` - -### Comparison with MemoryRouter Approach - -| Aspect | MemoryRouter Detach | WebContents Detach | -|--------|--------------------|--------------------| -| **Implementation** | Serialize → IPC → Deserialize | Move view reference | -| **State Loss** | Some (non-serializable) | None | -| **Scroll Position** | Manual restore | Preserved | -| **WebSocket** | Reconnect needed | Preserved | -| **Complexity** | High | Low (native) | - ---- - -## 7. Advantages & Disadvantages - -### Advantages - -| Feature | Description | -|---------|-------------| -| **Faster First Paint** | Load only needed page, not entire app | -| **Crash Isolation** | One tab crash doesn't affect others | -| **Native Tab Detach** | Move WebContentsView between windows | -| **Independent Updates** | Can update one page without rebuilding all | -| **Memory Isolation** | Each tab has isolated memory space | -| **Parallel Loading** | Multiple tabs can load simultaneously | - -### Disadvantages - -| Feature | Description | -|---------|-------------| -| **Higher Memory** | Each WebContents ~100-150MB base | -| **IPC Overhead** | Cross-tab communication slower | -| **State Sync Complexity** | Need to implement state hub | -| **Code Duplication** | Shared code loaded in each process | -| **Dev Experience** | HMR per page, not global | -| **No Direct State Sharing** | Cannot share React context | - ---- - -## 8. Comparison Summary - -| Feature | Multi MemoryRouter | MPA + WebContents | -|---------|-------------------|-------------------| -| **First Paint Speed** | Slower (load all) | ✅ Faster (per page) | -| **Tab Switch Speed** | ✅ Instant (CSS) | Instant (view swap) | -| **Memory Efficiency** | ✅ Better (shared) | Worse (per process) | -| **Crash Isolation** | ❌ No | ✅ Yes | -| **Tab Detach** | Needs serialization | ✅ Native support | -| **State Sharing** | ✅ Direct | IPC required | -| **Code Complexity** | Medium | Higher | -| **TSR Features** | ✅ Full | N/A (separate apps) | - ---- - -## 9. When to Choose This Architecture - -### Good Fit - -- Tab detach to window is critical requirement -- Each tab is largely independent (different features) -- Crash isolation is important -- Initial load performance is priority -- Large application with heavy pages - -### Not Ideal - -- Frequent cross-tab state sharing needed -- Memory is constrained -- Simple tab switching without detach need -- Small application -- Need shared React context/providers - ---- - -## 10. Hybrid Approach (Recommended) - -Combine both architectures for best results: - -``` -┌─────────────────────────────────────────────────────────┐ -│ Main Window │ -│ ┌───────────────────────────────────────────────────┐ │ -│ │ Primary WebContentsView (main.html) │ │ -│ │ ┌─────────────────────────────────────────────┐ │ │ -│ │ │ React App with Multi MemoryRouter │ │ │ -│ │ │ - Chat tabs (MemoryRouter instances) │ │ │ -│ │ │ - Settings (MemoryRouter instance) │ │ │ -│ │ │ - Notes (MemoryRouter instance) │ │ │ -│ │ └─────────────────────────────────────────────┘ │ │ -│ └───────────────────────────────────────────────────┘ │ -│ ┌───────────────────────────────────────────────────┐ │ -│ │ Secondary WebContentsViews (isolated content) │ │ -│ │ - MinApp webviews │ │ -│ │ - External web pages │ │ -│ │ - Heavy isolated features │ │ -│ └───────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -### Hybrid Benefits - -- Regular tabs use MemoryRouter (efficient, shared state) -- WebContents only for webviews/external content -- Tab detach: serialize MemoryRouter state → create new WebContents -- Best of both worlds - ---- - -## 11. Open Questions - -- [ ] What is the acceptable memory overhead per WebContents? -- [ ] How many concurrent tabs are expected in typical usage? -- [ ] Is crash isolation a real requirement for this app? -- [ ] Should detached windows be full-featured or minimal? -- [ ] How to handle shared authentication state across WebContents? - ---- - -## 12. References - -- [Electron WebContentsView API](https://www.electronjs.org/docs/latest/api/web-contents-view) -- [Figma BrowserView Architecture](https://www.figma.com/blog/introducing-browserview-for-electron/) -- [electron-vite Multi-Page Setup](https://electron-vite.org/guide/dev) -- [Vite MPA Configuration](https://vite-workshop.netlify.app/mpa) -- [Electron Multi-Tab Performance](https://dev.to/thanhlm/electron-multiple-tabs-without-dealing-with-performance-2cma) -- [Electron Process Model](https://www.electronjs.org/docs/latest/tutorial/process-model) - ---- - -## 13. Changelog - -| Version | Date | Changes | -|---------|------|---------| -| v1.0.0 | 2025-12-03 | Initial analysis | diff --git a/src/renderer/src/components/layout/architecture-multi-memory-router.md b/src/renderer/src/components/layout/architecture-multi-memory-router.md deleted file mode 100644 index efb7c8bc3b..0000000000 --- a/src/renderer/src/components/layout/architecture-multi-memory-router.md +++ /dev/null @@ -1,693 +0,0 @@ -# Architecture: Multi MemoryRouter Instances (Recommended) - -> **Version**: v1.0.0 -> **Updated**: 2025-12-03 -> **Status**: Research Complete & Recommended -> **Core Idea**: Each Tab has an independent MemoryRouter instance, CSS controls visibility, native KeepAlive - -## 1. Problem Statement - -### Core Contradiction - -```text -URL Router Design Philosophy: URL change → Component switch (single active view) -Tab System Requirement: Multiple views coexist, only switch visibility (preserve state) -``` - -These two are fundamentally conflicting. The current architecture uses TanStack Router's ``, which causes the following issues on each Tab switch: - -- Component unmount/remount -- State loss (scroll position, input content, expand/collapse state) -- White screen flicker - -### Key Requirements - -| Requirement | Priority | Description | -|-------------|----------|-------------| -| No flicker on switch | P0 | UI responds instantly on Tab switch | -| State preservation | P0 | Scroll position, input content, etc. | -| Tab detach to window | P1 | Similar to Chrome/VS Code | -| Memory control | P1 | Support LRU eviction for inactive Tabs | -| URL deep linking | P2 | Support sharing/bookmarks (optional) | - ---- - -## 2. Industry Research - -### 2.1 Electron Application Comparison - -| Project | Tab/Sidebar | Router Solution | KeepAlive | Detach to Window | Tech Stack | -|---------|-------------|-----------------|-----------|------------------|------------| -| **VS Code** | Tabs | No Router | ✅ Self-impl | ✅ Auxiliary Window | Native TS | -| **Figma** | Tabs | None | ✅ BrowserView | ✅ | Electron | -| **Hyper** | Tabs | No Router | ✅ Redux | ❌ | React + Redux | -| **LobeChat** | Sidebar | MemoryRouter (migrating) | ❌ | ❌ | Next.js + Zustand | -| **Jan AI** | Sidebar | None | ❌ | ❌ | Tauri + React | - -### 2.2 VS Code Implementation Analysis - -VS Code 1.85 implemented "Auxiliary Window" feature: - -```text -┌─────────────────────────────────────────────────────────┐ -│ Main Window │ -│ ┌─────────────────────────────────────────────────────┐│ -│ │ Editor Model (document state in memory) ││ -│ │ - file content ││ -│ │ - cursor position ││ -│ │ - undo/redo stack ││ -│ └─────────────────────────────────────────────────────┘│ -│ ↑ shared │ -│ ↓ │ -│ ┌─────────────────────────────────────────────────────┐│ -│ │ Auxiliary Window (new BrowserWindow) ││ -│ │ - renders the same Editor Model ││ -│ │ - changes sync in real-time ││ -│ └─────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────┘ -``` - -**Key Features**: - -- All windows operate on the same in-memory Model -- Changes in one window update all others in real-time -- Does not rely on URL Router, pure state-driven - -### 2.3 LobeChat Migration (RFC #9848) - -LobeChat is migrating from RSC to SPA: - -**Migration Reasons**: - -- RSC requires server-side data fetching, blocking page load -- Each navigation requires a server round-trip -- Windows users reported noticeable lag - -**New Solution**: - -- `react-router-dom` + `MemoryRouter` -- Zustand for centralized state management -- SWR/React Query for data fetching - ---- - -## 3. KeepAlive Solutions - -### 3.1 Solution Comparison - -| Solution | Mechanism | Advantages | Disadvantages | -|----------|-----------|------------|---------------| -| **React 19.2 Activity** | Official component, unmounts effects when hidden | Official support, long-term reliable | Requires React 19.2 upgrade | -| **keepalive-for-react** | Portal + cache management | Supports LRU, feature-rich | Incompatible with StrictMode | -| **react-activation** | Portal relocation | More mature | React 18 requires disabling autoFreeze | -| **CSS display:none** | Render all components, CSS controls visibility | Simple and direct | High memory usage | - -### 3.2 TanStack Router + KeepAlive Status - -**Official Stance**: TSR has no built-in KeepAlive - -**Community Solution Issues**: - -- `tanstack-router-keepalive`: `useSearch()` doesn't update, useQuery fails -- Manual implementation: Requires handling RouterContext synchronization - -**Core Problem**: `` only renders the current route, cannot keep multiple route components alive simultaneously. - -### 3.3 Cost of Abandoning Outlet - -If not using ``, TSR feature availability: - -| Feature | Availability | Notes | -|---------|--------------|-------| -| Type-safe route definitions | ✅ Fully available | Route table definition unchanged | -| URL building `Link` | ✅ Fully available | Type-safe URL generation | -| Parameter parsing `useParams` | ⚠️ Needs adaptation | Depends on RouterContext | -| Loader data loading | ❌ Manual call required | Auto-trigger mechanism disabled | -| Nested route rendering | ❌ Self-implementation required | Core Outlet functionality | -| beforeLoad guards | ❌ Manual call required | Route lifecycle | - ---- - -## 4. Recommended Solution - -### 4.1 Solution Choice: TSR MemoryHistory Multi-Instance - -Each Tab has an independent MemoryRouter instance, achieving state isolation and KeepAlive. - -```text -┌─────────────────────────────────────────────────────────┐ -│ AppShell │ -│ ┌─────────────────────────────────────────────────────┐│ -│ │ Tab Bar ││ -│ │ [Chat 1] [Chat 2] [Settings] ││ -│ └─────────────────────────────────────────────────────┘│ -│ ┌─────────────────────────────────────────────────────┐│ -│ │ Tab Contents (coexist, CSS controls visibility) ││ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││ -│ │ │MemoryRouter │ │MemoryRouter │ │MemoryRouter │ ││ -│ │ │ Tab 1 │ │ Tab 2 │ │ Tab 3 │ ││ -│ │ │ visible │ │ hidden │ │ hidden │ ││ -│ │ └─────────────┘ └─────────────┘ └─────────────┘ ││ -│ └─────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────┘ -``` - -### 4.2 Core Advantages - -| Feature | Description | -|---------|-------------| -| **No flicker** | Components not unmounted, CSS hidden | -| **State preservation** | Scroll position, input content fully preserved | -| **TSR capabilities preserved** | useParams, useSearch, nested routes work normally | -| **State isolation** | Each Tab has independent RouterContext | -| **Independent history stack** | Each Tab has its own forward/back | -| **Supports detach to window** | State is serializable | - -### 4.3 Architecture Design - -#### 4.3.1 Tab State Definition - -```typescript -// packages/shared/data/cache/cacheSchemas.ts -export type TabType = 'route' | 'webview' - -export interface Tab { - id: string - type: TabType - url: string // Current URL of MemoryRouter - title: string - icon?: string - // Serializable state (for detaching to window) - scrollPosition?: number - inputDraft?: string - metadata?: Record -} - -export interface TabsState { - tabs: Tab[] - activeTabId: string -} -``` - -#### 4.3.2 Tab Router Component - -```typescript -// src/renderer/src/components/layout/TabRouter.tsx -import { Activity } from 'react' // React 19.2+ -import { createRouter, RouterProvider } from '@tanstack/react-router' -import { createMemoryHistory } from '@tanstack/react-router' -import { useMemo, useEffect } from 'react' -import { routeTree } from '../../routeTree.gen' - -interface TabRouterProps { - tab: Tab - isActive: boolean - onUrlChange: (url: string) => void -} - -export const TabRouter = ({ tab, isActive, onUrlChange }: TabRouterProps) => { - // Create independent MemoryRouter for each Tab - const router = useMemo(() => { - const history = createMemoryHistory({ - initialEntries: [tab.url] - }) - - return createRouter({ - routeTree, - history, - }) - }, [tab.id]) // Only initialize when Tab is created - - // Listen to Tab internal navigation, sync URL to Tab state - useEffect(() => { - const unsubscribe = router.subscribe('onResolved', () => { - const currentUrl = router.state.location.pathname - if (currentUrl !== tab.url) { - onUrlChange(currentUrl) - } - }) - return unsubscribe - }, [router, tab.url, onUrlChange]) - - // Use React 19.2 Activity for visibility control - // Benefits over CSS display:none: - // - Effects unmount when hidden (timers, subscriptions cleaned up) - // - Effects re-mount when visible (fresh state) - // - Better React integration and memory optimization - return ( - - - - ) -} -``` - -> **Why Activity over CSS `display:none`?** -> -> | Aspect | CSS `display:none` | React `` | -> |--------|-------------------|-------------------| -> | DOM preserved | ✅ | ✅ | -> | State preserved | ✅ | ✅ | -> | Effects (timers, subscriptions) | ❌ Keep running | ✅ **Cleanup when hidden** | -> | Memory optimization | ❌ None | ✅ React can optimize | -> | Suspense integration | ❌ None | ✅ Better boundaries | - -#### 4.3.3 AppShell Component - -```typescript -// src/renderer/src/components/layout/AppShell.tsx -import { useTabs } from '../../hooks/useTabs' -import { TabRouter } from './TabRouter' -import { WebviewContainer } from './WebviewContainer' - -export const AppShell = () => { - const { tabs, activeTabId, updateTab, setActiveTab, closeTab, addTab } = useTabs() - - const handleUrlChange = (tabId: string, url: string) => { - updateTab(tabId, { url }) - } - - const handleDetachTab = (tab: Tab) => { - // Serialize state, create new window - window.api.createWindow({ - initialTab: JSON.stringify({ - ...tab, - // Capture current scroll position, etc. - scrollPosition: getScrollPosition(tab.id), - inputDraft: getInputDraft(tab.id), - }) - }) - closeTab(tab.id) - } - - return ( -
- {/* Sidebar */} - - -
- {/* Tab Bar */} - - - {/* Tab Contents */} -
- {tabs.map(tab => { - if (tab.type === 'route') { - return ( - handleUrlChange(tab.id, url)} - /> - ) - } - - if (tab.type === 'webview') { - return ( - - ) - } - - return null - })} -
-
-
- ) -} -``` - -### 4.4 Tab Detach to Window - -#### 4.4.1 Design Decision: Pure MemoryRouter (No HashRouter Needed) - -**Question**: Should we use HashRouter for detached windows so the URL can specify the route? - -```text -Option A: HashRouter - index.html#/chat/123 - └── HashRouter reads hash → route matches → loader runs - -Option B: Pure MemoryRouter (Recommended) - index.html?path=/chat/123 - └── Read URL query → MemoryRouter initializes → loader runs -``` - -**Analysis**: - -| Aspect | HashRouter | Pure MemoryRouter | -|--------|------------|-------------------| -| Loader execution | ✅ Works | ✅ Works | -| Route matching | ✅ Works | ✅ Works | -| Code consistency | Two router types | **Unified** | -| State passing | Hash only | **URL query (flexible)** | - -**Conclusion**: Pure MemoryRouter is simpler. TSR loader runs when MemoryRouter initializes with `initialEntries`, no HashRouter needed. - -#### 4.4.2 State Passing: URL Query vs IPC - -```text -┌─────────────────────────────────────────────────────────┐ -│ IPC Approach: │ -│ 1. Renderer → IPC → Main process │ -│ 2. Main process → create window → load index.html │ -│ 3. Main process → IPC → New renderer │ -│ 4. New renderer receives state │ -│ │ -│ URL Query Approach (Recommended): │ -│ 1. Renderer → Main process │ -│ 2. Main process → create window │ -│ 3. load index.html?path=/chat/123&scroll=500 │ -│ 4. New renderer reads window.location.search ✨ │ -└─────────────────────────────────────────────────────────┘ -``` - -| Aspect | URL Query | IPC | -|--------|-----------|-----| -| Complexity | **Simple** | Requires bidirectional communication | -| Data size | URL length limit (~2KB) | Unlimited | -| Data types | Strings only | Any serializable | -| Security | Visible in URL | Process internal | - -**Recommendation**: Use URL query for simple state (path, scroll), use shared cache for complex state (long inputDraft). - -#### 4.4.3 Architecture Flow - -```text -┌─────────────────────────────────────────────────────────┐ -│ Main Window - Tab Detach │ -│ │ -│ Tab (MemoryRouter) │ -│ router.state.location.pathname = '/chat/123' │ -│ (Browser URL unchanged, path is in memory) │ -│ │ │ -│ │ Drag out │ -│ ▼ │ -│ Construct URL: index.html?path=/chat/123&scroll=500 │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────┐│ -│ │ New Window ││ -│ │ ││ -│ │ const params = new URLSearchParams(location.search) ││ -│ │ const path = params.get('path') // '/chat/123' ││ -│ │ ││ -│ │ createMemoryHistory({ initialEntries: [path] }) ││ -│ │ │ ││ -│ │ ▼ ││ -│ │ TSR matches route → loader() executes → render ││ -│ └─────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────┘ -``` - -#### 4.4.4 Implementation - -**Router Factory**: - -```typescript -// src/renderer/src/lib/createTabRouter.ts -import { createRouter, createMemoryHistory } from '@tanstack/react-router' -import { routeTree } from '../routeTree.gen' - -export function createTabRouter(initialPath: string = '/') { - const history = createMemoryHistory({ - initialEntries: [initialPath] - }) - return createRouter({ routeTree, history }) -} -``` - -**Main Window - Detach Handler**: - -```typescript -// src/renderer/src/components/layout/AppShell.tsx -const handleDetachTab = async (tab: Tab) => { - const path = tab.router.state.location.pathname - const search = tab.router.state.location.search - const scroll = captureScrollPosition(tab.id) - - // Construct URL with query params - const params = new URLSearchParams({ - path: path + search, - scroll: String(scroll) - }) - - // For complex state (like inputDraft), save to shared cache - if (tab.inputDraft) { - await cacheService.set(`detached:${tab.id}:draft`, tab.inputDraft) - params.set('tabId', tab.id) - } - - // Request main process to create window with URL - await window.api.createWindow({ - url: `index.html?${params.toString()}` - }) - - closeTab(tab.id) -} -``` - -**App Entry - Detect Detached Window**: - -```typescript -// src/renderer/src/App.tsx -const App = () => { - const params = new URLSearchParams(window.location.search) - const initialPath = params.get('path') - const initialScroll = Number(params.get('scroll')) || 0 - const tabId = params.get('tabId') - - // If has path param, this is a detached window - if (initialPath) { - return ( - - ) - } - - // Main window with full tab system - return -} -``` - -**Detached Window Component**: - -```typescript -// src/renderer/src/components/DetachedTabWindow.tsx -interface Props { - path: string - scroll: number - tabId?: string | null -} - -const DetachedTabWindow = ({ path, scroll, tabId }: Props) => { - const router = useMemo(() => createTabRouter(path), [path]) - const [inputDraft, setInputDraft] = useState() - - // Restore scroll position after mount - useEffect(() => { - if (scroll) { - requestAnimationFrame(() => window.scrollTo(0, scroll)) - } - }, [scroll]) - - // Restore complex state from shared cache - useEffect(() => { - if (tabId) { - cacheService.get(`detached:${tabId}:draft`).then(setInputDraft) - } - }, [tabId]) - - // TSR automatically: match route → run loader → render - return -} -``` - -#### 4.4.5 TSR Loader Works with MemoryRouter - -Key insight: TSR's `loader` runs during route matching, regardless of history type. - -```typescript -// src/renderer/src/routes/chat/$topicId.tsx -export const Route = createFileRoute('/chat/$topicId')({ - loader: async ({ params }) => { - // Runs for BOTH HashRouter and MemoryRouter - const topic = await fetchTopic(params.topicId) - const messages = await fetchMessages(params.topicId) - return { topic, messages } - }, - component: ChatPage -}) - -function ChatPage() { - const { topic, messages } = Route.useLoaderData() - return -} -``` - -When `createMemoryHistory({ initialEntries: ['/chat/123'] })` is called: - -1. TSR parses path `/chat/123` -2. Matches route `/chat/$topicId` -3. Extracts params: `{ topicId: '123' }` -4. Executes `loader({ params })` -5. Renders component with loaded data - -#### 4.4.6 Tab Attach (Drag Back to Main Window) - -```text -┌─────────────────────────────────────────────────────────┐ -│ Tab Lifecycle │ -│ │ -│ Main Window Detached Window │ -│ ┌─────────┐ ┌─────────┐ │ -│ │ Tab 1 │ ──── Drag Out ────► │ Window │ │ -│ │ Tab 2 │ (URL Query) │ │ │ -│ │ Tab 3 │ ◄─── Drag Back ──── │ │ │ -│ └─────────┘ (IPC) └─────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -Drag Out vs Drag Back: - -| Aspect | Drag Out | Drag Back | -|--------|----------|-----------| -| State passing | URL Query | IPC (reverse direction) | -| Detection | Simple (user action) | Detect drop on main window tab bar | -| Window action | Create new window | Close detached window | -| Complexity | Low | Medium | - -**Implementation Key Points**: - -1. **Drop Detection**: Detached window monitors drag events, checks if drop position overlaps main window's tab bar bounds -2. **State Transfer**: Use IPC to send current state (path, scroll, inputDraft) back to main window -3. **Window Coordination**: Main process coordinates between windows - add tab to main, close detached -4. **Graceful Fallback**: If drop detection fails, provide "Attach to Main Window" button as alternative - -### 4.5 Memory Management - -#### 4.5.1 LRU Eviction Strategy - -```typescript -const MAX_CACHED_TABS = 10 - -const useTabs = () => { - // When Tab count exceeds limit, unload least recently used - useEffect(() => { - if (tabs.length > MAX_CACHED_TABS) { - const sortedByLastAccess = [...tabs].sort( - (a, b) => a.lastAccessTime - b.lastAccessTime - ) - const toRemove = sortedByLastAccess.slice(0, tabs.length - MAX_CACHED_TABS) - - toRemove.forEach(tab => { - // Save state to persistent storage - persistTabState(tab) - // Remove Router instance from memory - unloadTab(tab.id) - }) - } - }, [tabs.length]) -} -``` - -#### 4.5.2 Lazy Load Recovery - -```typescript -// When unloaded Tab is reactivated -const rehydrateTab = async (tabId: string) => { - const persistedState = await loadTabState(tabId) - // Recreate Router instance - // Restore scroll position, etc. -} -``` - ---- - -## 5. Migration Strategy - -### 5.1 Phase 1: Basic Infrastructure - -1. Create `TabRouter` component -2. Modify `AppShell` to support multiple Router instances -3. Update `useTabs` hook - -### 5.2 Phase 2: Feature Completion - -1. Implement Tab detach to window -2. Add LRU memory management -3. State persistence and recovery - -### 5.3 Phase 3: Optimization - -1. Performance optimization (lazy loading, virtualization) -2. Animation transition effects -3. Error boundary handling - ---- - -## 6. Comparison with Alternatives - -### 6.1 Solution Comparison - -| Solution | Flicker | State Preservation | TSR Capabilities | Detach to Window | Complexity | -|----------|---------|-------------------|------------------|------------------|------------| -| **MemoryHistory Multi-Instance** | ✅ None | ✅ Complete | ✅ Complete | ✅ | Medium | -| Pure State Management | ✅ None | ✅ Complete | ❌ Lost | ⚠️ Needs adaptation | Low | -| Outlet + KeepAlive | ✅ None | ✅ Complete | ⚠️ Hook issues | ✅ | Medium | -| BrowserView | ✅ None | ✅ Complete | ❌ Not applicable | ✅ Best | High | -| Original (no KeepAlive) | ❌ Yes | ❌ Lost | ✅ Complete | ✅ | Low | - -### 6.2 Recommendation Rationale - -Reasons for choosing **MemoryHistory Multi-Instance**: - -1. **Preserves TSR capabilities**: useParams, useSearch, nested routes work normally -2. **State isolation**: Each Tab has independent Context, no pollution -3. **Supports detach**: State is serializable -4. **Moderate complexity**: No additional dependencies required - ---- - -## 7. Open Questions - -- [ ] React 19.2 upgrade plan? Is Activity component better? -- [ ] What should be the Tab count limit? -- [ ] Do we need to support Tab grouping? -- [ ] Should Webview Tabs and Route Tabs have different memory strategies? - ---- - -## 8. References - -- [TanStack Router Discussion #1447](https://github.com/TanStack/router/discussions/1447) -- [LobeChat RFC #9848](https://github.com/lobehub/lobe-chat/discussions/9848) -- [VS Code Auxiliary Window](https://github.com/Microsoft/vscode/issues/8171) -- [React 19.2 Activity Component](https://react.dev/blog/2025/10/01/react-19-2) -- [Figma BrowserView](https://www.figma.com/blog/introducing-browserview-for-electron/) -- [keepalive-for-react](https://github.com/irychen/keepalive-for-react) - ---- - -## 9. Changelog - -| Version | Date | Changes | -|---------|------|---------| -| v1.3.0 | 2025-12-04 | Added Tab Attach (drag back) key points | -| v1.2.0 | 2025-12-04 | Replaced CSS `display:none` with React 19.2 `` for better effect management | -| v1.1.0 | 2025-12-03 | Added detailed Tab Detach implementation (URL Query approach, TSR Loader compatibility) | -| v1.0.0 | 2025-12-03 | Initial research report | diff --git a/src/renderer/src/components/layout/architecture-single-outlet.md b/src/renderer/src/components/layout/architecture-single-outlet.md deleted file mode 100644 index 1e134f76f2..0000000000 --- a/src/renderer/src/components/layout/architecture-single-outlet.md +++ /dev/null @@ -1,110 +0,0 @@ -# Architecture: Single Router + Outlet (KeepAlive Required) - -> **Status**: Initial Design (需要 KeepAlive 库支持) -> **Core Idea**: 单个 HashRouter,依赖 `` 渲染,需要 KeepAlive 包装防止组件卸载 - -## Overview - -This document describes the routing and tab management architecture for Cherry Studio. The system implements a "Chrome-like" tabbed interface where every tab can be a distinct application state (Chat, Settings, MinApp/Webview). - -The architecture is **Hybrid**, combining: - -1. **TanStack Router (HashRouter)**: Handles URL parsing and standard React page rendering. Standard pages (Home, Settings) are re-mounted on tab switch but optimized via data caching. -2. **Webview Overlay System**: Manages persistent processes (Webviews) that live *outside* the router to ensure they are never destroyed during navigation. -3. **Bidirectional State Sync**: `AppShell` ensures the URL bar and the Tab Database (`app_state` table) are always in sync. - -## Core Architecture - -### 1. Hybrid Rendering - -We use a "Single Router + Overlay" approach. We do **not** force `KeepAlive` for standard React pages, as it complicates the router logic significantly. Instead, we rely on TanStack Router's fast caching to make re-mounting feel instant. - -* **Layer 1: Standard Router (The Outlet)** - * Always present in the DOM. - * Renders standard pages (Home, Settings, Native Chat). - * **Behavior**: When switching between standard tabs (e.g., Home -> Settings), the router navigates, unmounting the old component and mounting the new one. - * **Optimization**: Data loaders are cached, so "re-mounting" is cheap and fast. - * **Visibility**: Hidden via `display: none` if a Webview tab is active. - -* **Layer 2: Webview Overlays** - * Rendered **outside** the Router. - * **Behavior**: These components are *never* unmounted as long as the tab is open. - * **Visibility**: Controlled purely by CSS (`display: block` / `none`). - * **Purpose**: To keep heavy processes (like MinApps or external websites) alive in the background. - -### 2. State Synchronization (The "Listener" Pattern) - -Since we use a single Router instance, we must manually sync the "Active Tab's URL" with the "Router's URL". - -* **URL -> Database (Passive Sync)**: - * A `useEffect` hook in `AppShell` listens to `location.pathname`. - * If the URL changes (e.g., user navigates inside a Chat tab), we update the current tab's `url` field in the SQLite database. - * *Benefit*: Restores the exact sub-route (e.g., `/chat/session-123`) when the user comes back later. - -* **Tab Switch -> Router (Active Navigation)**: - * When the user clicks a tab, we read its stored `url` from the database. - * We calling `navigate({ to: storedUrl })` to restore the view. - -### 3. Data Management - -* **Storage**: Tab data (`tabs`, `activeTabId`) is stored in SQLite (`app_state` table). -* **Sync**: `useTabs` hook uses SWR (`useQuery`) to sync frontend state with the database. -* **Optimistic Updates**: UI updates immediately, background sync handles persistence. - -## Routing & Overlay Mapping - -For detailed route tree definitions and component mappings, please refer to [Router Planning](./router-planning.md). - -### Handling Webview Routes - -The planning document mentions routes like `/apps/$appId` that may correspond to Webview applications. In our Hybrid Architecture, these are handled as follows: - -1. **Router Layer**: The route `/apps/$appId` is still defined in TSR. - * Purpose: Maintains URL semantics and supports deep linking. - * Rendering: Renders a "shell" component or loading state. -2. **Overlay Layer**: `AppShell` detects that the current Tab type is `webview`. - * Behavior: Hides the Router's Outlet. - * Rendering: Displays the corresponding `` instance in the Overlay layer. - -This mechanism ensures that even Webview apps have standard URLs, providing a consistent navigation experience across the application. - -## Key Components - -### `AppShell` (`src/renderer/src/components/layout/AppShell.tsx`) - -The coordinator that manages the two layers. - -```tsx -return ( -
- -
- - - {/* Layer 1: Standard Router (Hidden if Webview is active) */} -
- -
- - {/* Layer 2: Webview Overlays (Only for type='webview') */} - {tabs.map(tab => { - if (tab.type !== 'webview') return null; - return ( -
- -
- ) - })} -
-
-) -``` - -## Trade-offs - -| Feature | Approach | Rationale | -| :--- | :--- | :--- | -| **Standard Pages** | Re-mount on switch | Simplicity. Reactivity problems with KeepAlive are avoided. TSR caching makes it fast. | -| **Webviews** | Keep-Alive (CSS Hide) | Essential. Reloading an external app/website is bad UX. | -| **Routing** | HashRouter | Native to Electron file system. Avoids history API complexities. | -| **URL Logic** | Single Source of Truth | The address bar always reflects the *active* tab. Background tabs are just state in DB. | diff --git a/src/renderer/src/components/layout/router-planning.md b/src/renderer/src/components/layout/router-planning.md deleted file mode 100644 index 90a0232242..0000000000 --- a/src/renderer/src/components/layout/router-planning.md +++ /dev/null @@ -1,347 +0,0 @@ -# Router Planning - -> Version: v0.1.0 -> Updated: 2025-11-25 -> Status: Draft - -## 1. Overview - -This document defines the routing structure plan for migrating Cherry Studio from React Router to TanStack Router (TSR). - -### 1.1 Core Interaction Model - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Left Sidebar │ Top Tab Bar │ -│ (Shortcuts) │ [Tab1] [Tab2] [Tab3] [+] │ -│ ┌───────────┐ ├──────────────────────────────────────────┤ -│ │ 💬 Chat │ │ │ -│ │ ⚙️ Settings│ │ Content Area (Outlet) │ -│ │ 📁 Files │ │ │ -│ │ 📝 Notes │ │ Rendered based on active Tab's URL │ -│ │ ... │ │ │ -│ └───────────┘ │ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -- **Left Sidebar**: Like a "bookmarks bar", stores shortcuts. Clicking navigates to the URL (may reuse existing Tab or create new Tab) -- **Top Tab Bar**: Manages multiple open pages, supports closing and switching -- **Content Area**: Rendered by TanStack Router's `` - ---- - -## 2. Route Structure - -### 2.1 Directory Structure - -``` -src/renderer/src/routes/ -├── __root.tsx # Root route → AppShell -├── index.tsx # / → Welcome page or redirect (TBD) -│ -├── chat/ -│ ├── route.tsx # /chat layout: sidebar + -│ ├── index.tsx # /chat → Empty state (no topic selected) -│ └── $assistantId/ -│ ├── route.tsx # /chat/$assistantId layout (optional) -│ ├── index.tsx # /chat/$assistantId → Assistant home (optional) -│ └── $topicId.tsx # /chat/$assistantId/$topicId → Chat view -│ -├── settings/ -│ ├── route.tsx # /settings layout: menu + -│ ├── index.tsx # /settings → Redirect to default sub-page -│ ├── provider.tsx # /settings/provider -│ ├── model.tsx # /settings/model -│ ├── general.tsx # /settings/general -│ ├── display.tsx # /settings/display -│ ├── data.tsx # /settings/data -│ ├── mcp.tsx # /settings/mcp -│ ├── shortcut.tsx # /settings/shortcut -│ └── about.tsx # /settings/about -│ -├── knowledge/ -│ ├── route.tsx # /knowledge layout -│ ├── index.tsx # /knowledge → Knowledge base list -│ └── $baseId.tsx # /knowledge/$baseId → Knowledge base detail -│ -├── notes/ -│ ├── route.tsx # /notes layout: tree sidebar + -│ ├── index.tsx # /notes → Empty state -│ └── $noteId.tsx # /notes/$noteId → Editor -│ -├── apps/ -│ ├── route.tsx # /apps layout -│ ├── index.tsx # /apps → App list -│ └── $appId.tsx # /apps/$appId → App detail (possibly Webview) -│ -├── paintings/ -│ ├── route.tsx # /paintings layout: provider select + -│ ├── index.tsx # /paintings → Redirect to default provider -│ ├── zhipu.tsx # /paintings/zhipu → Zhipu painting -│ ├── aihubmix.tsx # /paintings/aihubmix → Aihubmix -│ ├── silicon.tsx # /paintings/silicon → Silicon Flow -│ ├── dmxapi.tsx # /paintings/dmxapi → Dmxapi -│ ├── tokenflux.tsx # /paintings/tokenflux → TokenFlux -│ ├── ovms.tsx # /paintings/ovms → OVMS -│ └── $providerId.tsx # /paintings/$providerId → Dynamic NewApi provider -│ -├── files.tsx # /files → File management -├── translate.tsx # /translate → Translation -├── store.tsx # /store → App store -└── launchpad.tsx # /launchpad → Launchpad -``` - -### 2.2 Route Table - -| Route | Component | Loader Data | Description | -|-------|-----------|-------------|-------------| -| `/` | `WelcomePage` | - | Welcome page or redirect (TBD) | -| `/chat` | `ChatLayout` | Assistants, Topics | Chat layout layer | -| `/chat/$assistantId/$topicId` | `ChatView` | Topic detail, Messages | Chat main view | -| `/settings` | `SettingsLayout` | - | Settings layout layer | -| `/settings/provider` | `ProviderSettings` | Provider list | Provider settings | -| `/settings/model` | `ModelSettings` | Model list | Model settings | -| `/settings/*` | `*Settings` | Respective data | Other settings pages | -| `/knowledge` | `KnowledgeLayout` | Knowledge bases | Knowledge layout | -| `/knowledge/$baseId` | `KnowledgeDetail` | Knowledge detail | Knowledge detail page | -| `/notes` | `NotesLayout` | Notes tree | Notes layout | -| `/notes/$noteId` | `NotesEditor` | Note content | Notes editor | -| `/apps` | `AppsLayout` | App list | Apps layout | -| `/apps/$appId` | `AppDetail` | App detail | App detail/Webview | -| `/paintings` | `PaintingsLayout` | Provider list | Paintings layout layer | -| `/paintings/zhipu` | `ZhipuPage` | - | Zhipu painting | -| `/paintings/aihubmix` | `AihubmixPage` | - | Aihubmix painting | -| `/paintings/silicon` | `SiliconPage` | - | Silicon Flow painting | -| `/paintings/dmxapi` | `DmxapiPage` | - | Dmxapi painting | -| `/paintings/tokenflux` | `TokenFluxPage` | - | TokenFlux painting | -| `/paintings/ovms` | `OvmsPage` | - | OVMS painting | -| `/paintings/$providerId` | `NewApiPage` | - | Dynamic NewApi provider | -| `/files` | `FilesPage` | File list | File management | -| `/translate` | `TranslatePage` | - | Translation page | -| `/store` | `StorePage` | Store data | App store | -| `/launchpad` | `LaunchpadPage` | - | Launchpad | - ---- - -## 3. Chat Route Design - -### 3.1 URL Structure - -``` -/chat/$assistantId/$topicId - │ │ - │ └── Topic ID (conversation ID) - └── Assistant ID -``` - -**Examples**: - -- `/chat` → Chat home (sidebar + empty state) -- `/chat/assistant-1` → Assistant 1's home (optional, may redirect to first topic) -- `/chat/assistant-1/topic-123` → Chat view for topic 123 under assistant 1 - -### 3.2 Component Structure - -```tsx -// routes/chat/route.tsx -export const Route = createFileRoute('/chat')({ - component: ChatLayout, - loader: async () => ({ - assistants: await fetchAssistants(), - topics: await fetchTopics() - }), - staleTime: 30_000, -}) - -function ChatLayout() { - const data = Route.useLoaderData() - - return ( -
- {/* Sidebar: Assistant list + Topic list */} - - - {/* Chat content area */} -
- -
-
- ) -} -``` - -```tsx -// routes/chat/$assistantId/$topicId.tsx -export const Route = createFileRoute('/chat/$assistantId/$topicId')({ - component: ChatView, - loader: async ({ params }) => ({ - topic: await fetchTopic(params.topicId), - messages: await fetchMessages(params.topicId) - }), - staleTime: 10_000, -}) - -function ChatView() { - const { topic, messages } = Route.useLoaderData() - const { assistantId, topicId } = Route.useParams() - - return ( -
- - - -
- ) -} -``` - -### 3.3 Data Flow - -``` -1. User clicks topic in sidebar - ↓ -2. navigate({ to: '/chat/$assistantId/$topicId' }) - ↓ -3. TSR matches route, checks loader cache - ↓ -4. Cache hit → Render directly - Cache miss → Execute loader, fetch data - ↓ -5. ChatLayout does not re-render (parent route data cached) - ↓ -6. Only ChatView updates (child route data changed) -``` - ---- - -## 4. Settings Route Design - -### 4.1 Sub-page List - -| Route | Component | Existing File | -|-------|-----------|---------------| -| `/settings/provider` | `ProviderSettings` | `ProviderSettings/` | -| `/settings/model` | `ModelSettings` | `ModelSettings/` | -| `/settings/general` | `GeneralSettings` | `GeneralSettings.tsx` | -| `/settings/display` | `DisplaySettings` | `DisplaySettings.tsx` | -| `/settings/data` | `DataSettings` | `DataSettings/` | -| `/settings/mcp` | `MCPSettings` | `MCPSettings/` | -| `/settings/websearch` | `WebSearchSettings` | `WebSearchSettings/` | -| `/settings/memory` | `MemorySettings` | `MemorySettings/` | -| `/settings/shortcut` | `ShortcutSettings` | `ShortcutSettings.tsx` | -| `/settings/quickassistant` | `QuickAssistantSettings` | `QuickAssistantSettings.tsx` | -| `/settings/about` | `AboutSettings` | `AboutSettings.tsx` | - -### 4.2 Layout Structure - -```tsx -// routes/settings/route.tsx -function SettingsLayout() { - return ( -
- {/* Left menu */} - - - {/* Right content */} -
- -
-
- ) -} -``` - ---- - -## 5. Paintings Route Design - -### 5.1 URL Structure - -``` -/paintings/$providerId - │ - └── Provider ID (zhipu, aihubmix, silicon, dmxapi, tokenflux, ovms, or dynamic NewApi provider) -``` - -**Examples**: - -- `/paintings` → Redirect to user's default painting provider -- `/paintings/zhipu` → Zhipu painting page -- `/paintings/aihubmix` → Aihubmix painting page -- `/paintings/my-custom-provider` → User's custom NewApi provider - -### 5.2 Provider List - -| Provider ID | Component | Description | -|-------------|-----------|-------------| -| `zhipu` | `ZhipuPage` | Zhipu AI Painting | -| `aihubmix` | `AihubmixPage` | Aihubmix Aggregation | -| `silicon` | `SiliconPage` | Silicon Flow | -| `dmxapi` | `DmxapiPage` | Dmxapi | -| `tokenflux` | `TokenFluxPage` | TokenFlux | -| `ovms` | `OvmsPage` | OVMS (Local Inference) | -| `$providerId` | `NewApiPage` | Dynamic NewApi Provider | - -### 5.3 Component Structure - -```tsx -// routes/paintings/route.tsx -export const Route = createFileRoute('/paintings')({ - component: PaintingsLayout, - loader: async () => ({ - providers: await fetchPaintingProviders(), - defaultProvider: await getDefaultPaintingProvider() - }), -}) - -function PaintingsLayout() { - const { providers } = Route.useLoaderData() - - return ( -
- {/* Provider selector */} - - - {/* Painting content area */} -
- -
-
- ) -} -``` - -### 5.4 Special Handling - -- **OVMS Provider**: Only shown in options when local OVMS service is running -- **Dynamic Providers**: Custom providers added by users via NewApi, captured using `$providerId` - ---- - -## 6. Component Mapping - -| New Route Component | Existing Component | Migration Strategy | -|---------------------|-------------------|-------------------| -| `ChatLayout` | `HomePage.tsx` | Extract sidebar logic | -| `ChatSidebar` | `HomeTabs/index.tsx` | Rename, adjust props | -| `ChatView` | `Chat.tsx` | Keep unchanged, adjust data fetching | -| `SettingsLayout` | `SettingsPage.tsx` | Extract layout logic | -| `NotesLayout` | `NotesSidebar.tsx` | Extract as layout component | -| `NotesEditor` | `NotesEditor.tsx` | Keep unchanged | - ---- - -## 7. Open Questions - -- [ ] `/` home behavior: Redirect to `/chat` or standalone welcome page? -- [ ] Does `/chat/$assistantId` need a dedicated page? Or redirect to first topic directly? -- [ ] Left sidebar interaction: Always create new Tab on click? Or reuse existing Tab? -- [ ] Tab bar UI details: Close button position, drag-to-reorder, context menu, etc. - ---- - -## 8. Changelog - -| Version | Date | Changes | -|---------|------|---------| -| v0.1.0 | 2025-11-25 | Initial version | diff --git a/src/renderer/src/services/__tests__/TabLRUManager.test.ts b/src/renderer/src/services/__tests__/TabLRUManager.test.ts new file mode 100644 index 0000000000..076f680a99 --- /dev/null +++ b/src/renderer/src/services/__tests__/TabLRUManager.test.ts @@ -0,0 +1,260 @@ +import type { Tab } from '@shared/data/cache/cacheValueTypes' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { TAB_LIMITS, TabLRUManager } from '../TabLRUManager' + +// Helper to create a mock tab +const createTab = (id: string, overrides: Partial = {}): Tab => ({ + id, + type: 'route', + url: `/${id}`, + title: id, + lastAccessTime: Date.now(), + isDormant: false, + isPinned: false, + ...overrides +}) + +describe('TabLRUManager', () => { + let manager: TabLRUManager + + beforeEach(() => { + manager = new TabLRUManager() + // Suppress logger output during tests + vi.spyOn(console, 'info').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + describe('constructor', () => { + it('should use default limits', () => { + const limits = manager.getLimits() + expect(limits.softCap).toBe(TAB_LIMITS.softCap) + expect(limits.hardCap).toBe(TAB_LIMITS.hardCap) + }) + + it('should accept custom limits', () => { + const customManager = new TabLRUManager({ softCap: 5, hardCap: 15 }) + const limits = customManager.getLimits() + expect(limits.softCap).toBe(5) + expect(limits.hardCap).toBe(15) + }) + }) + + describe('checkAndGetDormantCandidates', () => { + describe('when under soft cap', () => { + it('should return empty array when active tabs <= softCap', () => { + const tabs = Array.from({ length: TAB_LIMITS.softCap }, (_, i) => createTab(`tab-${i}`)) + const result = manager.checkAndGetDormantCandidates(tabs, 'tab-0') + expect(result).toEqual([]) + }) + + it('should return empty array for 1 tab', () => { + const tabs = [createTab('tab-0')] + const result = manager.checkAndGetDormantCandidates(tabs, 'tab-0') + expect(result).toEqual([]) + }) + }) + + describe('when exceeding soft cap', () => { + it('should return oldest tabs when exceeding softCap', () => { + const now = Date.now() + const tabs = Array.from({ length: TAB_LIMITS.softCap + 3 }, (_, i) => + createTab(`tab-${i}`, { lastAccessTime: now + i * 1000 }) + ) + + const result = manager.checkAndGetDormantCandidates(tabs, `tab-${TAB_LIMITS.softCap + 2}`) + + // Should hibernate 3 tabs (to get back to softCap) + expect(result.length).toBe(3) + // Should be the oldest tabs (lowest access times) + expect(result).toContain('tab-0') + expect(result).toContain('tab-1') + expect(result).toContain('tab-2') + }) + + it('should not hibernate the active tab', () => { + const now = Date.now() + const tabs = Array.from({ length: TAB_LIMITS.softCap + 2 }, (_, i) => + createTab(`tab-${i}`, { lastAccessTime: now + i * 1000 }) + ) + + // Make tab-0 the oldest but also the active tab + const result = manager.checkAndGetDormantCandidates(tabs, 'tab-0') + + expect(result).not.toContain('tab-0') + }) + + it('should not hibernate the home tab', () => { + const now = Date.now() + const tabs = [ + createTab('home', { lastAccessTime: now - 10000 }), // Oldest + ...Array.from({ length: TAB_LIMITS.softCap + 1 }, (_, i) => + createTab(`tab-${i}`, { lastAccessTime: now + i * 1000 }) + ) + ] + + const result = manager.checkAndGetDormantCandidates(tabs, `tab-${TAB_LIMITS.softCap}`) + + expect(result).not.toContain('home') + }) + + it('should not hibernate pinned tabs', () => { + const now = Date.now() + const tabs = [ + createTab('pinned-tab', { lastAccessTime: now - 10000, isPinned: true }), // Oldest but pinned + ...Array.from({ length: TAB_LIMITS.softCap + 1 }, (_, i) => + createTab(`tab-${i}`, { lastAccessTime: now + i * 1000 }) + ) + ] + + const result = manager.checkAndGetDormantCandidates(tabs, `tab-${TAB_LIMITS.softCap}`) + + expect(result).not.toContain('pinned-tab') + }) + + it('should not hibernate already dormant tabs', () => { + const now = Date.now() + const tabs = [ + createTab('dormant-tab', { lastAccessTime: now - 10000, isDormant: true }), + ...Array.from({ length: TAB_LIMITS.softCap + 1 }, (_, i) => + createTab(`tab-${i}`, { lastAccessTime: now + i * 1000 }) + ) + ] + + const result = manager.checkAndGetDormantCandidates(tabs, `tab-${TAB_LIMITS.softCap}`) + + expect(result).not.toContain('dormant-tab') + }) + }) + + describe('hard cap behavior', () => { + it('should use relaxed exemption rules when exceeding hard cap', () => { + const now = Date.now() + // Create tabs exceeding hard cap, with one pinned oldest tab + const tabs = [ + createTab('pinned-old', { lastAccessTime: now - 20000, isPinned: true }), + ...Array.from({ length: TAB_LIMITS.hardCap + 2 }, (_, i) => + createTab(`tab-${i}`, { lastAccessTime: now + i * 1000 }) + ) + ] + + const result = manager.checkAndGetDormantCandidates(tabs, `tab-${TAB_LIMITS.hardCap + 1}`) + + // Hard cap triggered: pinned tabs are no longer exempt (except home and active) + expect(result).toContain('pinned-old') + }) + + it('should still protect home and active tabs in hard cap mode', () => { + const now = Date.now() + const tabs = [ + createTab('home', { lastAccessTime: now - 30000 }), + ...Array.from({ length: TAB_LIMITS.hardCap + 2 }, (_, i) => + createTab(`tab-${i}`, { lastAccessTime: now + i * 1000 }) + ) + ] + + const activeTabId = `tab-${TAB_LIMITS.hardCap + 1}` + const result = manager.checkAndGetDormantCandidates(tabs, activeTabId) + + expect(result).not.toContain('home') + expect(result).not.toContain(activeTabId) + }) + }) + + describe('edge cases', () => { + it('should handle empty tabs array', () => { + const result = manager.checkAndGetDormantCandidates([], 'any-id') + expect(result).toEqual([]) + }) + + it('should handle tabs with undefined lastAccessTime', () => { + const tabs = Array.from({ length: TAB_LIMITS.softCap + 2 }, (_, i) => + createTab(`tab-${i}`, { lastAccessTime: undefined }) + ) + + // Should not throw + const result = manager.checkAndGetDormantCandidates(tabs, `tab-${TAB_LIMITS.softCap + 1}`) + expect(Array.isArray(result)).toBe(true) + }) + + it('should handle when all tabs are exempt', () => { + const now = Date.now() + // All tabs are pinned + const tabs = Array.from({ length: TAB_LIMITS.softCap + 3 }, (_, i) => + createTab(`tab-${i}`, { lastAccessTime: now + i * 1000, isPinned: true }) + ) + + const result = manager.checkAndGetDormantCandidates(tabs, 'tab-0') + + // Should return empty (no candidates available) + expect(result.length).toBeLessThan(3) + }) + + it('should handle mixed dormant and active tabs correctly', () => { + const now = Date.now() + const tabs = [ + // 5 dormant tabs (should not count toward active) + ...Array.from({ length: 5 }, (_, i) => + createTab(`dormant-${i}`, { isDormant: true, lastAccessTime: now - i * 1000 }) + ), + // Active tabs exceeding soft cap + ...Array.from({ length: TAB_LIMITS.softCap + 2 }, (_, i) => + createTab(`active-${i}`, { lastAccessTime: now + i * 1000 }) + ) + ] + + const result = manager.checkAndGetDormantCandidates(tabs, `active-${TAB_LIMITS.softCap + 1}`) + + // Should only consider active tabs + expect(result.every((id) => id.startsWith('active-'))).toBe(true) + expect(result.length).toBe(2) // Need to hibernate 2 to reach soft cap + }) + }) + }) + + describe('updateSoftCap', () => { + it('should update soft cap value', () => { + manager.updateSoftCap(15) + expect(manager.getLimits().softCap).toBe(15) + }) + }) + + describe('updateHardCap', () => { + it('should update hard cap value', () => { + manager.updateHardCap(30) + expect(manager.getLimits().hardCap).toBe(30) + }) + }) + + describe('getLimits', () => { + it('should return current limits', () => { + const customManager = new TabLRUManager({ softCap: 8, hardCap: 20 }) + const limits = customManager.getLimits() + + expect(limits).toEqual({ softCap: 8, hardCap: 20 }) + }) + }) + + describe('LRU ordering', () => { + it('should correctly order tabs by lastAccessTime', () => { + const customManager = new TabLRUManager({ softCap: 3, hardCap: 10 }) + const now = Date.now() + + const tabs = [ + createTab('tab-oldest', { lastAccessTime: now - 3000 }), + createTab('tab-newest', { lastAccessTime: now }), + createTab('tab-middle', { lastAccessTime: now - 1000 }), + createTab('tab-second-oldest', { lastAccessTime: now - 2000 }), + createTab('tab-active', { lastAccessTime: now + 1000 }) // Active tab + ] + + const result = customManager.checkAndGetDormantCandidates(tabs, 'tab-active') + + // Should hibernate the 2 oldest tabs + expect(result.length).toBe(2) + expect(result[0]).toBe('tab-oldest') + expect(result[1]).toBe('tab-second-oldest') + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/routeTitle.test.ts b/src/renderer/src/utils/__tests__/routeTitle.test.ts new file mode 100644 index 0000000000..601db51ca5 --- /dev/null +++ b/src/renderer/src/utils/__tests__/routeTitle.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock i18n before importing the module +vi.mock('@renderer/i18n', () => ({ + default: { + t: vi.fn((key: string) => { + const translations: Record = { + 'tab.new': '新标签页', + 'assistants.title': '助手', + 'assistants.presets.title': '预设助手', + 'paintings.title': '绘图', + 'translate.title': '翻译', + 'minapp.title': '小程序', + 'knowledge.title': '知识库', + 'files.title': '文件', + 'code.title': '代码', + 'notes.title': '笔记', + 'settings.title': '设置' + } + return translations[key] || key + }) + } +})) + +import { getDefaultRouteTitle, getRouteTitleKey } from '../routeTitle' + +describe('routeTitle', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('getDefaultRouteTitle', () => { + describe('exact route matches', () => { + it.each([ + ['/', '新标签页'], + ['/chat', '助手'], + ['/store', '预设助手'], + ['/paintings', '绘图'], + ['/translate', '翻译'], + ['/apps', '小程序'], + ['/knowledge', '知识库'], + ['/files', '文件'], + ['/code', '代码'], + ['/notes', '笔记'], + ['/settings', '设置'] + ])('should return correct title for %s', (url, expectedTitle) => { + expect(getDefaultRouteTitle(url)).toBe(expectedTitle) + }) + }) + + describe('nested route matches', () => { + it('should match base path for nested routes', () => { + expect(getDefaultRouteTitle('/chat/topic-123')).toBe('助手') + expect(getDefaultRouteTitle('/settings/provider')).toBe('设置') + expect(getDefaultRouteTitle('/settings/mcp/servers')).toBe('设置') + expect(getDefaultRouteTitle('/paintings/zhipu')).toBe('绘图') + }) + }) + + describe('URL with query params and hash', () => { + it('should handle URLs with query parameters', () => { + expect(getDefaultRouteTitle('/chat?topicId=123')).toBe('助手') + expect(getDefaultRouteTitle('/settings/provider?id=openai')).toBe('设置') + }) + + it('should handle URLs with hash', () => { + expect(getDefaultRouteTitle('/knowledge#section1')).toBe('知识库') + }) + + it('should handle URLs with both query and hash', () => { + expect(getDefaultRouteTitle('/chat?id=1#message-5')).toBe('助手') + }) + }) + + describe('unknown routes', () => { + it('should return last segment for unknown routes', () => { + expect(getDefaultRouteTitle('/unknown')).toBe('unknown') + expect(getDefaultRouteTitle('/foo/bar/baz')).toBe('baz') + }) + + it('should return pathname for root-like unknown routes', () => { + expect(getDefaultRouteTitle('/x')).toBe('x') + }) + }) + + describe('edge cases', () => { + it('should handle trailing slashes', () => { + expect(getDefaultRouteTitle('/chat/')).toBe('助手') + expect(getDefaultRouteTitle('/settings/')).toBe('设置') + }) + + it('should handle double slashes (protocol-relative URL)', () => { + // '//chat' is a protocol-relative URL, so 'chat' becomes the hostname + // This is expected behavior per URL standard + expect(getDefaultRouteTitle('//chat')).toBe('新标签页') + }) + + it('should handle relative-like paths', () => { + // URL constructor with base will normalize these + expect(getDefaultRouteTitle('chat')).toBe('助手') + expect(getDefaultRouteTitle('./chat')).toBe('助手') + }) + }) + }) + + describe('getRouteTitleKey', () => { + describe('exact matches', () => { + it.each([ + ['/', 'tab.new'], + ['/chat', 'assistants.title'], + ['/store', 'assistants.presets.title'], + ['/settings', 'settings.title'] + ])('should return i18n key for %s', (url, expectedKey) => { + expect(getRouteTitleKey(url)).toBe(expectedKey) + }) + }) + + describe('base path matches', () => { + it('should return base path key for nested routes', () => { + expect(getRouteTitleKey('/chat/topic-123')).toBe('assistants.title') + expect(getRouteTitleKey('/settings/provider')).toBe('settings.title') + }) + }) + + describe('unknown routes', () => { + it('should return undefined for unknown routes', () => { + expect(getRouteTitleKey('/unknown')).toBeUndefined() + expect(getRouteTitleKey('/foo/bar')).toBeUndefined() + }) + }) + }) +})