diff --git a/electron.vite.config.ts b/electron.vite.config.ts index bbb8e2ecf8..4fa3afccf1 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,3 +1,4 @@ +import { tanstackRouter } from '@tanstack/router-plugin/vite' import react from '@vitejs/plugin-react-swc' import { CodeInspectorPlugin } from 'code-inspector-plugin' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' @@ -80,6 +81,12 @@ export default defineConfig({ }, renderer: { plugins: [ + tanstackRouter({ + target: 'react', + autoCodeSplitting: true, + routesDirectory: './src/renderer/src/routes', + generatedRouteTree: './src/renderer/src/routeTree.gen.ts' + }), (async () => (await import('@tailwindcss/vite')).default())(), react({ tsDecorators: true, diff --git a/package.json b/package.json index aafac8b3f8..cd3ff067be 100644 --- a/package.json +++ b/package.json @@ -182,7 +182,9 @@ "@swc/plugin-styled-components": "^8.0.4", "@tailwindcss/vite": "^4.1.13", "@tanstack/react-query": "^5.85.5", + "@tanstack/react-router": "^1.139.3", "@tanstack/react-virtual": "^3.13.12", + "@tanstack/router-plugin": "^1.139.3", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -220,8 +222,8 @@ "@types/mime-types": "^3", "@types/node": "^22.17.1", "@types/pako": "^1.0.2", - "@types/react": "^19.0.12", - "@types/react-dom": "^19.0.4", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", "@types/react-infinite-scroll-component": "^5.0.0", "@types/react-transition-group": "^4.4.12", "@types/react-window": "^1", diff --git a/packages/shared/data/api/apiSchemas.ts b/packages/shared/data/api/apiSchemas.ts index e405af806e..c049cb9261 100644 --- a/packages/shared/data/api/apiSchemas.ts +++ b/packages/shared/data/api/apiSchemas.ts @@ -18,6 +18,25 @@ export type { ConcreteApiPaths } from './apiPaths' * enabling full TypeScript type checking across IPC boundaries. */ export interface ApiSchemas { + /** + * App State storage endpoint + * @example GET /app/state/tabs_state + * @example PUT /app/state/tabs_state { "value": {...} } + */ + '/app/state/:key': { + /** Get app state by key */ + GET: { + params: { key: string } + response: any + } + /** Save app state */ + PUT: { + params: { key: string } + body: any + response: { success: boolean } + } + } + /** * Test items collection endpoint * @example GET /test/items?page=1&limit=10&search=hello diff --git a/packages/shared/data/cache/cacheSchemas.ts b/packages/shared/data/cache/cacheSchemas.ts index f75fd4f4f3..ed835eeb36 100644 --- a/packages/shared/data/cache/cacheSchemas.ts +++ b/packages/shared/data/cache/cacheSchemas.ts @@ -26,6 +26,9 @@ export type UseCacheSchema = { 'topic.active': CacheValueTypes.CacheTopic | null 'topic.renaming': string[] 'topic.newly_renamed': string[] + + // UI State + 'ui.activeTabId': string } export const DefaultUseCache: UseCacheSchema = { @@ -56,7 +59,10 @@ export const DefaultUseCache: UseCacheSchema = { // Topic management 'topic.active': null, 'topic.renaming': [], - 'topic.newly_renamed': [] + 'topic.newly_renamed': [], + + // UI State + 'ui.activeTabId': '' } /** diff --git a/src/main/data/api/handlers/index.ts b/src/main/data/api/handlers/index.ts index 817a882be8..bc911597b5 100644 --- a/src/main/data/api/handlers/index.ts +++ b/src/main/data/api/handlers/index.ts @@ -5,6 +5,7 @@ * TypeScript will error if any endpoint is missing. */ +import { appStateService } from '@data/services/AppStateService' import { TestService } from '@data/services/TestService' import type { ApiImplementation } from '@shared/data/api/apiSchemas' @@ -16,6 +17,18 @@ const testService = TestService.getInstance() * Must implement every path+method combination from ApiSchemas */ export const apiHandlers: ApiImplementation = { + '/app/state/:key': { + GET: async ({ params }) => { + const state = await appStateService.getState(params.key) + return state + }, + + PUT: async ({ params, body }) => { + await appStateService.setState(params.key, body) + return { success: true } + } + }, + '/test/items': { GET: async ({ query }) => { return await testService.getItems({ diff --git a/src/main/data/services/AppStateService.ts b/src/main/data/services/AppStateService.ts new file mode 100644 index 0000000000..c8c6596446 --- /dev/null +++ b/src/main/data/services/AppStateService.ts @@ -0,0 +1,100 @@ +import { dbService } from '@data/db/DbService' +import { appStateTable } from '@data/db/schemas/appState' +import { loggerService } from '@logger' +import { eq } from 'drizzle-orm' + +const logger = loggerService.withContext('AppStateService') + +/** + * Service for managing application state in the database. + * Provides key-value storage for persisting UI state like tabs, window positions, etc. + */ +export class AppStateService { + private static instance: AppStateService + + private constructor() {} + + public static getInstance(): AppStateService { + if (!AppStateService.instance) { + AppStateService.instance = new AppStateService() + } + return AppStateService.instance + } + + /** + * Get app state by key + * @param key - The state key to retrieve + * @returns The stored value or null if not found + */ + async getState(key: string): Promise { + try { + const db = dbService.getDb() + const result = await db.select().from(appStateTable).where(eq(appStateTable.key, key)).limit(1) + + if (result.length === 0) { + logger.debug('App state not found', { key }) + return null + } + + logger.debug('Retrieved app state', { key }) + return result[0].value as T + } catch (error) { + logger.error('Failed to get app state', error as Error, { key }) + throw error + } + } + + /** + * Save app state by key (upsert) + * @param key - The state key + * @param value - The value to store (will be JSON serialized) + * @param description - Optional description of what this state stores + */ + async setState(key: string, value: T, description?: string): Promise { + try { + const db = dbService.getDb() + + await db + .insert(appStateTable) + .values({ + key, + value: value as any, + description + }) + .onConflictDoUpdate({ + target: appStateTable.key, + set: { + value: value as any, + description, + updatedAt: Date.now() + } + }) + + logger.debug('Saved app state', { key }) + } catch (error) { + logger.error('Failed to save app state', error as Error, { key }) + throw error + } + } + + /** + * Delete app state by key + * @param key - The state key to delete + * @returns true if deleted, false if not found + */ + async deleteState(key: string): Promise { + try { + const db = dbService.getDb() + const result = await db.delete(appStateTable).where(eq(appStateTable.key, key)) + + const deleted = result.rowsAffected > 0 + logger.debug('Deleted app state', { key, deleted }) + return deleted + } catch (error) { + logger.error('Failed to delete app state', error as Error, { key }) + throw error + } + } +} + +export const appStateService = AppStateService.getInstance() diff --git a/src/renderer/src/AppRouter.tsx b/src/renderer/src/AppRouter.tsx new file mode 100644 index 0000000000..d3bdcf413c --- /dev/null +++ b/src/renderer/src/AppRouter.tsx @@ -0,0 +1,17 @@ +import { createRouter, RouterProvider } from '@tanstack/react-router' + +import { routeTree } from './routeTree.gen' + +// Create a new router instance +const router = createRouter({ routeTree }) + +// Register the router instance for type safety +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +export const AppRouter = () => { + return +} diff --git a/src/renderer/src/components/layout/AppShell.tsx b/src/renderer/src/components/layout/AppShell.tsx new file mode 100644 index 0000000000..c5a881ca76 --- /dev/null +++ b/src/renderer/src/components/layout/AppShell.tsx @@ -0,0 +1,187 @@ +// TODO demo component +import { cn } from '@cherrystudio/ui' +import { Link, Outlet, useLocation, useNavigate } from '@tanstack/react-router' +import { X } from 'lucide-react' +import { useEffect } from 'react' + +import { useTabs } from '../../hooks/useTabs' + +// Mock Sidebar component (Replace with actual one later) +const Sidebar = ({ onNavigate }: { onNavigate: (id: string) => void }) => { + // Helper to render a Sidebar Link that acts as a Tab Switcher + const SidebarItem = ({ to, title, id }: { to: string; title: string; id: string }) => ( + { + // Intercept the router navigation! + // We want to switch tabs, not just navigate within the current tab. + e.preventDefault() + onNavigate(id) + }}> + {title.slice(0, 1).toUpperCase() + title.slice(1, 3)} + + ) + + return ( + + ) +} + +// Mock MinApp component (Replace with actual implementation) +const MinApp = ({ url }: { url: string }) => ( +
+
Webview App
+ {url} +
+) + +export const AppShell = () => { + const { tabs, activeTabId, setActiveTab, closeTab, addTab, updateTab } = useTabs() + const navigate = useNavigate() + const location = useLocation() + + // 1. Sync Route -> Tab (Handle internal navigation & deep links) + useEffect(() => { + const currentPath = location.pathname + const activeTab = tabs.find((t) => t.id === activeTabId) + + if (activeTab?.type === 'url' && activeTab.url !== currentPath) { + const existingTab = tabs.find((t) => t.type === 'url' && t.url === currentPath && t.id !== activeTabId) + if (existingTab) { + setActiveTab(existingTab.id) + } else { + // Sync URL changes back to DB + updateTab(activeTabId, { url: currentPath }) + } + } + }, [location.pathname, tabs, activeTabId, setActiveTab, updateTab]) + + // 2. Sync Tab -> Route (Handle tab switching) + useEffect(() => { + const activeTab = tabs.find((t) => t.id === activeTabId) + if (!activeTab) return + + if (activeTab.type === 'url') { + if (location.pathname !== activeTab.url) { + navigate({ to: activeTab.url }) + } + } + }, [activeTabId, tabs, navigate, location.pathname]) + + const handleSidebarClick = (menuId: string) => { + let targetUrl = '' + let targetTitle = '' + + switch (menuId) { + case 'home': + targetUrl = '/' + targetTitle = 'Home' + break + case 'settings': + targetUrl = '/settings' + targetTitle = 'Settings' + break + default: + return + } + + const existingTab = tabs.find((t) => t.type === 'url' && t.url === targetUrl) + + if (existingTab) { + setActiveTab(existingTab.id) + } else { + addTab({ + id: `${menuId}-${Date.now()}`, + type: 'url', + url: targetUrl, + title: targetTitle + }) + } + } + + const activeTab = tabs.find((t) => t.id === activeTabId) + const isWebviewActive = activeTab?.type === 'webview' + + return ( +
+ {/* Zone 1: Sidebar */} + + +
+ {/* Zone 2: Tab Bar */} +
+
+
+ {tabs.map((tab) => ( + setActiveTab(tab.id)} + className={cn( + 'relative flex h-full min-w-[120px] max-w-[200px] items-center justify-between gap-2 border-border/40 border-r px-3 py-2 text-sm transition-colors hover:bg-muted/50', + tab.id === activeTabId ? 'bg-background shadow-sm' : 'bg-transparent opacity-70 hover:opacity-100' + )}> + {tab.title} +
{ + e.stopPropagation() + e.preventDefault() + closeTab(tab.id) + }} + className="ml-1 cursor-pointer rounded-sm p-0.5 opacity-50 hover:bg-muted-foreground/20 hover:opacity-100"> + +
+ + ))} +
+
+
+ + {/* Zone 3: Content Area (Simplified Hybrid Architecture) */} +
+ {/* Layer A: Standard Router Outlet */} + {/* Always rendered, but hidden if a webview is active. This keeps the Router alive. */} +
+ +
+ + {/* Layer B: Webview Apps (Overlay) */} + {tabs.map((tab) => { + if (tab.type !== 'webview') return null + return ( +
+ +
+ ) + })} +
+
+
+ ) +} diff --git a/src/renderer/src/components/layout/router-architecture.md b/src/renderer/src/components/layout/router-architecture.md new file mode 100644 index 0000000000..dc4bbe0b9f --- /dev/null +++ b/src/renderer/src/components/layout/router-architecture.md @@ -0,0 +1,107 @@ +# Router Architecture & Tab System + +## Overview + +This document describes the routing and tab management architecture for Cherry Studio. The system implements a "Chrome-like" tabbed interface where every tab can be a distinct application state (Chat, Settings, MinApp/Webview). + +The architecture is **Hybrid**, combining: + +1. **TanStack Router (HashRouter)**: Handles URL parsing and standard React page rendering. Standard pages (Home, Settings) are re-mounted on tab switch but optimized via data caching. +2. **Webview Overlay System**: Manages persistent processes (Webviews) that live *outside* the router to ensure they are never destroyed during navigation. +3. **Bidirectional State Sync**: `AppShell` ensures the URL bar and the Tab Database (`app_state` table) are always in sync. + +## Core Architecture + +### 1. Hybrid Rendering + +We use a "Single Router + Overlay" approach. We do **not** force `KeepAlive` for standard React pages, as it complicates the router logic significantly. Instead, we rely on TanStack Router's fast caching to make re-mounting feel instant. + +* **Layer 1: Standard Router (The Outlet)** + * Always present in the DOM. + * Renders standard pages (Home, Settings, Native Chat). + * **Behavior**: When switching between standard tabs (e.g., Home -> Settings), the router navigates, unmounting the old component and mounting the new one. + * **Optimization**: Data loaders are cached, so "re-mounting" is cheap and fast. + * **Visibility**: Hidden via `display: none` if a Webview tab is active. + +* **Layer 2: Webview Overlays** + * Rendered **outside** the Router. + * **Behavior**: These components are *never* unmounted as long as the tab is open. + * **Visibility**: Controlled purely by CSS (`display: block` / `none`). + * **Purpose**: To keep heavy processes (like MinApps or external websites) alive in the background. + +### 2. State Synchronization (The "Listener" Pattern) + +Since we use a single Router instance, we must manually sync the "Active Tab's URL" with the "Router's URL". + +* **URL -> Database (Passive Sync)**: + * A `useEffect` hook in `AppShell` listens to `location.pathname`. + * If the URL changes (e.g., user navigates inside a Chat tab), we update the current tab's `url` field in the SQLite database. + * *Benefit*: Restores the exact sub-route (e.g., `/chat/session-123`) when the user comes back later. + +* **Tab Switch -> Router (Active Navigation)**: + * When the user clicks a tab, we read its stored `url` from the database. + * We calling `navigate({ to: storedUrl })` to restore the view. + +### 3. Data Management + +* **Storage**: Tab data (`tabs`, `activeTabId`) is stored in SQLite (`app_state` table). +* **Sync**: `useTabs` hook uses SWR (`useQuery`) to sync frontend state with the database. +* **Optimistic Updates**: UI updates immediately, background sync handles persistence. + +## Routing & Overlay Mapping + +For detailed route tree definitions and component mappings, please refer to [Router Planning](./router-planning.md). + +### Handling Webview Routes + +The planning document mentions routes like `/apps/$appId` that may correspond to Webview applications. In our Hybrid Architecture, these are handled as follows: + +1. **Router Layer**: The route `/apps/$appId` is still defined in TSR. + * Purpose: Maintains URL semantics and supports deep linking. + * Rendering: Renders a "shell" component or loading state. +2. **Overlay Layer**: `AppShell` detects that the current Tab type is `webview`. + * Behavior: Hides the Router's Outlet. + * Rendering: Displays the corresponding `` instance in the Overlay layer. + +This mechanism ensures that even Webview apps have standard URLs, providing a consistent navigation experience across the application. + +## Key Components + +### `AppShell` (`src/renderer/src/components/layout/AppShell.tsx`) + +The coordinator that manages the two layers. + +```tsx +return ( +
+ +
+ + + {/* Layer 1: Standard Router (Hidden if Webview is active) */} +
+ +
+ + {/* Layer 2: Webview Overlays (Only for type='webview') */} + {tabs.map(tab => { + if (tab.type !== 'webview') return null; + return ( +
+ +
+ ) + })} +
+
+) +``` + +## Trade-offs + +| Feature | Approach | Rationale | +| :--- | :--- | :--- | +| **Standard Pages** | Re-mount on switch | Simplicity. Reactivity problems with KeepAlive are avoided. TSR caching makes it fast. | +| **Webviews** | Keep-Alive (CSS Hide) | Essential. Reloading an external app/website is bad UX. | +| **Routing** | HashRouter | Native to Electron file system. Avoids history API complexities. | +| **URL Logic** | Single Source of Truth | The address bar always reflects the *active* tab. Background tabs are just state in DB. | diff --git a/src/renderer/src/components/layout/router-planning.md b/src/renderer/src/components/layout/router-planning.md new file mode 100644 index 0000000000..90a0232242 --- /dev/null +++ b/src/renderer/src/components/layout/router-planning.md @@ -0,0 +1,347 @@ +# Router Planning + +> Version: v0.1.0 +> Updated: 2025-11-25 +> Status: Draft + +## 1. Overview + +This document defines the routing structure plan for migrating Cherry Studio from React Router to TanStack Router (TSR). + +### 1.1 Core Interaction Model + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Left Sidebar │ Top Tab Bar │ +│ (Shortcuts) │ [Tab1] [Tab2] [Tab3] [+] │ +│ ┌───────────┐ ├──────────────────────────────────────────┤ +│ │ 💬 Chat │ │ │ +│ │ ⚙️ Settings│ │ Content Area (Outlet) │ +│ │ 📁 Files │ │ │ +│ │ 📝 Notes │ │ Rendered based on active Tab's URL │ +│ │ ... │ │ │ +│ └───────────┘ │ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +- **Left Sidebar**: Like a "bookmarks bar", stores shortcuts. Clicking navigates to the URL (may reuse existing Tab or create new Tab) +- **Top Tab Bar**: Manages multiple open pages, supports closing and switching +- **Content Area**: Rendered by TanStack Router's `` + +--- + +## 2. Route Structure + +### 2.1 Directory Structure + +``` +src/renderer/src/routes/ +├── __root.tsx # Root route → AppShell +├── index.tsx # / → Welcome page or redirect (TBD) +│ +├── chat/ +│ ├── route.tsx # /chat layout: sidebar + +│ ├── index.tsx # /chat → Empty state (no topic selected) +│ └── $assistantId/ +│ ├── route.tsx # /chat/$assistantId layout (optional) +│ ├── index.tsx # /chat/$assistantId → Assistant home (optional) +│ └── $topicId.tsx # /chat/$assistantId/$topicId → Chat view +│ +├── settings/ +│ ├── route.tsx # /settings layout: menu + +│ ├── index.tsx # /settings → Redirect to default sub-page +│ ├── provider.tsx # /settings/provider +│ ├── model.tsx # /settings/model +│ ├── general.tsx # /settings/general +│ ├── display.tsx # /settings/display +│ ├── data.tsx # /settings/data +│ ├── mcp.tsx # /settings/mcp +│ ├── shortcut.tsx # /settings/shortcut +│ └── about.tsx # /settings/about +│ +├── knowledge/ +│ ├── route.tsx # /knowledge layout +│ ├── index.tsx # /knowledge → Knowledge base list +│ └── $baseId.tsx # /knowledge/$baseId → Knowledge base detail +│ +├── notes/ +│ ├── route.tsx # /notes layout: tree sidebar + +│ ├── index.tsx # /notes → Empty state +│ └── $noteId.tsx # /notes/$noteId → Editor +│ +├── apps/ +│ ├── route.tsx # /apps layout +│ ├── index.tsx # /apps → App list +│ └── $appId.tsx # /apps/$appId → App detail (possibly Webview) +│ +├── paintings/ +│ ├── route.tsx # /paintings layout: provider select + +│ ├── index.tsx # /paintings → Redirect to default provider +│ ├── zhipu.tsx # /paintings/zhipu → Zhipu painting +│ ├── aihubmix.tsx # /paintings/aihubmix → Aihubmix +│ ├── silicon.tsx # /paintings/silicon → Silicon Flow +│ ├── dmxapi.tsx # /paintings/dmxapi → Dmxapi +│ ├── tokenflux.tsx # /paintings/tokenflux → TokenFlux +│ ├── ovms.tsx # /paintings/ovms → OVMS +│ └── $providerId.tsx # /paintings/$providerId → Dynamic NewApi provider +│ +├── files.tsx # /files → File management +├── translate.tsx # /translate → Translation +├── store.tsx # /store → App store +└── launchpad.tsx # /launchpad → Launchpad +``` + +### 2.2 Route Table + +| Route | Component | Loader Data | Description | +|-------|-----------|-------------|-------------| +| `/` | `WelcomePage` | - | Welcome page or redirect (TBD) | +| `/chat` | `ChatLayout` | Assistants, Topics | Chat layout layer | +| `/chat/$assistantId/$topicId` | `ChatView` | Topic detail, Messages | Chat main view | +| `/settings` | `SettingsLayout` | - | Settings layout layer | +| `/settings/provider` | `ProviderSettings` | Provider list | Provider settings | +| `/settings/model` | `ModelSettings` | Model list | Model settings | +| `/settings/*` | `*Settings` | Respective data | Other settings pages | +| `/knowledge` | `KnowledgeLayout` | Knowledge bases | Knowledge layout | +| `/knowledge/$baseId` | `KnowledgeDetail` | Knowledge detail | Knowledge detail page | +| `/notes` | `NotesLayout` | Notes tree | Notes layout | +| `/notes/$noteId` | `NotesEditor` | Note content | Notes editor | +| `/apps` | `AppsLayout` | App list | Apps layout | +| `/apps/$appId` | `AppDetail` | App detail | App detail/Webview | +| `/paintings` | `PaintingsLayout` | Provider list | Paintings layout layer | +| `/paintings/zhipu` | `ZhipuPage` | - | Zhipu painting | +| `/paintings/aihubmix` | `AihubmixPage` | - | Aihubmix painting | +| `/paintings/silicon` | `SiliconPage` | - | Silicon Flow painting | +| `/paintings/dmxapi` | `DmxapiPage` | - | Dmxapi painting | +| `/paintings/tokenflux` | `TokenFluxPage` | - | TokenFlux painting | +| `/paintings/ovms` | `OvmsPage` | - | OVMS painting | +| `/paintings/$providerId` | `NewApiPage` | - | Dynamic NewApi provider | +| `/files` | `FilesPage` | File list | File management | +| `/translate` | `TranslatePage` | - | Translation page | +| `/store` | `StorePage` | Store data | App store | +| `/launchpad` | `LaunchpadPage` | - | Launchpad | + +--- + +## 3. Chat Route Design + +### 3.1 URL Structure + +``` +/chat/$assistantId/$topicId + │ │ + │ └── Topic ID (conversation ID) + └── Assistant ID +``` + +**Examples**: + +- `/chat` → Chat home (sidebar + empty state) +- `/chat/assistant-1` → Assistant 1's home (optional, may redirect to first topic) +- `/chat/assistant-1/topic-123` → Chat view for topic 123 under assistant 1 + +### 3.2 Component Structure + +```tsx +// routes/chat/route.tsx +export const Route = createFileRoute('/chat')({ + component: ChatLayout, + loader: async () => ({ + assistants: await fetchAssistants(), + topics: await fetchTopics() + }), + staleTime: 30_000, +}) + +function ChatLayout() { + const data = Route.useLoaderData() + + return ( +
+ {/* Sidebar: Assistant list + Topic list */} + + + {/* Chat content area */} +
+ +
+
+ ) +} +``` + +```tsx +// routes/chat/$assistantId/$topicId.tsx +export const Route = createFileRoute('/chat/$assistantId/$topicId')({ + component: ChatView, + loader: async ({ params }) => ({ + topic: await fetchTopic(params.topicId), + messages: await fetchMessages(params.topicId) + }), + staleTime: 10_000, +}) + +function ChatView() { + const { topic, messages } = Route.useLoaderData() + const { assistantId, topicId } = Route.useParams() + + return ( +
+ + + +
+ ) +} +``` + +### 3.3 Data Flow + +``` +1. User clicks topic in sidebar + ↓ +2. navigate({ to: '/chat/$assistantId/$topicId' }) + ↓ +3. TSR matches route, checks loader cache + ↓ +4. Cache hit → Render directly + Cache miss → Execute loader, fetch data + ↓ +5. ChatLayout does not re-render (parent route data cached) + ↓ +6. Only ChatView updates (child route data changed) +``` + +--- + +## 4. Settings Route Design + +### 4.1 Sub-page List + +| Route | Component | Existing File | +|-------|-----------|---------------| +| `/settings/provider` | `ProviderSettings` | `ProviderSettings/` | +| `/settings/model` | `ModelSettings` | `ModelSettings/` | +| `/settings/general` | `GeneralSettings` | `GeneralSettings.tsx` | +| `/settings/display` | `DisplaySettings` | `DisplaySettings.tsx` | +| `/settings/data` | `DataSettings` | `DataSettings/` | +| `/settings/mcp` | `MCPSettings` | `MCPSettings/` | +| `/settings/websearch` | `WebSearchSettings` | `WebSearchSettings/` | +| `/settings/memory` | `MemorySettings` | `MemorySettings/` | +| `/settings/shortcut` | `ShortcutSettings` | `ShortcutSettings.tsx` | +| `/settings/quickassistant` | `QuickAssistantSettings` | `QuickAssistantSettings.tsx` | +| `/settings/about` | `AboutSettings` | `AboutSettings.tsx` | + +### 4.2 Layout Structure + +```tsx +// routes/settings/route.tsx +function SettingsLayout() { + return ( +
+ {/* Left menu */} + + + {/* Right content */} +
+ +
+
+ ) +} +``` + +--- + +## 5. Paintings Route Design + +### 5.1 URL Structure + +``` +/paintings/$providerId + │ + └── Provider ID (zhipu, aihubmix, silicon, dmxapi, tokenflux, ovms, or dynamic NewApi provider) +``` + +**Examples**: + +- `/paintings` → Redirect to user's default painting provider +- `/paintings/zhipu` → Zhipu painting page +- `/paintings/aihubmix` → Aihubmix painting page +- `/paintings/my-custom-provider` → User's custom NewApi provider + +### 5.2 Provider List + +| Provider ID | Component | Description | +|-------------|-----------|-------------| +| `zhipu` | `ZhipuPage` | Zhipu AI Painting | +| `aihubmix` | `AihubmixPage` | Aihubmix Aggregation | +| `silicon` | `SiliconPage` | Silicon Flow | +| `dmxapi` | `DmxapiPage` | Dmxapi | +| `tokenflux` | `TokenFluxPage` | TokenFlux | +| `ovms` | `OvmsPage` | OVMS (Local Inference) | +| `$providerId` | `NewApiPage` | Dynamic NewApi Provider | + +### 5.3 Component Structure + +```tsx +// routes/paintings/route.tsx +export const Route = createFileRoute('/paintings')({ + component: PaintingsLayout, + loader: async () => ({ + providers: await fetchPaintingProviders(), + defaultProvider: await getDefaultPaintingProvider() + }), +}) + +function PaintingsLayout() { + const { providers } = Route.useLoaderData() + + return ( +
+ {/* Provider selector */} + + + {/* Painting content area */} +
+ +
+
+ ) +} +``` + +### 5.4 Special Handling + +- **OVMS Provider**: Only shown in options when local OVMS service is running +- **Dynamic Providers**: Custom providers added by users via NewApi, captured using `$providerId` + +--- + +## 6. Component Mapping + +| New Route Component | Existing Component | Migration Strategy | +|---------------------|-------------------|-------------------| +| `ChatLayout` | `HomePage.tsx` | Extract sidebar logic | +| `ChatSidebar` | `HomeTabs/index.tsx` | Rename, adjust props | +| `ChatView` | `Chat.tsx` | Keep unchanged, adjust data fetching | +| `SettingsLayout` | `SettingsPage.tsx` | Extract layout logic | +| `NotesLayout` | `NotesSidebar.tsx` | Extract as layout component | +| `NotesEditor` | `NotesEditor.tsx` | Keep unchanged | + +--- + +## 7. Open Questions + +- [ ] `/` home behavior: Redirect to `/chat` or standalone welcome page? +- [ ] Does `/chat/$assistantId` need a dedicated page? Or redirect to first topic directly? +- [ ] Left sidebar interaction: Always create new Tab on click? Or reuse existing Tab? +- [ ] Tab bar UI details: Close button position, drag-to-reorder, context menu, etc. + +--- + +## 8. Changelog + +| Version | Date | Changes | +|---------|------|---------| +| v0.1.0 | 2025-11-25 | Initial version | diff --git a/src/renderer/src/data/hooks/useDataApi.ts b/src/renderer/src/data/hooks/useDataApi.ts index e13b0c37c5..50467d12ab 100644 --- a/src/renderer/src/data/hooks/useDataApi.ts +++ b/src/renderer/src/data/hooks/useDataApi.ts @@ -2,6 +2,7 @@ import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/d import type { ConcreteApiPaths } from '@shared/data/api/apiSchemas' import type { PaginatedResponse } from '@shared/data/api/apiTypes' import { useState } from 'react' +import type { KeyedMutator } from 'swr' import useSWR, { useSWRConfig } from 'swr' import useSWRMutation from 'swr/mutation' @@ -139,6 +140,8 @@ export function useQuery( error?: Error /** Function to manually refetch data */ refetch: () => void + /** SWR mutate function for optimistic updates */ + mutate: KeyedMutator> } { // Internal type conversion for SWR compatibility const key = options?.enabled !== false ? buildSWRKey(path, options?.query as Record) : null @@ -160,7 +163,8 @@ export function useQuery( data, loading: isLoading || isValidating, error: error as Error | undefined, - refetch + refetch, + mutate } } diff --git a/src/renderer/src/hooks/useTabs.ts b/src/renderer/src/hooks/useTabs.ts new file mode 100644 index 0000000000..f290942f1d --- /dev/null +++ b/src/renderer/src/hooks/useTabs.ts @@ -0,0 +1,122 @@ +import { loggerService } from '@logger' +import { useCallback, useMemo } from 'react' + +import { useMutation, useQuery } from '../data/hooks/useDataApi' + +const logger = loggerService.withContext('useTabs') + +export type TabType = 'webview' | 'url' | 'browser' + +export interface Tab { + id: string + type: TabType + url: string + title: string + icon?: string + isKeepAlive?: boolean + metadata?: Record +} + +interface TabsState { + tabs: Tab[] + activeTabId: string +} + +const TABS_STORAGE_KEY = 'tabs_state' +const DEFAULT_STATE: TabsState = { tabs: [], activeTabId: '' } + +export function useTabs() { + // Load state from DB + // We cast the path because we haven't fully updated the concrete path types globally yet + const { + data: tabsState, + mutate: mutateState, + loading: isLoading + } = useQuery(`/app/state/${TABS_STORAGE_KEY}` as any, { + swrOptions: { + revalidateOnFocus: false, + fallbackData: DEFAULT_STATE + } + }) + + // Ensure we always have a valid object structure + const currentState: TabsState = useMemo( + () => (tabsState && typeof tabsState === 'object' ? (tabsState as TabsState) : DEFAULT_STATE), + [tabsState] + ) + const tabs = useMemo(() => (Array.isArray(currentState.tabs) ? currentState.tabs : []), [currentState.tabs]) + const activeTabId = currentState.activeTabId || '' + + // Mutation for saving + const saveMutation = useMutation('PUT', `/app/state/${TABS_STORAGE_KEY}` as any) + + // Unified update helper + const updateState = useCallback( + async (newState: TabsState) => { + // 1. Optimistic update local cache + await mutateState(newState, { revalidate: false }) + + // 2. Sync to DB + saveMutation.mutate({ body: newState }).catch((err) => logger.error('Failed to save tabs state:', err)) + }, + [mutateState, saveMutation] + ) + + const addTab = useCallback( + (tab: Tab) => { + const exists = tabs.find((t) => t.id === tab.id) + if (exists) { + updateState({ ...currentState, activeTabId: tab.id }) + return + } + const newTabs = [...tabs, tab] + updateState({ tabs: newTabs, activeTabId: tab.id }) + }, + [tabs, currentState, updateState] + ) + + const closeTab = useCallback( + (id: string) => { + const newTabs = tabs.filter((t) => t.id !== id) + let newActiveId = activeTabId + + if (activeTabId === id) { + // Activate adjacent tab + const index = tabs.findIndex((t) => t.id === id) + // Try to go left, then right + const nextTab = newTabs[index - 1] || newTabs[index] + newActiveId = nextTab ? nextTab.id : '' + } + + updateState({ tabs: newTabs, activeTabId: newActiveId }) + }, + [tabs, activeTabId, updateState] + ) + + const setActiveTab = useCallback( + (id: string) => { + if (id !== activeTabId) { + updateState({ ...currentState, activeTabId: id }) + } + }, + [activeTabId, currentState, updateState] + ) + + const updateTab = useCallback( + (id: string, updates: Partial) => { + const newTabs = tabs.map((t) => (t.id === id ? { ...t, ...updates } : t)) + updateState({ ...currentState, tabs: newTabs }) + }, + [tabs, currentState, updateState] + ) + + return { + tabs, + activeTabId, + isLoading, + addTab, + closeTab, + setActiveTab, + updateTab + } +} diff --git a/src/renderer/src/routeTree.gen.ts b/src/renderer/src/routeTree.gen.ts new file mode 100644 index 0000000000..a9c8543e57 --- /dev/null +++ b/src/renderer/src/routeTree.gen.ts @@ -0,0 +1,55 @@ +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' + fileRoutesByTo: FileRoutesByTo + to: '/' + id: '__root__' | '/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute +} +export const routeTree = rootRouteImport._addFileChildren(rootRouteChildren)._addFileTypes() diff --git a/src/renderer/src/routes/__root.tsx b/src/renderer/src/routes/__root.tsx new file mode 100644 index 0000000000..60ee43b324 --- /dev/null +++ b/src/renderer/src/routes/__root.tsx @@ -0,0 +1,7 @@ +import { createRootRoute } from '@tanstack/react-router' + +import { AppShell } from '../components/layout/AppShell' + +export const Route = createRootRoute({ + component: () => +}) diff --git a/src/renderer/src/routes/index.tsx b/src/renderer/src/routes/index.tsx new file mode 100644 index 0000000000..89bf801e57 --- /dev/null +++ b/src/renderer/src/routes/index.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Index +}) + +function Index() { + return ( +
+

Welcome to Cherry Studio!

+

Select a tab to start.

+
+ ) +} diff --git a/yarn.lock b/yarn.lock index f087d77d25..5e4c47f2ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1545,7 +1545,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.21.3": +"@babel/core@npm:^7.21.3, @babel/core@npm:^7.23.7, @babel/core@npm:^7.27.4": version: 7.28.5 resolution: "@babel/core@npm:7.28.5" dependencies: @@ -1591,6 +1591,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.27.5, @babel/generator@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/generator@npm:7.28.5" + dependencies: + "@babel/parser": "npm:^7.28.5" + "@babel/types": "npm:^7.28.5" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10c0/9f219fe1d5431b6919f1a5c60db8d5d34fe546c0d8f5a8511b32f847569234ffc8032beb9e7404649a143f54e15224ecb53a3d11b6bb85c3203e573d91fca752 + languageName: node + linkType: hard + "@babel/generator@npm:^7.28.0, @babel/generator@npm:^7.28.3": version: 7.28.3 resolution: "@babel/generator@npm:7.28.3" @@ -1604,16 +1617,12 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/generator@npm:7.28.5" +"@babel/helper-annotate-as-pure@npm:^7.27.3": + version: 7.27.3 + resolution: "@babel/helper-annotate-as-pure@npm:7.27.3" dependencies: - "@babel/parser": "npm:^7.28.5" - "@babel/types": "npm:^7.28.5" - "@jridgewell/gen-mapping": "npm:^0.3.12" - "@jridgewell/trace-mapping": "npm:^0.3.28" - jsesc: "npm:^3.0.2" - checksum: 10c0/9f219fe1d5431b6919f1a5c60db8d5d34fe546c0d8f5a8511b32f847569234ffc8032beb9e7404649a143f54e15224ecb53a3d11b6bb85c3203e573d91fca752 + "@babel/types": "npm:^7.27.3" + checksum: 10c0/94996ce0a05b7229f956033e6dcd69393db2b0886d0db6aff41e704390402b8cdcca11f61449cb4f86cfd9e61b5ad3a73e4fa661eeed7846b125bd1c33dbc633 languageName: node linkType: hard @@ -1630,6 +1639,23 @@ __metadata: languageName: node linkType: hard +"@babel/helper-create-class-features-plugin@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-create-class-features-plugin@npm:7.28.5" + dependencies: + "@babel/helper-annotate-as-pure": "npm:^7.27.3" + "@babel/helper-member-expression-to-functions": "npm:^7.28.5" + "@babel/helper-optimise-call-expression": "npm:^7.27.1" + "@babel/helper-replace-supers": "npm:^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" + "@babel/traverse": "npm:^7.28.5" + semver: "npm:^6.3.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/786a6514efcf4514aaad85beed419b9184d059f4c9a9a95108f320142764999827252a851f7071de19f29424d369616573ecbaa347f1ce23fb12fc6827d9ff56 + languageName: node + linkType: hard + "@babel/helper-globals@npm:^7.28.0": version: 7.28.0 resolution: "@babel/helper-globals@npm:7.28.0" @@ -1637,6 +1663,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-member-expression-to-functions@npm:^7.27.1, @babel/helper-member-expression-to-functions@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-member-expression-to-functions@npm:7.28.5" + dependencies: + "@babel/traverse": "npm:^7.28.5" + "@babel/types": "npm:^7.28.5" + checksum: 10c0/4e6e05fbf4dffd0bc3e55e28fcaab008850be6de5a7013994ce874ec2beb90619cda4744b11607a60f8aae0227694502908add6188ceb1b5223596e765b44814 + languageName: node + linkType: hard + "@babel/helper-module-imports@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-module-imports@npm:7.27.1" @@ -1647,7 +1683,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-module-transforms@npm:^7.28.3": +"@babel/helper-module-transforms@npm:^7.27.1, @babel/helper-module-transforms@npm:^7.28.3": version: 7.28.3 resolution: "@babel/helper-module-transforms@npm:7.28.3" dependencies: @@ -1660,6 +1696,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-optimise-call-expression@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-optimise-call-expression@npm:7.27.1" + dependencies: + "@babel/types": "npm:^7.27.1" + checksum: 10c0/6b861e7fcf6031b9c9fc2de3cd6c005e94a459d6caf3621d93346b52774925800ca29d4f64595a5ceacf4d161eb0d27649ae385110ed69491d9776686fa488e6 + languageName: node + linkType: hard + "@babel/helper-plugin-utils@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-plugin-utils@npm:7.27.1" @@ -1667,6 +1712,29 @@ __metadata: languageName: node linkType: hard +"@babel/helper-replace-supers@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-replace-supers@npm:7.27.1" + dependencies: + "@babel/helper-member-expression-to-functions": "npm:^7.27.1" + "@babel/helper-optimise-call-expression": "npm:^7.27.1" + "@babel/traverse": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/4f2eaaf5fcc196580221a7ccd0f8873447b5d52745ad4096418f6101a1d2e712e9f93722c9a32bc9769a1dc197e001f60d6f5438d4dfde4b9c6a9e4df719354c + languageName: node + linkType: hard + +"@babel/helper-skip-transparent-expression-wrappers@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.27.1" + dependencies: + "@babel/traverse": "npm:^7.27.1" + "@babel/types": "npm:^7.27.1" + checksum: 10c0/f625013bcdea422c470223a2614e90d2c1cc9d832e97f32ca1b4f82b34bb4aa67c3904cb4b116375d3b5b753acfb3951ed50835a1e832e7225295c7b0c24dff7 + languageName: node + linkType: hard + "@babel/helper-string-parser@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-string-parser@npm:7.27.1" @@ -1705,7 +1773,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.28.3, @babel/parser@npm:^7.28.4, @babel/parser@npm:^7.28.5": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.6, @babel/parser@npm:^7.28.3, @babel/parser@npm:^7.28.4, @babel/parser@npm:^7.28.5": version: 7.28.5 resolution: "@babel/parser@npm:7.28.5" dependencies: @@ -1727,6 +1795,28 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-jsx@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-syntax-jsx@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/bc5afe6a458d5f0492c02a54ad98c5756a0c13bd6d20609aae65acd560a9e141b0876da5f358dce34ea136f271c1016df58b461184d7ae9c4321e0f98588bc84 + languageName: node + linkType: hard + +"@babel/plugin-syntax-typescript@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-syntax-typescript@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/11589b4c89c66ef02d57bf56c6246267851ec0c361f58929327dc3e070b0dab644be625bbe7fb4c4df30c3634bfdfe31244e1f517be397d2def1487dbbe3c37d + languageName: node + linkType: hard + "@babel/plugin-transform-arrow-functions@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-arrow-functions@npm:7.27.1" @@ -1738,6 +1828,48 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-modules-commonjs@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-modules-commonjs@npm:7.27.1" + dependencies: + "@babel/helper-module-transforms": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/4def972dcd23375a266ea1189115a4ff61744b2c9366fc1de648b3fab2c650faf1a94092de93a33ff18858d2e6c4dddeeee5384cb42ba0129baeab01a5cdf1e2 + languageName: node + linkType: hard + +"@babel/plugin-transform-typescript@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/plugin-transform-typescript@npm:7.28.5" + dependencies: + "@babel/helper-annotate-as-pure": "npm:^7.27.3" + "@babel/helper-create-class-features-plugin": "npm:^7.28.5" + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" + "@babel/plugin-syntax-typescript": "npm:^7.27.1" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/09e574ba5462e56452b4ceecae65e53c8e697a2d3559ce5d210bed10ac28a18aa69377e7550c30520eb29b40c417ee61997d5d58112657f22983244b78915a7c + languageName: node + linkType: hard + +"@babel/preset-typescript@npm:^7.27.1": + version: 7.28.5 + resolution: "@babel/preset-typescript@npm:7.28.5" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-validator-option": "npm:^7.27.1" + "@babel/plugin-syntax-jsx": "npm:^7.27.1" + "@babel/plugin-transform-modules-commonjs": "npm:^7.27.1" + "@babel/plugin-transform-typescript": "npm:^7.28.5" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/b3d55548854c105085dd80f638147aa8295bc186d70492289242d6c857cb03a6c61ec15186440ea10ed4a71cdde7d495f5eb3feda46273f36b0ac926e8409629 + languageName: node + linkType: hard + "@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.24.8, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.26.7": version: 7.28.3 resolution: "@babel/runtime@npm:7.28.3" @@ -1763,6 +1895,21 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.23.7, @babel/traverse@npm:^7.27.7, @babel/traverse@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/traverse@npm:7.28.5" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@babel/generator": "npm:^7.28.5" + "@babel/helper-globals": "npm:^7.28.0" + "@babel/parser": "npm:^7.28.5" + "@babel/template": "npm:^7.27.2" + "@babel/types": "npm:^7.28.5" + debug: "npm:^4.3.1" + checksum: 10c0/f6c4a595993ae2b73f2d4cd9c062f2e232174d293edd4abe1d715bd6281da8d99e47c65857e8d0917d9384c65972f4acdebc6749a7c40a8fcc38b3c7fb3e706f + languageName: node + linkType: hard + "@babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.28.0, @babel/traverse@npm:^7.28.3, @babel/traverse@npm:^7.28.4": version: 7.28.4 resolution: "@babel/traverse@npm:7.28.4" @@ -1778,21 +1925,6 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/traverse@npm:7.28.5" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.5" - "@babel/helper-globals": "npm:^7.28.0" - "@babel/parser": "npm:^7.28.5" - "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.28.5" - debug: "npm:^4.3.1" - checksum: 10c0/f6c4a595993ae2b73f2d4cd9c062f2e232174d293edd4abe1d715bd6281da8d99e47c65857e8d0917d9384c65972f4acdebc6749a7c40a8fcc38b3c7fb3e706f - languageName: node - linkType: hard - "@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.28.4": version: 7.28.4 resolution: "@babel/types@npm:7.28.4" @@ -1803,7 +1935,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.21.3, @babel/types@npm:^7.28.5": +"@babel/types@npm:^7.21.3, @babel/types@npm:^7.23.6, @babel/types@npm:^7.27.3, @babel/types@npm:^7.27.7, @babel/types@npm:^7.28.5": version: 7.28.5 resolution: "@babel/types@npm:7.28.5" dependencies: @@ -11016,6 +11148,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/history@npm:1.139.0": + version: 1.139.0 + resolution: "@tanstack/history@npm:1.139.0" + checksum: 10c0/000fe41d3c3d7f0384e74fcfb1ecda25800906220925d1ab715e4fad7dab081e81c738238a3c09bfb1203ffd4ab0e1f24da1384ded322a154b9eba58405e3e90 + languageName: node + linkType: hard + "@tanstack/query-core@npm:5.85.5": version: 5.85.5 resolution: "@tanstack/query-core@npm:5.85.5" @@ -11034,6 +11173,36 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-router@npm:^1.139.3": + version: 1.139.3 + resolution: "@tanstack/react-router@npm:1.139.3" + dependencies: + "@tanstack/history": "npm:1.139.0" + "@tanstack/react-store": "npm:^0.8.0" + "@tanstack/router-core": "npm:1.139.3" + isbot: "npm:^5.1.22" + tiny-invariant: "npm:^1.3.3" + tiny-warning: "npm:^1.0.3" + peerDependencies: + react: ">=18.0.0 || >=19.0.0" + react-dom: ">=18.0.0 || >=19.0.0" + checksum: 10c0/1a145ee628ab43c5ce326dc1e5068508b9b71c3570723b2acee6a11d0487221f704489f9f73a93903cb1cba31985fdadf59922ca2148bf91246cbeb71d64aab9 + languageName: node + linkType: hard + +"@tanstack/react-store@npm:^0.8.0": + version: 0.8.0 + resolution: "@tanstack/react-store@npm:0.8.0" + dependencies: + "@tanstack/store": "npm:0.8.0" + use-sync-external-store: "npm:^1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/ecf7ad81d97810336d0a808a41442f235a444e98599c6e7e026efd3c4360548b84af9a23612f1d0da85e32a4d9e207632b2ee2cec6f635109a256209caa3bc59 + languageName: node + linkType: hard + "@tanstack/react-virtual@npm:3.11.3": version: 3.11.3 resolution: "@tanstack/react-virtual@npm:3.11.3" @@ -11058,6 +11227,99 @@ __metadata: languageName: node linkType: hard +"@tanstack/router-core@npm:1.139.3": + version: 1.139.3 + resolution: "@tanstack/router-core@npm:1.139.3" + dependencies: + "@tanstack/history": "npm:1.139.0" + "@tanstack/store": "npm:^0.8.0" + cookie-es: "npm:^2.0.0" + seroval: "npm:^1.4.0" + seroval-plugins: "npm:^1.4.0" + tiny-invariant: "npm:^1.3.3" + tiny-warning: "npm:^1.0.3" + checksum: 10c0/1e342c39cb8a9e437671aeaa60c82c11530cd3ae0523983585f7c8d332b5ca471aa73d15ffd242cd4b2c73ecebeee7db8dc1b19cbfc3c211253782df9e4ac9fc + languageName: node + linkType: hard + +"@tanstack/router-generator@npm:1.139.3": + version: 1.139.3 + resolution: "@tanstack/router-generator@npm:1.139.3" + dependencies: + "@tanstack/router-core": "npm:1.139.3" + "@tanstack/router-utils": "npm:1.139.0" + "@tanstack/virtual-file-routes": "npm:1.139.0" + prettier: "npm:^3.5.0" + recast: "npm:^0.23.11" + source-map: "npm:^0.7.4" + tsx: "npm:^4.19.2" + zod: "npm:^3.24.2" + checksum: 10c0/6ee595fc7b3da75016129bbf65d8ee1ec50e3c5d96dbabf56718c5ee3332c4f660aa738a7dd8f8207d6aad4e71f3c6c51572f17192db12ada668dc5e491d6757 + languageName: node + linkType: hard + +"@tanstack/router-plugin@npm:^1.139.3": + version: 1.139.3 + resolution: "@tanstack/router-plugin@npm:1.139.3" + dependencies: + "@babel/core": "npm:^7.27.7" + "@babel/plugin-syntax-jsx": "npm:^7.27.1" + "@babel/plugin-syntax-typescript": "npm:^7.27.1" + "@babel/template": "npm:^7.27.2" + "@babel/traverse": "npm:^7.27.7" + "@babel/types": "npm:^7.27.7" + "@tanstack/router-core": "npm:1.139.3" + "@tanstack/router-generator": "npm:1.139.3" + "@tanstack/router-utils": "npm:1.139.0" + "@tanstack/virtual-file-routes": "npm:1.139.0" + babel-dead-code-elimination: "npm:^1.0.10" + chokidar: "npm:^3.6.0" + unplugin: "npm:^2.1.2" + zod: "npm:^3.24.2" + peerDependencies: + "@rsbuild/core": ">=1.0.2" + "@tanstack/react-router": ^1.139.3 + vite: ">=5.0.0 || >=6.0.0 || >=7.0.0" + vite-plugin-solid: ^2.11.10 + webpack: ">=5.92.0" + peerDependenciesMeta: + "@rsbuild/core": + optional: true + "@tanstack/react-router": + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + checksum: 10c0/5795a880e65b27168fb703aff7254955787edde438fcfb381ddd201d74974806880164785b56f055a3c4186331a71ea0ccfa6f1474351cdeaf2608d10f13d2f9 + languageName: node + linkType: hard + +"@tanstack/router-utils@npm:1.139.0": + version: 1.139.0 + resolution: "@tanstack/router-utils@npm:1.139.0" + dependencies: + "@babel/core": "npm:^7.27.4" + "@babel/generator": "npm:^7.27.5" + "@babel/parser": "npm:^7.27.5" + "@babel/preset-typescript": "npm:^7.27.1" + ansis: "npm:^4.1.0" + diff: "npm:^8.0.2" + pathe: "npm:^2.0.3" + tinyglobby: "npm:^0.2.15" + checksum: 10c0/cb8477ab32f16881b8b92db952d0ab343cb77704ada953e422cee4543dc31aa5efae31295d415ff7deea8f9fadd19ad1a88e26d5f3e0d30a2f6b4099e82d19c9 + languageName: node + linkType: hard + +"@tanstack/store@npm:0.8.0, @tanstack/store@npm:^0.8.0": + version: 0.8.0 + resolution: "@tanstack/store@npm:0.8.0" + checksum: 10c0/71841a7a7653f744bdea457d2c41768b8d5e5aed1d5ff22bd068e28ced9bf658208c730963809c2223b26b753e19da987c0d98acb7c543abd97de14e0d58991f + languageName: node + linkType: hard + "@tanstack/virtual-core@npm:3.11.3": version: 3.11.3 resolution: "@tanstack/virtual-core@npm:3.11.3" @@ -11072,6 +11334,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/virtual-file-routes@npm:1.139.0": + version: 1.139.0 + resolution: "@tanstack/virtual-file-routes@npm:1.139.0" + checksum: 10c0/abb8173520e133f3e4d8d2eb356059ce0fbc6aa64d55e358cbc810cc323c8848fbd3adf512095bafaf69e7bc01ee588ca5a345d361c42452b8eb41239ce5ed73 + languageName: node + linkType: hard + "@testing-library/dom@npm:^10.4.0": version: 10.4.0 resolution: "@testing-library/dom@npm:10.4.0" @@ -12384,12 +12653,12 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:^19.0.4": - version: 19.1.2 - resolution: "@types/react-dom@npm:19.1.2" +"@types/react-dom@npm:^19.0.4, @types/react-dom@npm:^19.2.3": + version: 19.2.3 + resolution: "@types/react-dom@npm:19.2.3" peerDependencies: - "@types/react": ^19.0.0 - checksum: 10c0/100c341cacba9ec8ae1d47ee051072a3450e9573bf8eeb7262490e341cb246ea0f95a07a1f2077e61cf92648f812a0324c602fcd811bd87b7ce41db2811510cd + "@types/react": ^19.2.0 + checksum: 10c0/b486ebe0f4e2fb35e2e108df1d8fc0927ca5d6002d5771e8a739de11239fe62d0e207c50886185253c99eb9dedfeeb956ea7429e5ba17f6693c7acb4c02f8cd1 languageName: node linkType: hard @@ -12420,21 +12689,12 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:*": - version: 19.1.12 - resolution: "@types/react@npm:19.1.12" +"@types/react@npm:*, @types/react@npm:^19.0.12, @types/react@npm:^19.2.7": + version: 19.2.7 + resolution: "@types/react@npm:19.2.7" dependencies: - csstype: "npm:^3.0.2" - checksum: 10c0/e35912b43da0caaab5252444bab87a31ca22950cde2822b3b3dc32e39c2d42dad1a4cf7b5dde9783aa2d007f0b2cba6ab9563fc6d2dbcaaa833b35178118767c - languageName: node - linkType: hard - -"@types/react@npm:^19.0.12": - version: 19.1.2 - resolution: "@types/react@npm:19.1.2" - dependencies: - csstype: "npm:^3.0.2" - checksum: 10c0/76ffe71395c713d4adc3c759465012d3c956db00af35ab7c6d0d91bd07b274b7ce69caa0478c0760311587bd1e38c78ffc9688ebc629f2b266682a19d8750947 + csstype: "npm:^3.2.2" + checksum: 10c0/a7b75f1f9fcb34badd6f84098be5e35a0aeca614bc91f93d2698664c0b2ba5ad128422bd470ada598238cebe4f9e604a752aead7dc6f5a92261d0c7f9b27cfd1 languageName: node linkType: hard @@ -13738,7 +13998,9 @@ __metadata: "@swc/plugin-styled-components": "npm:^8.0.4" "@tailwindcss/vite": "npm:^4.1.13" "@tanstack/react-query": "npm:^5.85.5" + "@tanstack/react-router": "npm:^1.139.3" "@tanstack/react-virtual": "npm:^3.13.12" + "@tanstack/router-plugin": "npm:^1.139.3" "@testing-library/dom": "npm:^10.4.0" "@testing-library/jest-dom": "npm:^6.6.3" "@testing-library/react": "npm:^16.3.0" @@ -13776,8 +14038,8 @@ __metadata: "@types/mime-types": "npm:^3" "@types/node": "npm:^22.17.1" "@types/pako": "npm:^1.0.2" - "@types/react": "npm:^19.0.12" - "@types/react-dom": "npm:^19.0.4" + "@types/react": "npm:^19.2.7" + "@types/react-dom": "npm:^19.2.3" "@types/react-infinite-scroll-component": "npm:^5.0.0" "@types/react-transition-group": "npm:^4.4.12" "@types/react-window": "npm:^1" @@ -14641,6 +14903,18 @@ __metadata: languageName: node linkType: hard +"babel-dead-code-elimination@npm:^1.0.10": + version: 1.0.10 + resolution: "babel-dead-code-elimination@npm:1.0.10" + dependencies: + "@babel/core": "npm:^7.23.7" + "@babel/parser": "npm:^7.23.6" + "@babel/traverse": "npm:^7.23.7" + "@babel/types": "npm:^7.23.6" + checksum: 10c0/9503662f28cf8f86e7a27c5cc1fa63fc556100cd3bc6f1a4382aa8e9c6df54b15d2e0fcc073016f315d26a9e4004bc4d70829a395f056172b8f9240314da8973 + languageName: node + linkType: hard + "bail@npm:^1.0.0": version: 1.0.5 resolution: "bail@npm:1.0.5" @@ -15348,7 +15622,7 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:^3.5.2": +"chokidar@npm:^3.5.2, chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" dependencies: @@ -15947,6 +16221,13 @@ __metadata: languageName: node linkType: hard +"cookie-es@npm:^2.0.0": + version: 2.0.0 + resolution: "cookie-es@npm:2.0.0" + checksum: 10c0/3b2459030a5ad2bc715aeb27a32f274340670bfc5031ac29e1fba804212517411bb617880d3fe66ace2b64dfb28f3049e2d1ff40d4bec342154ccdd124deaeaa + languageName: node + linkType: hard + "cookie-signature@npm:^1.2.1": version: 1.2.2 resolution: "cookie-signature@npm:1.2.2" @@ -16198,6 +16479,13 @@ __metadata: languageName: node linkType: hard +"csstype@npm:^3.2.2": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce + languageName: node + linkType: hard + "csv-parse@npm:^5.6.0": version: 5.6.0 resolution: "csv-parse@npm:5.6.0" @@ -20602,6 +20890,13 @@ __metadata: languageName: node linkType: hard +"isbot@npm:^5.1.22": + version: 5.1.32 + resolution: "isbot@npm:5.1.32" + checksum: 10c0/e5aa9c5c92dae4879cf49956797c46ef77fa919230183cd6254628667ca5e22f15b24bc4d63b0e88cb96da3d7a51e33f847ef7114fa542e3e066f78178c8d97e + languageName: node + linkType: hard + "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" @@ -24725,6 +25020,15 @@ __metadata: languageName: node linkType: hard +"prettier@npm:^3.5.0": + version: 3.6.2 + resolution: "prettier@npm:3.6.2" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/488cb2f2b99ec13da1e50074912870217c11edaddedeadc649b1244c749d15ba94e846423d062e2c4c9ae683e2d65f754de28889ba06e697ac4f988d44f45812 + languageName: node + linkType: hard + "pretty-format@npm:^27.0.2": version: 27.5.1 resolution: "pretty-format@npm:27.5.1" @@ -26153,7 +26457,7 @@ __metadata: languageName: node linkType: hard -"recast@npm:^0.23.5": +"recast@npm:^0.23.11, recast@npm:^0.23.5": version: 0.23.11 resolution: "recast@npm:0.23.11" dependencies: @@ -27197,6 +27501,22 @@ __metadata: languageName: node linkType: hard +"seroval-plugins@npm:^1.4.0": + version: 1.4.0 + resolution: "seroval-plugins@npm:1.4.0" + peerDependencies: + seroval: ^1.0 + checksum: 10c0/d774b8a23bec45f1fefe314e38e26d2fffc0733ad50253a760a10f46cbb0be3a28ed9fcf60aadc0b3f1d2873f4118453a47e84145e858736944dbcd93b42437e + languageName: node + linkType: hard + +"seroval@npm:^1.4.0": + version: 1.4.0 + resolution: "seroval@npm:1.4.0" + checksum: 10c0/020262db5572c16ae5d22ecefa089112a0b1b9a9c78229dbc9c6059c172ed7f0b5005c7990b80714ff8638ac86274195c2084537e0c2a9178690acacff4b705f + languageName: node + linkType: hard + "serve-static@npm:^2.2.0": version: 2.2.0 resolution: "serve-static@npm:2.2.0" @@ -28500,6 +28820,13 @@ __metadata: languageName: node linkType: hard +"tiny-warning@npm:^1.0.3": + version: 1.0.3 + resolution: "tiny-warning@npm:1.0.3" + checksum: 10c0/ef8531f581b30342f29670cb41ca248001c6fd7975ce22122bd59b8d62b4fc84ad4207ee7faa95cde982fa3357cd8f4be650142abc22805538c3b1392d7084fa + languageName: node + linkType: hard + "tinybench@npm:^2.9.0": version: 2.9.0 resolution: "tinybench@npm:2.9.0" @@ -28929,7 +29256,7 @@ __metadata: languageName: node linkType: hard -"tsx@npm:^4.20.3, tsx@npm:^4.20.6": +"tsx@npm:^4.19.2, tsx@npm:^4.20.3, tsx@npm:^4.20.6": version: 4.20.6 resolution: "tsx@npm:4.20.6" dependencies: @@ -29348,6 +29675,18 @@ __metadata: languageName: node linkType: hard +"unplugin@npm:^2.1.2": + version: 2.3.11 + resolution: "unplugin@npm:2.3.11" + dependencies: + "@jridgewell/remapping": "npm:^2.3.5" + acorn: "npm:^8.15.0" + picomatch: "npm:^4.0.3" + webpack-virtual-modules: "npm:^0.6.2" + checksum: 10c0/273c1eab0eca4470c7317428689295c31dbe8ab0b306504de9f03cd20c156debb4131bef24b27ac615862958c5dd950a3951d26c0723ea774652ab3624149cff + languageName: node + linkType: hard + "unplugin@npm:^2.3.5": version: 2.3.10 resolution: "unplugin@npm:2.3.10" @@ -29489,6 +29828,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.6.0": + version: 1.6.0 + resolution: "use-sync-external-store@npm:1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/35e1179f872a53227bdf8a827f7911da4c37c0f4091c29b76b1e32473d1670ebe7bcd880b808b7549ba9a5605c233350f800ffab963ee4a4ee346ee983b6019b + languageName: node + linkType: hard + "utf8-byte-length@npm:^1.0.1": version: 1.0.5 resolution: "utf8-byte-length@npm:1.0.5" @@ -30482,6 +30830,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^3.24.2": + version: 3.25.76 + resolution: "zod@npm:3.25.76" + checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c + languageName: node + linkType: hard + "zod@npm:^3.25.76 || ^4": version: 4.1.12 resolution: "zod@npm:4.1.12"