diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index 1fb3a3ce38..073240f052 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -37,6 +37,10 @@ description: 'Code review checklist for LobeHub. Use when reviewing PRs, diffs, - Keys added to `src/locales/default/{namespace}.ts` with `{feature}.{context}.{action|status}` naming - For PRs: `locales/` translations for all languages updated (`pnpm i18n`) +### SPA / routing + +- **`desktopRouter` pair:** If the diff touches `src/spa/router/desktopRouter.config.tsx`, does it also update `src/spa/router/desktopRouter.config.desktop.tsx` with the same route paths and nesting? Single-file edits often cause drift and blank screens. + ### Reuse - Newly written code duplicates existing utilities in `packages/utils` or shared modules? diff --git a/.agents/skills/react/SKILL.md b/.agents/skills/react/SKILL.md index ecb9490190..4aadcdd667 100644 --- a/.agents/skills/react/SKILL.md +++ b/.agents/skills/react/SKILL.md @@ -32,15 +32,28 @@ Hybrid routing: Next.js App Router (static pages) + React Router DOM (main SPA). | Route Type | Use Case | Implementation | | ------------------ | --------------------------------- | ---------------------------- | | Next.js App Router | Auth pages (login, signup, oauth) | `src/app/[variants]/(auth)/` | -| React Router DOM | Main SPA (chat, settings) | `desktopRouter.config.tsx` | +| React Router DOM | Main SPA (chat, settings) | `desktopRouter.config.tsx` + `desktopRouter.config.desktop.tsx` (must match) | ### Key Files - Entry: `src/spa/entry.web.tsx` (web), `src/spa/entry.mobile.tsx`, `src/spa/entry.desktop.tsx` -- Desktop router: `src/spa/router/desktopRouter.config.tsx` +- Desktop router (pair — **always edit both** when changing routes): `src/spa/router/desktopRouter.config.tsx` (dynamic imports) and `src/spa/router/desktopRouter.config.desktop.tsx` (sync imports). Drift can cause unregistered routes / blank screen. - Mobile router: `src/spa/router/mobileRouter.config.tsx` - Router utilities: `src/utils/router.tsx` +### `.desktop.{ts,tsx}` File Sync Rule + +**CRITICAL**: Some files have a `.desktop.ts(x)` variant that Electron uses instead of the base file. When editing a base file, **always check** if a `.desktop` counterpart exists and update it in sync. Drift causes blank pages or missing features in Electron. + +Known pairs that must stay in sync: + +| Base file (web, dynamic imports) | Desktop file (Electron, sync imports) | +| --- | --- | +| `src/spa/router/desktopRouter.config.tsx` | `src/spa/router/desktopRouter.config.desktop.tsx` | +| `src/routes/(main)/settings/features/componentMap.ts` | `src/routes/(main)/settings/features/componentMap.desktop.ts` | + +**How to check**: After editing any `.ts` / `.tsx` file, run `Glob` for `.desktop.{ts,tsx}` in the same directory. If a match exists, update it with the equivalent sync-import change. + ### Router Utilities ```tsx diff --git a/.agents/skills/spa-routes/SKILL.md b/.agents/skills/spa-routes/SKILL.md index 0cd55f3710..dbc6a653e3 100644 --- a/.agents/skills/spa-routes/SKILL.md +++ b/.agents/skills/spa-routes/SKILL.md @@ -1,6 +1,6 @@ --- name: spa-routes -description: SPA route and feature structure. Use when adding or modifying SPA routes in src/routes, defining new route segments, or moving route logic into src/features. Covers how to keep routes thin and how to divide files between routes and features. +description: MUST use when editing src/routes/ segments, src/spa/router/desktopRouter.config.tsx or desktopRouter.config.desktop.tsx (always change both together), mobileRouter.config.tsx, or when moving UI/logic between routes and src/features/. --- # SPA Routes and Features Guide @@ -13,6 +13,8 @@ SPA structure: This project uses a **roots vs features** split: `src/routes/` only holds page segments; business logic and UI live in `src/features/` by domain. +**Agent constraint — desktop router parity:** Edits to the desktop route tree must update **both** `src/spa/router/desktopRouter.config.tsx` and `src/spa/router/desktopRouter.config.desktop.tsx` in the same change (same paths, nesting, index routes, and segment registration). Updating only one causes drift; the missing tree can fail to register routes and surface as a **blank screen** or broken navigation on the affected build. + ## When to Use This Skill - Adding a new SPA route or route segment @@ -73,8 +75,21 @@ Each feature should: - Layout: `export { default } from '@/features/MyFeature/MyLayout'` or compose a few feature components + ``. - Page: import from `@/features/MyFeature` (or a specific subpath) and render; no business logic in the route file. -5. **Register the route** - - Add the segment to `src/spa/router/desktopRouter.config.tsx` (or the right router config) with `dynamicElement` / `dynamicLayout` pointing at the new route paths (e.g. `@/routes/(main)/my-feature`). +5. **Register the route (desktop — two files, always)** + - **`desktopRouter.config.tsx`:** Add the segment with `dynamicElement` / `dynamicLayout` pointing at route modules (e.g. `@/routes/(main)/my-feature`). + - **`desktopRouter.config.desktop.tsx`:** Mirror the **same** `RouteObject` shape: identical `path` / `index` / parent-child structure. Use the static imports and elements already used in that file (see neighboring routes). Do **not** register in only one of these files. + - **Mobile-only flows:** use `mobileRouter.config.tsx` instead (no need to duplicate into the desktop pair unless the route truly exists on both). + +--- + +## 3a. Desktop router pair (`desktopRouter.config` × 2) + +| File | Role | +|------|------| +| `desktopRouter.config.tsx` | Dynamic imports via `dynamicElement` / `dynamicLayout` — code-splitting; used by `entry.web.tsx` and `entry.desktop.tsx`. | +| `desktopRouter.config.desktop.tsx` | Same route tree with **synchronous** imports — kept for Electron / local parity and predictable bundling. | + +Anything that changes the tree (new segment, renamed `path`, moved layout, new child route) must be reflected in **both** files in one PR or commit. Remove routes from both when deleting. --- diff --git a/src/routes/(main)/community/(detail)/skill/features/Details/Installation/index.tsx b/src/routes/(main)/community/(detail)/skill/features/Details/Installation/index.tsx index 56a2a71f72..36b1ceaf0a 100644 --- a/src/routes/(main)/community/(detail)/skill/features/Details/Installation/index.tsx +++ b/src/routes/(main)/community/(detail)/skill/features/Details/Installation/index.tsx @@ -10,8 +10,8 @@ const Installation = memo<{ mobile?: boolean }>(({ mobile }) => { return ( diff --git a/src/routes/(main)/community/(detail)/skill/features/Details/Versions/index.tsx b/src/routes/(main)/community/(detail)/skill/features/Details/Versions/index.tsx index c42a48e411..c1bef9ddc3 100644 --- a/src/routes/(main)/community/(detail)/skill/features/Details/Versions/index.tsx +++ b/src/routes/(main)/community/(detail)/skill/features/Details/Versions/index.tsx @@ -22,6 +22,9 @@ const Versions = memo(() => { {t('skills.details.versions.title')} { url: pathname, })} > - + {record.version} {record.isLatest && ( {t('skills.details.versions.table.isLatest')} @@ -52,9 +55,6 @@ const Versions = memo(() => { title: t('skills.details.versions.table.publishAt'), }, ]} - dataSource={versions} - rowKey={'version'} - size={'middle'} /> diff --git a/src/routes/(main)/community/(detail)/skill/features/Header.tsx b/src/routes/(main)/community/(detail)/skill/features/Header.tsx index ec32e929f0..8cfec01aeb 100644 --- a/src/routes/(main)/community/(detail)/skill/features/Header.tsx +++ b/src/routes/(main)/community/(detail)/skill/features/Header.tsx @@ -80,8 +80,8 @@ const Header = memo<{ mobile?: boolean }>(({ mobile }) => { const resourcesCount = (Object.values(resources || {})?.length || 0) + 1; const scores = ( - - + + {resourcesCount} @@ -103,7 +103,7 @@ const Header = memo<{ mobile?: boolean }>(({ mobile }) => { return ( - + (({ mobile }) => { }} > (({ mobile }) => { }} > @@ -142,22 +142,22 @@ const Header = memo<{ mobile?: boolean }>(({ mobile }) => { {!mobile && scores} - + {homepage && ( )} - + {Boolean(ratingAverage) ? ( - + {ratingAverage?.toFixed(1)} @@ -182,37 +182,37 @@ const Header = memo<{ mobile?: boolean }>(({ mobile }) => { {mobile && scores} {!mobile && cateButton} - + {Boolean(license?.name) && ( - + {license?.name} )} {Boolean(installCount) && ( - + {formatCompactNumber(installCount)} )} {Boolean(github?.stars) && ( - + {formatCompactNumber(github?.stars)} )} {Boolean(comments?.totalCount) && ( - + {formatCompactNumber(comments?.totalCount)} diff --git a/src/routes/(main)/community/(detail)/skill/features/Sidebar/InstallationConfig.tsx b/src/routes/(main)/community/(detail)/skill/features/Sidebar/InstallationConfig.tsx index ebdfb5245f..e885af04d2 100644 --- a/src/routes/(main)/community/(detail)/skill/features/Sidebar/InstallationConfig.tsx +++ b/src/routes/(main)/community/(detail)/skill/features/Sidebar/InstallationConfig.tsx @@ -27,7 +27,7 @@ const InstallationConfig = memo(() => { {t('skills.details.sidebar.installationConfig')} - + ); }); diff --git a/src/routes/(main)/settings/features/componentMap.desktop.ts b/src/routes/(main)/settings/features/componentMap.desktop.ts index 57dee35960..9cdeb1c0a2 100644 --- a/src/routes/(main)/settings/features/componentMap.desktop.ts +++ b/src/routes/(main)/settings/features/componentMap.desktop.ts @@ -9,6 +9,7 @@ import About from '../about'; import Advanced from '../advanced'; import APIKey from '../apikey'; import Appearance from '../appearance'; +import Creds from '../creds'; import Hotkey from '../hotkey'; import Memory from '../memory'; import Profile from '../profile'; @@ -17,6 +18,7 @@ import Proxy from '../proxy'; import Security from '../security'; import ServiceModel from '../service-model'; import Skill from '../skill'; +import Stats from '../stats'; import Storage from '../storage'; import SystemTools from '../system-tools'; @@ -33,8 +35,10 @@ export const componentMap = { [SettingsTabs.Storage]: Storage, // Profile related tabs [SettingsTabs.Profile]: Profile, + [SettingsTabs.Stats]: Stats, [SettingsTabs.Usage]: Usage, [SettingsTabs.APIKey]: APIKey, + [SettingsTabs.Creds]: Creds, [SettingsTabs.Security]: Security, [SettingsTabs.Skill]: Skill, diff --git a/src/routes/(main)/settings/features/componentMap.sync.test.ts b/src/routes/(main)/settings/features/componentMap.sync.test.ts new file mode 100644 index 0000000000..43cc66618b --- /dev/null +++ b/src/routes/(main)/settings/features/componentMap.sync.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { componentMap as webMap } from './componentMap'; +import { componentMap as desktopMap } from './componentMap.desktop'; + +describe('componentMap desktop sync', () => { + it('desktop keys must match web keys', () => { + const webKeys = Object.keys(webMap).sort(); + const desktopKeys = Object.keys(desktopMap).sort(); + + const missingInDesktop = webKeys.filter((k) => !desktopKeys.includes(k)); + const extraInDesktop = desktopKeys.filter((k) => !webKeys.includes(k)); + + expect( + missingInDesktop, + `Missing in componentMap.desktop: ${missingInDesktop.join(', ')}`, + ).toEqual([]); + expect(extraInDesktop, `Extra in componentMap.desktop: ${extraInDesktop.join(', ')}`).toEqual( + [], + ); + }); +}); diff --git a/src/routes/(main)/settings/profile/features/UsernameRow.tsx b/src/routes/(main)/settings/profile/features/UsernameRow.tsx index 3ca5279742..d197f0f82a 100644 --- a/src/routes/(main)/settings/profile/features/UsernameRow.tsx +++ b/src/routes/(main)/settings/profile/features/UsernameRow.tsx @@ -104,12 +104,12 @@ const UsernameRow = ({ mobile }: UsernameRowProps) => { )} {dirty && !saving && ( @@ -125,13 +125,13 @@ const UsernameRow = ({ mobile }: UsernameRowProps) => { variant="filled" onBlur={handleSave} onChange={handleChange} + onPressEnter={handleSave} onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault(); handleCancel(); } }} - onPressEnter={handleSave} /> diff --git a/src/spa/router/desktopRouter.config.desktop.tsx b/src/spa/router/desktopRouter.config.desktop.tsx index c0bf10c255..38fd358a78 100644 --- a/src/spa/router/desktopRouter.config.desktop.tsx +++ b/src/spa/router/desktopRouter.config.desktop.tsx @@ -462,3 +462,10 @@ desktopRoutes.push({ errorElement: , path: '/desktop-onboarding', }); + +// Web onboarding aliases redirect to the desktop-specific onboarding flow. +desktopRoutes.push({ + element: redirectElement('/desktop-onboarding'), + errorElement: , + path: '/onboarding', +}); diff --git a/src/spa/router/desktopRouter.sync.test.tsx b/src/spa/router/desktopRouter.sync.test.tsx new file mode 100644 index 0000000000..82698a4ef3 --- /dev/null +++ b/src/spa/router/desktopRouter.sync.test.tsx @@ -0,0 +1,51 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +/** + * Known path pairs that intentionally differ between web and desktop (Electron). + * Map: desktop path → web path + */ +const KNOWN_DIVERGENCES: Record = { + '/desktop-onboarding': '/onboarding', +}; + +function extractIndexCount(source: string) { + return [...source.matchAll(/index:\s*true/g)].length; +} + +function extractPaths(source: string) { + return [...source.matchAll(/path:\s*'([^']+)'/g)].map((match) => match[1]); +} + +function normalizePaths(paths: string[]) { + return [...new Set(paths.map((path) => KNOWN_DIVERGENCES[path] ?? path))].sort(); +} + +describe('desktopRouter config sync', () => { + it('desktop (sync) route paths must match web (async) route paths', async () => { + const asyncSource = await readFile( + join(process.cwd(), 'src/spa/router/desktopRouter.config.tsx'), + 'utf8', + ); + const syncSource = await readFile( + join(process.cwd(), 'src/spa/router/desktopRouter.config.desktop.tsx'), + 'utf8', + ); + + const asyncPaths = normalizePaths(extractPaths(asyncSource)); + const syncPaths = normalizePaths(extractPaths(syncSource)); + + const missingInSync = asyncPaths.filter((p) => !syncPaths.includes(p)); + const extraInSync = syncPaths.filter((p) => !asyncPaths.includes(p)); + const asyncIndexCount = extractIndexCount(asyncSource); + const syncIndexCount = extractIndexCount(syncSource); + + expect(missingInSync, `Missing in desktop config: ${missingInSync.join(', ')}`).toEqual([]); + expect(extraInSync, `Extra in desktop config: ${extraInSync.join(', ')}`).toEqual([]); + expect(syncIndexCount, 'Desktop config index route count must match async config').toBe( + asyncIndexCount, + ); + }); +}); diff --git a/tests/mocks/emojiMartData.ts b/tests/mocks/emojiMartData.ts new file mode 100644 index 0000000000..460a46aff7 --- /dev/null +++ b/tests/mocks/emojiMartData.ts @@ -0,0 +1,3 @@ +const emojiMartData = {}; + +export default emojiMartData; diff --git a/tests/mocks/emojiMartReact.tsx b/tests/mocks/emojiMartReact.tsx new file mode 100644 index 0000000000..debe2ad980 --- /dev/null +++ b/tests/mocks/emojiMartReact.tsx @@ -0,0 +1,3 @@ +const EmojiMartPicker = () => null; + +export default EmojiMartPicker; diff --git a/vitest.config.mts b/vitest.config.mts index 7303cc35a2..18a55fad17 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,12 +1,32 @@ import { dirname, join, resolve } from 'node:path'; +import tsconfigPaths from 'vite-tsconfig-paths'; import { coverageConfigDefaults, defineConfig } from 'vitest/config'; +const alias = { + '@emoji-mart/data': resolve(__dirname, './tests/mocks/emojiMartData.ts'), + '@emoji-mart/react': resolve(__dirname, './tests/mocks/emojiMartReact.tsx'), + '@/database/_deprecated': resolve(__dirname, './src/database/_deprecated'), + '@/utils/client/switchLang': resolve(__dirname, './src/utils/client/switchLang'), + '@/const/locale': resolve(__dirname, './src/const/locale'), + // TODO: after refactor the errorResponse, we can remove it + '@/utils/errorResponse': resolve(__dirname, './src/utils/errorResponse'), + '@/utils/unzipFile': resolve(__dirname, './src/utils/unzipFile'), + '@/utils/server': resolve(__dirname, './src/utils/server'), + '@/utils/identifier': resolve(__dirname, './src/utils/identifier'), + '@/utils/electron': resolve(__dirname, './src/utils/electron'), + '@/utils/markdownToTxt': resolve(__dirname, './src/utils/markdownToTxt'), + '@/utils/sanitizeFileName': resolve(__dirname, './src/utils/sanitizeFileName'), + '~test-utils': resolve(__dirname, './tests/utils.tsx'), + 'lru_map': resolve(__dirname, './tests/mocks/lru_map'), +}; + export default defineConfig({ optimizeDeps: { exclude: ['crypto', 'util', 'tty'], include: ['@lobehub/tts'], }, plugins: [ + tsconfigPaths({ projects: ['.'] }), /** * @lobehub/fluent-emoji@4.0.0 ships `es/FluentEmoji/style.js` but its `es/FluentEmoji/index.js` * imports `./style/index.js` which doesn't exist. @@ -38,28 +58,11 @@ export default defineConfig({ }, }, ], + resolve: { + alias, + }, test: { - alias: { - '@/database/_deprecated': resolve(__dirname, './src/database/_deprecated'), - '@/database': resolve(__dirname, './packages/database/src'), - '@/utils/client/switchLang': resolve(__dirname, './src/utils/client/switchLang'), - '@/const/locale': resolve(__dirname, './src/const/locale'), - // TODO: after refactor the errorResponse, we can remove it - '@/utils/errorResponse': resolve(__dirname, './src/utils/errorResponse'), - '@/utils/unzipFile': resolve(__dirname, './src/utils/unzipFile'), - '@/utils/server': resolve(__dirname, './src/utils/server'), - '@/utils/identifier': resolve(__dirname, './src/utils/identifier'), - '@/utils/electron': resolve(__dirname, './src/utils/electron'), - '@/utils/markdownToTxt': resolve(__dirname, './src/utils/markdownToTxt'), - '@/utils/sanitizeFileName': resolve(__dirname, './src/utils/sanitizeFileName'), - '@/utils': resolve(__dirname, './packages/utils/src'), - '@/types': resolve(__dirname, './packages/types/src'), - '@/const': resolve(__dirname, './packages/const/src'), - '@': resolve(__dirname, './src'), - '~test-utils': resolve(__dirname, './tests/utils.tsx'), - 'lru_map': resolve(__dirname, './tests/mocks/lru_map'), - - }, + alias, coverage: { all: false, exclude: [ @@ -99,6 +102,7 @@ export default defineConfig({ deps: { inline: [ 'vitest-canvas-mock', + /@emoji-mart/, '@lobehub/ui', '@lobehub/fluent-emoji', '@pierre/diffs',