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:
MyPrototypeWhat 2025-12-24 14:50:03 +08:00
parent e63397d5c2
commit 0599a583da
6 changed files with 392 additions and 1725 deletions

View File

@ -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 |

View File

@ -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 |

View File

@ -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. |

View File

@ -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 |

View 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')
})
})
})

View 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()
})
})
})
})