diff --git a/src/renderer/src/components/layout/architecture-multi-memory-router.md b/src/renderer/src/components/layout/architecture-multi-memory-router.md index d9af072262..efb7c8bc3b 100644 --- a/src/renderer/src/components/layout/architecture-multi-memory-router.md +++ b/src/renderer/src/components/layout/architecture-multi-memory-router.md @@ -9,7 +9,7 @@ ### 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) ``` @@ -48,7 +48,7 @@ These two are fundamentally conflicting. The current architecture uses TanStack VS Code 1.85 implemented "Auxiliary Window" feature: -``` +```text ┌─────────────────────────────────────────────────────────┐ │ Main Window │ │ ┌─────────────────────────────────────────────────────┐│ @@ -134,7 +134,7 @@ If not using ``, TSR feature availability: Each Tab has an independent MemoryRouter instance, achieving state isolation and KeepAlive. -``` +```text ┌─────────────────────────────────────────────────────────┐ │ AppShell │ │ ┌─────────────────────────────────────────────────────┐│ @@ -193,9 +193,10 @@ export interface TabsState { ```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, useRef } from 'react' +import { useMemo, useEffect } from 'react' import { routeTree } from '../../routeTree.gen' interface TabRouterProps { @@ -228,20 +229,29 @@ export const TabRouter = ({ tab, isActive, onUrlChange }: TabRouterProps) => { return unsubscribe }, [router, tab.url, onUrlChange]) + // Use React 19.2 Activity for visibility control + // Benefits over CSS display:none: + // - Effects unmount when hidden (timers, subscriptions cleaned up) + // - Effects re-mount when visible (fresh state) + // - Better React integration and memory optimization return ( -
+ -
+ ) } ``` +> **Why Activity over CSS `display:none`?** +> +> | Aspect | CSS `display:none` | React `` | +> |--------|-------------------|-------------------| +> | DOM preserved | ✅ | ✅ | +> | State preserved | ✅ | ✅ | +> | Effects (timers, subscriptions) | ❌ Keep running | ✅ **Cleanup when hidden** | +> | Memory optimization | ❌ None | ✅ React can optimize | +> | Suspense integration | ❌ None | ✅ Better boundaries | + #### 4.3.3 AppShell Component ```typescript @@ -320,48 +330,254 @@ export const AppShell = () => { ### 4.4 Tab Detach to Window -#### 4.4.1 State Serialization +#### 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 -// On detach: serialize current state -const serializeTabState = (tab: Tab): SerializedTabState => { - return { - ...tab, - scrollPosition: captureScrollPosition(tab.id), - inputDraft: captureInputDraft(tab.id), +// 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) } -} -// On new window startup: deserialize to restore state -const deserializeTabState = (state: SerializedTabState): Tab => { - // MemoryRouter will initialize from state.url - // Scroll position, etc. restored after component mounts - return state + // Request main process to create window with URL + await window.api.createWindow({ + url: `index.html?${params.toString()}` + }) + + closeTab(tab.id) } ``` -#### 4.4.2 IPC Communication +**App Entry - Detect Detached Window**: ```typescript -// Main Process -ipcMain.handle('create-window', async (_, options) => { - const newWindow = new BrowserWindow({ - // ... - }) +// 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') - // Pass initial state to new window - newWindow.webContents.once('did-finish-load', () => { - newWindow.webContents.send('init-tab', options.initialTab) - }) -}) + // If has path param, this is a detached window + if (initialPath) { + return ( + + ) + } -// Renderer Process (new window) -window.api.onInitTab((serializedTab) => { - const tab = deserializeTabState(JSON.parse(serializedTab)) - tabStore.addTab(tab) -}) + // Main window with full tab system + return +} ``` +**Detached Window Component**: + +```typescript +// src/renderer/src/components/DetachedTabWindow.tsx +interface Props { + path: string + scroll: number + tabId?: string | null +} + +const DetachedTabWindow = ({ path, scroll, tabId }: Props) => { + const router = useMemo(() => createTabRouter(path), [path]) + const [inputDraft, setInputDraft] = useState() + + // Restore scroll position after mount + useEffect(() => { + if (scroll) { + requestAnimationFrame(() => window.scrollTo(0, scroll)) + } + }, [scroll]) + + // Restore complex state from shared cache + useEffect(() => { + if (tabId) { + cacheService.get(`detached:${tabId}:draft`).then(setInputDraft) + } + }, [tabId]) + + // TSR automatically: match route → run loader → render + return +} +``` + +#### 4.4.5 TSR Loader Works with MemoryRouter + +Key insight: TSR's `loader` runs during route matching, regardless of history type. + +```typescript +// src/renderer/src/routes/chat/$topicId.tsx +export const Route = createFileRoute('/chat/$topicId')({ + loader: async ({ params }) => { + // Runs for BOTH HashRouter and MemoryRouter + const topic = await fetchTopic(params.topicId) + const messages = await fetchMessages(params.topicId) + return { topic, messages } + }, + component: ChatPage +}) + +function ChatPage() { + const { topic, messages } = Route.useLoaderData() + return +} +``` + +When `createMemoryHistory({ initialEntries: ['/chat/123'] })` is called: + +1. TSR parses path `/chat/123` +2. Matches route `/chat/$topicId` +3. Extracts params: `{ topicId: '123' }` +4. Executes `loader({ params })` +5. Renders component with loaded data + +#### 4.4.6 Tab Attach (Drag Back to Main Window) + +```text +┌─────────────────────────────────────────────────────────┐ +│ Tab Lifecycle │ +│ │ +│ Main Window Detached Window │ +│ ┌─────────┐ ┌─────────┐ │ +│ │ Tab 1 │ ──── Drag Out ────► │ Window │ │ +│ │ Tab 2 │ (URL Query) │ │ │ +│ │ Tab 3 │ ◄─── Drag Back ──── │ │ │ +│ └─────────┘ (IPC) └─────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +Drag Out vs Drag Back: + +| Aspect | Drag Out | Drag Back | +|--------|----------|-----------| +| State passing | URL Query | IPC (reverse direction) | +| Detection | Simple (user action) | Detect drop on main window tab bar | +| Window action | Create new window | Close detached window | +| Complexity | Low | Medium | + +**Implementation Key Points**: + +1. **Drop Detection**: Detached window monitors drag events, checks if drop position overlaps main window's tab bar bounds +2. **State Transfer**: Use IPC to send current state (path, scroll, inputDraft) back to main window +3. **Window Coordination**: Main process coordinates between windows - add tab to main, close detached +4. **Graceful Fallback**: If drop detection fails, provide "Attach to Main Window" button as alternative + ### 4.5 Memory Management #### 4.5.1 LRU Eviction Strategy @@ -471,4 +687,7 @@ Reasons for choosing **MemoryHistory Multi-Instance**: | Version | Date | Changes | |---------|------|---------| +| v1.3.0 | 2025-12-04 | Added Tab Attach (drag back) key points | +| v1.2.0 | 2025-12-04 | Replaced CSS `display:none` with React 19.2 `` for better effect management | +| v1.1.0 | 2025-12-03 | Added detailed Tab Detach implementation (URL Query approach, TSR Loader compatibility) | | v1.0.0 | 2025-12-03 | Initial research report |