실제 제품을 적절한 조작 환경에서 실행해 변경 사항을 검증하고, 사용자가 확인할 수 있는 증거를 모아 인수 보고서로 게시합니다. 기존 검증 계획을 확인하거나 점검 항목을 만들고, `lh` CLI로 결과를 게시할 수 있습니다.
- 변경된 제품 동작을 실제로 확인하기
- 스크린샷 등 검증 증거 수집하기
- 검증 계획과 보고서 작성하기
이 가이드는 SPA 페이지를 추가하거나 라우터를 정리할 때 페이지 진입 파일과 도메인별 기능 코드를 나누고, 웹·데스크톱·모바일 경로와 로딩 스켈레톤을 맞추는 방법을 안내합니다.
Use for SPA routes and page segments: Web/desktop/mobile/popup routers, redirects and src/routes-to-features boundaries.
실행 점수 미검증
설치 방법 보기이 가이드는 SPA 라우트를 추가하고 정리하면서 페이지 진입점과 비즈니스 로직 및 UI를 분리하는 방법을 설명합니다. src/routes/에는 얇은 레이아웃과 페이지 진입 파일만 두고, 도메인별 화면 구성 요소와 상태 관리는 src/features/에 모으도록 안내합니다. 공통 데스크톱 라우트를 공유 정의에 등록하는 방법, Web과 Electron의 차이를 다루는 방법, 모바일 대응 라우트를 추가하거나 지원하지 않는 경로를 표시하는 방법도 확인할 수 있습니다. 지연 로딩 중 표시할 스켈레톤의 선택과 등록 위치도 다룹니다.
새 페이지나 동적 라우트를 추가하거나 기존 페이지 구조를 단계적으로 정리할 때 참고할 수 있습니다. 특정 UI와 로직을 라우트와 기능 코드 중 어디에 둘지 판단하거나, 여러 페이지에서 도메인 기능을 함께 사용하려는 경우에도 유용합니다. Web, Electron, 모바일 사이의 라우트 차이를 줄이고 싶을 때도 활용할 수 있습니다. 새 구현의 위치를 정하기 전에 현재 프로젝트 구성을 확인하는 방식을 따릅니다.
특별한 주의 사항은 없습니다.
이 소개문은 공개된 SKILL.md를 바탕으로 AI(OpenAI Codex)가 작성했습니다. 정확한 사양은 아래 원문을 확인하세요.
AI가 SKILL.md의 내용에서 주의가 필요한 작업과 도입 전제를 확인했습니다. 점수는 실제로 실행한 결과(실행 검증)만으로 매깁니다.
점수에는 넣지 않았습니다. 2026년 10월 26일 기준으로 AI(OpenAI Codex)가 판정했습니다. 참고용이므로 설치 전에 원문도 확인해 주세요.
설치하기 전에 에이전트에게 전달되는 지시 내용을 확인할 수 있습니다.
SPA structure:
src/spa/ – Entry points (entry.web.tsx, entry.mobile.tsx, entry.desktop.tsx) and router config (router/). Router lives here to avoid confusion with src/routes/.src/routes/ – Page segments only (roots).src/features/ – Business logic and UI by domain.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 — shared desktop router: Common Web/Electron paths, nesting, metadata, lazy loaders, and preload groups belong in src/spa/router/desktopRouter.shared.tsx. The two desktopRouter.config* files are thin platform adapters; change them only for genuine runtime differences. Do not duplicate a common route in both adapters.
src/routes/src/features/src/routes/ (roots)Each route directory should contain only:
| File / folder | Purpose |
|---|---|
_layout/index.tsx or layout.tsx | Layout for this segment: wrap with <Outlet />, optional shell (e.g. sidebar + main). Should be thin: prefer re-exporting or composing from @/features/*. |
index.tsx or page.tsx | Page entry for this segment. Only import from features and render; no business logic. |
[param]/index.tsx (e.g. [id], [cronId]) | Dynamic segment page. Same rule: thin, delegate to features. |
Rule: Route files should only import and compose. No new features/ folders or heavy components inside src/routes/.
src/features/Put domain-oriented UI and logic here:
Organize by domain (e.g. Pages, Home, Agent, PageEditor), not by route path. One route can use several features; one feature can be used by several routes.
Each feature should:
src/features/<FeatureName>/index.ts or index.tsx@/features/<FeatureName>/... for internal imports when neededChoose the route group
(main)/ – desktop main app(mobile)/ – mobile(desktop)/ – Electron-specificonboarding/, share/ – special flowsCreate only segment files under src/routes/
src/routes/(main)/my-feature/_layout/index.tsx and src/routes/(main)/my-feature/index.tsx (and optional [id]/index.tsx).Implement layout and page content in src/features/
src/features/MyFeature/).index.Keep route files thin
export { default } from '@/features/MyFeature/MyLayout' or compose a few feature components + <Outlet />.@/features/MyFeature (or a specific subpath) and render; no business logic in the route file.Register the route in the correct definition layer
desktopRouter.shared.tsx with dynamicElement / dynamicLayout. Put its preloadId there as part of the shared lazy-loader definition.desktopRouter.config.tsx adapter. Keep platform-only differences explicit and small.mobileRouter.parity.test.ts fails until the mobile router resolves the new URL to the same pattern, or the route is listed in MOBILE_UNSUPPORTED with a reason.mobileRouter.config.tsx.Register a route skeleton (REQUIRED for every lazy route)
RouteSegmentSkeleton (src/components/Skeleton/RouteSegment.tsx) while its chunk loads. It resolves via handle.meta.Skeleton only (deepest match wins, walking up through parent routes). Omitting Skeleton renders a blank pane — use NoRouteSkeleton when that is intentional.Skeleton: createSurfaceSkeleton('list' | 'form' | 'grid' | 'editor' | 'detail') from @/components/Skeleton/Surface.src/components/Skeleton/ and register it (see ResourceHome.tsx, Generation.tsx, Conversation/).routeMeta (feature routeMeta.ts or inline in desktopRouter.shared.tsx). A whole subtree sharing one shape can register once on the parent/layout route's handle — children with their own Skeleton still override.lazy() skeleton (preloaded or not) renders blank until its chunk lands. That puts the skeleton's whole import graph in the entry chunk, so it may only reach UI primitives, other skeletons, constants and import type. Keep stores, services, feature components and value imports from barrels (@/types/* re-exports zod schemas) out of it; move the minimal piece next to the skeleton instead (see ResourceHome/Skeleton and Explorer/viewMode.ts).fallback: option, in-page Suspense, or an early return <XSkeleton />) keeps the handover continuous only through delayed() / DelayedFallback or RouteLoading, which register the skeleton as visible (retainSkeleton). Rendering a skeleton any other way lets the next boundary wait out its 200ms delay on a blank pane.| File | Role |
|---|---|
desktopRouter.shared.tsx | Single source of truth for common paths, nesting, metadata, lazy imports, and prioritized route preload groups. |
desktopRouter.config.tsx | Thin Web adapter: mounts the common content tree at / and adds Web-only routes. |
desktopRouter.config.desktop.tsx | Thin Electron adapter: injects per-tab Home behavior, TabHost root stubs, and the Electron-only /desktop-onboarding local setup. |
Add or remove common routes only in desktopRouter.shared.tsx, or in a subtree module it imports (taskWorkspaceRoutes.tsx, projectWorkspaceRoutes.tsx, agentPageRoutes.tsx, memoryRoutes.tsx, resourceRoutes.tsx, workspaceSettingsRoutes.tsx for the padded /:slug/settings/* tabs) — the shared file is over the 1200-line limit, so large subtrees move out instead of growing it. Add any new subtree module to readRouterSources in desktopRouter.sync.test.tsx. Keep desktopRouter.sync.test.tsx passing so path behavior, lazy boundaries, preload ownership, and the intentional platform differences remain verified.
.desktop.{ts,tsx} variants inside src/routes/The thin router adapters are not duplicated trees. Other route modules may still colocate a <name>.desktop.{ts,tsx} next to a base <name>.{ts,tsx}; Vite's resolver swaps in the .desktop file for Electron builds. Those paired module implementations still carry a drift risk.
Known variants today:
| Base file (web) | Desktop file (Electron) | Purpose |
|---|---|---|
src/features/Settings/features/componentMap.ts | src/features/Settings/features/componentMap.desktop.ts | Settings tab → component map. Web uses dynamic import(); desktop uses sync imports. componentMap.sync.test.ts enforces identical keys. |
src/routes/(main)/agent/index.tsx | src/routes/(main)/agent/index.desktop.tsx | Page entry. Desktop variant overrides the web page wholesale (e.g. extra popup guards). |
src/routes/(main)/group/index.tsx | src/routes/(main)/group/index.desktop.tsx | Same pattern as agent. |
Rules:
.ts/.tsx under src/routes/, glob the same directory for a <filename>.desktop.{ts,tsx} sibling. If one exists, apply the equivalent change there in the same commit.componentMap.ts (with dynamic(...)) and componentMap.desktop.ts (with a sync import). componentMap.sync.test.ts will fail the build otherwise..desktop.tsx variant — only add a new variant when the two trees genuinely diverge (different store wiring, different popup guards, etc.).The mobile web build has its own router (mobileRouter.config.tsx, __MOBILE__), but it mounts the same page segments as desktop. Do not copy a desktop route into the mobile files: reuse the subtree module and adjust only what differs with overlayRoutes (routeOverlay.ts; withHandle, dropRoute, or a mobile element).
| Mobile file | Reuses |
|---|---|
mobileAgentRoutes.tsx | agentPageChildren under the agent scope layout; group profile / permission |
mobileWorkspaceRoutes.tsx | createTaskWorkspaceChildren, projectWorkspaceRoute, resourceRoute, memoryChildren, inside the mobile page layout |
mobileWorkspaceSettingsRoutes.tsx | workspaceSettingsContentRoute children plus the mobile settings menu and full-bleed tabs |
pageLayout ((mobile)/_page-layout).NavHeader draws back + the route's NavPanelPortal sidebar as a drawer, and falls back to the route titleKey when the page header has no title. A page that paints no NavHeader gets one from the route handle mobileShellHeader: true | 'untitled' (and mobileShellTitle for a record name). Do not add a second back button inside the page; opt out with showTogglePanelButton={false} when the page keeps its own.mobileUnsupportedRoutes and MOBILE_UNSUPPORTED in mobileRouter.parity.test.ts, so they redirect home instead of being read as a /:workspaceSlug.| Question | Put in src/routes/ | Put in src/features/ |
|---|---|---|
| Is it the route’s layout wrapper or page entry? | Yes – _layout/index.tsx, index.tsx, [id]/index.tsx | No |
| Does it contain business logic or non-trivial UI? | No | Yes – under the right domain |
| Is it a reusable layout piece (sidebar, header, body)? | No | Yes |
| Is it a hook, store usage, or domain logic? | No | Yes |
| Is it only re-exporting or composing feature components? | Yes | No |
Examples
src/routes/(main)/page/_layout/index.tsx → export { default } from '@/features/Pages/PageLayout'src/features/Pages/PageLayout/ → Sidebar, DataSync, Body, Header, styles, etc.src/routes/(main)/page/index.tsx → Import PageTitle, PageExplorerPlaceholder from @/features/Pages and @/features/PageExplorer; render with <PageTitle /> and placeholder.src/features/Pages/.Existing routes follow this structure unevenly: some are already thin segment files (e.g. src/routes/(main)/page/, src/routes/(main)/settings/), while others still hold logic or a features/ folder under src/routes/. Check the route's current state instead of assuming.
When touching an old route that still has logic or features/ inside src/routes/:
src/features/<Domain>/ and importing from routes.git mv when moving files so history is preserved.Route (thin):
src/routes/(main)/page/
├── _layout/index.tsx → re-export or compose from @/features/Pages/PageLayout
├── index.tsx → import from @/features/Pages, @/features/PageExplorer
└── [id]/index.tsx → import from @/features/Pages, @/features/PageExplorer
Feature (implementation):
src/features/Pages/
├── index.ts → export PageLayout, PageTitle
├── PageTitle.tsx
└── PageLayout/
├── index.tsx → Sidebar + Outlet + DataSync
├── DataSync.tsx
├── Sidebar.tsx
├── style.ts
├── Body/ → list, actions, drawer, etc.
└── Header/ → breadcrumb, add button, etc.
Router config continues to point at route paths (e.g. @/routes/(main)/page, @/routes/(main)/page/_layout); route files then delegate to features.
아직 리뷰가 없습니다. 사용해 본 소감을 남겨 주세요.
로그인 없이 쓸 수 있습니다. 사용 후기, 질문, 작동하지 않은 환경 등을 편하게 남겨 주세요.
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.
실제 제품을 적절한 조작 환경에서 실행해 변경 사항을 검증하고, 사용자가 확인할 수 있는 증거를 모아 인수 보고서로 게시합니다. 기존 검증 계획을 확인하거나 점검 항목을 만들고, `lh` CLI로 결과를 게시할 수 있습니다.
LobeHub에 AI 모델 제공자를 추가하는 과정을 안내합니다. 런타임 연동, 모델 정보와 설정, 브랜드 표시, 문서화 및 인수 확인을 다루며, 기존 제공자에 모델만 추가하는 경우와 새 연동이 필요한 경우를 구분합니다.
서버 환경 변수로 사용자 설정의 기본값을 지정하도록 구현하는 절차를 안내합니다. 변수 검증, 서버 설정 구성, 사용자 저장소 반영부터 예시 환경 파일과 셀프 호스팅 문서 갱신까지 다룹니다.
에이전트 실행 단계, 도구 호출, 사람의 승인, 컨텍스트 압축, 하위 에이전트 호출에 훅을 등록하는 방법을 안내합니다. 이벤트를 관찰하고 로컬 도구 결과를 모의하거나 설정에 따라 웹훅으로 도구 호출을 허용 또는 거부할 수 있습니다.
에이전트의 이벤트 기반 백그라운드 작업을 설계하고 구현하도록 돕습니다. 소스, 시그널, 액션, 정책의 역할을 나누고 비동기 처리, 중복 실행 방지, 완료 기록과 관측 기능을 다룹니다.
실제 데스크톱 채팅 앱에서 봇 채널의 인수 테스트를 진행합니다. Discord, Slack, Telegram, WeChat, Lark, QQ에서 메시지를 보내고 응답을 기다린 뒤 결과 화면을 캡처합니다. iMessage는 BlueBubbles 브리지를 사용합니다.