mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 04:31:27 +08:00
Remove outdated architecture documents for MPA, Multi MemoryRouter, and Single Outlet approaches; add tests for TabLRUManager and route title utilities.
This commit is contained in:
parent
e63397d5c2
commit
0599a583da
@ -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<string, Tab> = 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<string, string> = {
|
||||
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 = <T>(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<T>) => {
|
||||
// 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<string, any> = 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 |
|
||||
@ -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 `<Outlet />`, 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**: `<Outlet />` only renders the current route, cannot keep multiple route components alive simultaneously.
|
||||
|
||||
### 3.3 Cost of Abandoning Outlet
|
||||
|
||||
If not using `<Outlet />`, 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<string, unknown>
|
||||
}
|
||||
|
||||
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 (
|
||||
<Activity mode={isActive ? 'visible' : 'hidden'}>
|
||||
<RouterProvider router={router} />
|
||||
</Activity>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
> **Why Activity over CSS `display:none`?**
|
||||
>
|
||||
> | Aspect | CSS `display:none` | React `<Activity>` |
|
||||
> |--------|-------------------|-------------------|
|
||||
> | 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 (
|
||||
<div className="flex h-screen w-screen">
|
||||
{/* Sidebar */}
|
||||
<Sidebar onNavigate={handleSidebarClick} />
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
{/* Tab Bar */}
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
onTabClick={setActiveTab}
|
||||
onTabClose={closeTab}
|
||||
onTabDetach={handleDetachTab}
|
||||
/>
|
||||
|
||||
{/* Tab Contents */}
|
||||
<main className="relative flex-1 overflow-hidden">
|
||||
{tabs.map(tab => {
|
||||
if (tab.type === 'route') {
|
||||
return (
|
||||
<TabRouter
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
onUrlChange={(url) => handleUrlChange(tab.id, url)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (tab.type === 'webview') {
|
||||
return (
|
||||
<WebviewContainer
|
||||
key={tab.id}
|
||||
url={tab.url}
|
||||
isActive={tab.id === activeTabId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 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 (
|
||||
<DetachedTabWindow
|
||||
path={initialPath}
|
||||
scroll={initialScroll}
|
||||
tabId={tabId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Main window with full tab system
|
||||
return <AppShell />
|
||||
}
|
||||
```
|
||||
|
||||
**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<string>()
|
||||
|
||||
// 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 <RouterProvider router={router} />
|
||||
}
|
||||
```
|
||||
|
||||
#### 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 <ChatUI topic={topic} messages={messages} />
|
||||
}
|
||||
```
|
||||
|
||||
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 `<Activity>` 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 |
|
||||
@ -1,110 +0,0 @@
|
||||
# Architecture: Single Router + Outlet (KeepAlive Required)
|
||||
|
||||
> **Status**: Initial Design (需要 KeepAlive 库支持)
|
||||
> **Core Idea**: 单个 HashRouter,依赖 `<Outlet />` 渲染,需要 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 `<Webview />` 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 (
|
||||
<div className="app-shell">
|
||||
<Sidebar />
|
||||
<div className="main-content">
|
||||
<TabBar />
|
||||
|
||||
{/* Layer 1: Standard Router (Hidden if Webview is active) */}
|
||||
<div style={{ display: isWebviewActive ? 'none' : 'block' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* Layer 2: Webview Overlays (Only for type='webview') */}
|
||||
{tabs.map(tab => {
|
||||
if (tab.type !== 'webview') return null;
|
||||
return (
|
||||
<div style={{ display: isActive ? 'block' : 'none' }}>
|
||||
<Webview url={tab.url} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
```
|
||||
|
||||
## 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. |
|
||||
@ -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 `<Outlet />`
|
||||
|
||||
---
|
||||
|
||||
## 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 + <Outlet/>
|
||||
│ ├── 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 + <Outlet/>
|
||||
│ ├── 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 + <Outlet/>
|
||||
│ ├── 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 + <Outlet/>
|
||||
│ ├── 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 (
|
||||
<div className="flex h-full">
|
||||
{/* Sidebar: Assistant list + Topic list */}
|
||||
<ChatSidebar assistants={data.assistants} topics={data.topics} />
|
||||
|
||||
{/* Chat content area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
```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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<ChatNavbar topic={topic} />
|
||||
<Messages messages={messages} />
|
||||
<Inputbar topicId={topicId} assistantId={assistantId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 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 (
|
||||
<div className="flex h-full">
|
||||
{/* Left menu */}
|
||||
<SettingsMenu />
|
||||
|
||||
{/* Right content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Provider selector */}
|
||||
<ProviderSelect providers={providers} />
|
||||
|
||||
{/* Painting content area */}
|
||||
<div className="flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 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 |
|
||||
260
src/renderer/src/services/__tests__/TabLRUManager.test.ts
Normal file
260
src/renderer/src/services/__tests__/TabLRUManager.test.ts
Normal file
@ -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> = {}): 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
132
src/renderer/src/utils/__tests__/routeTitle.test.ts
Normal file
132
src/renderer/src/utils/__tests__/routeTitle.test.ts
Normal file
@ -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<string, string> = {
|
||||
'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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user