diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..2ebeeaa5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# Agent Navigation + +This file is a navigation layer for architecture and implementation guidance. + +Start here: +- Repo overview and commands: [README.md](README.md) +- Working instructions and project conventions: [CLAUDE.md](CLAUDE.md) +- Canonical feature architecture standard: [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md) + +For new features: +- Default home for medium and large features: `src/features//` +- Reference implementation: `src/features/recent-projects` +- Feature-local guidance for work inside `src/features`: [src/features/CLAUDE.md](src/features/CLAUDE.md) + +Do not treat this file as a second source of truth. +Keep architecture rules centralized in [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md). diff --git a/CLAUDE.md b/CLAUDE.md index 858c0dcf..2d7f0988 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,8 +57,21 @@ Use path aliases for imports: - `@preload/*` → `src/preload/*` ## Features Architecture -**All new features MUST be created in `src/renderer/features//`.** -See `src/renderer/features/CLAUDE.md` for the full guide on creating features with Clean Architecture, SOLID, and class-based patterns. +**All new medium and large features should follow the canonical slice standard in [`docs/FEATURE_ARCHITECTURE_STANDARD.md`](docs/FEATURE_ARCHITECTURE_STANDARD.md).** + +Default location: +- `src/features//` + +Reference implementation: +- `src/features/recent-projects` + +Feature-local guidance: +- `src/features/CLAUDE.md` + +Legacy note: +- `src/renderer/features/*` still exists for older renderer-only slices +- do not use `src/renderer/features/*` as the default for new cross-process features +- thin renderer-only slices may still stay local when they do not need `core/`, transport wiring, or multi-process boundaries ## Data Sources ~/.claude/projects/{encoded-path}/*.jsonl - Session files diff --git a/README.md b/README.md index 871f52b0..b8f55f57 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ No prerequisites - the app can detect supported runtimes/providers and guide set - [Comparison](#comparison) - [Quick start](#quick-start) - [FAQ](#faq) +- [Developer architecture docs](#developer-architecture-docs) - [Development](#development) - [Tech stack](#tech-stack) - [Build for distribution](#build-for-distribution) @@ -118,6 +119,15 @@ A local orchestration layer for AI agent teams across Claude and Codex. - **Task-specific logs and messages** — clearly see agent/runtime logs and messages in isolation for each individual task, making it easy to trace what happened for any assignment - **Live process section** — see which agents are running processes and open URLs directly in the browser - **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work + +## Developer architecture docs + +For feature architecture and implementation guidance: + +- Canonical standard - [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md) +- Repo working instructions - [CLAUDE.md](CLAUDE.md) +- Feature root guidance - [src/features/README.md](src/features/README.md) +- Reference implementation - `src/features/recent-projects` - **Flexible autonomy** — let agents run fully autonomous, or review and approve each action one by one (you'll get a notification) — configure the level of control that fits your security needs - **Solo mode** — one-member team: a single agent that creates its own tasks and shows live progress. Saves tokens; can expand to a full team anytime diff --git a/docs/FEATURE_ARCHITECTURE_STANDARD.md b/docs/FEATURE_ARCHITECTURE_STANDARD.md new file mode 100644 index 00000000..913970ce --- /dev/null +++ b/docs/FEATURE_ARCHITECTURE_STANDARD.md @@ -0,0 +1,297 @@ +# Feature Architecture Standard + +**Status**: team standard +**Reference implementation**: `src/features/recent-projects` + +This document defines the default architecture for medium and large features in this repository. + +## Goals + +- keep business rules isolated from Electron-specific runtime details +- make features easier to scale, test, and review +- keep renderer code closer to browser and Tauri portability +- enforce architecture with tooling, not only with code review comments + +## Canonical Template + +```text +src/features// + contracts/ + core/ + domain/ + application/ + main/ + composition/ + adapters/ + input/ + output/ + infrastructure/ + preload/ + renderer/ +``` + +Use this template by default when a feature: + +- spans more than one process boundary +- introduces its own use case or business policy +- needs its own transport bridge or integration surface +- is expected to grow with new providers, sources, or presentation flows + +## Layer Responsibilities + +### `contracts/` + +Cross-process public API for the feature. + +Allowed content: + +- DTOs +- API fragment types +- IPC or route constants + +Not allowed: + +- store access +- Electron APIs +- business orchestration + +### `core/domain/` + +Pure business rules and invariants. + +Examples: + +- merge policies +- provider-agnostic models +- selection rules +- dedupe logic + +Not allowed: + +- infrastructure access +- framework access +- side effects + +### `core/application/` + +Use cases and ports. + +Examples: + +- orchestration flow +- output ports +- cache ports +- source ports +- response models + +Not allowed: + +- Electron, Fastify, React, Zustand, child processes + +### `main/composition/` + +Feature composition root in the main process. + +Responsibilities: + +- instantiate infrastructure +- wire adapters +- wire use cases +- expose a small facade to app shell entrypoints + +### `main/adapters/input/` + +Driving adapters for the main process. + +Examples: + +- IPC handlers +- HTTP route registration + +Responsibilities: + +- translate transport input into use case calls +- keep transport concerns out of use cases + +### `main/adapters/output/` + +Driven adapters that implement application ports. + +Examples: + +- presenters +- source adapters + +Responsibilities: + +- translate between external data and core models +- stay thin around infrastructure helpers + +### `main/infrastructure/` + +Concrete technical implementation details. + +Examples: + +- file system adapters +- JSON-RPC transport clients +- binary discovery +- cache implementation +- git identity helpers + +Responsibilities: + +- know about runtime, process, OS, or protocol details + +### `preload/` + +Thin transport bridge between renderer and main. + +Responsibilities: + +- expose a feature API fragment +- depend on `contracts/` + +Not allowed: + +- main composition code +- renderer logic + +### `renderer/` + +Feature presentation and interaction. + +Recommended structure: + +```text +renderer/ + index.ts + adapters/ + hooks/ + ui/ + utils/ +``` + +Responsibilities: + +- `ui/` renders +- `hooks/` orchestrate interaction and transport usage +- `adapters/` transform DTOs into view models +- `utils/` contain small pure renderer helpers + +## Import Rules + +### Public entrypoints only + +Outside the feature, import only: + +- `@features//contracts` +- `@features//main` +- `@features//preload` +- `@features//renderer` + +Do not deep-import feature internals from app shell or from other features. + +### Core isolation + +`core/domain` must not import: + +- `@main/*` +- `@renderer/*` +- `@preload/*` +- adapters +- infrastructure +- Electron APIs +- Fastify +- child process modules + +`core/application` must not import: + +- `main/*` +- `renderer/*` +- Electron APIs +- Fastify +- child process modules + +### UI isolation + +`renderer/ui` must not import: + +- `@renderer/api` +- `@renderer/store` +- `@main/*` +- Electron APIs + +Push transport and store access into feature hooks or adapters. + +## Browser and Tauri Friendly Guidance + +The default transport direction should be: + +`renderer -> feature contracts -> app api abstraction -> preload/http adapter` + +This keeps renderer code closer to: + +- browser mode through HTTP adapters +- a future Tauri bridge +- alternative shells with minimal feature rewrites + +To keep that path clean: + +- never call `window.electronAPI` directly inside feature UI or hooks +- go through shared renderer API adapters +- keep Electron-specific concerns in `main/` and `preload/` +- keep business rules in `core/` + +## When To Use The Full Slice + +Use the full template when a feature has: + +- its own business rules +- its own merge or filtering policy +- transport wiring +- more than one adapter +- a roadmap beyond a one-off screen tweak + +## When A Thin Slice Is Enough + +A smaller feature may skip `core/` and `preload/` when it is: + +- purely presentational +- only reshaping already-owned data +- not adding a new use case +- not adding a new transport boundary + +## Definition Of Done For A Reference Feature + +A feature is reference-quality when: + +- structure matches the canonical template +- core is side-effect free +- app shell imports only public entrypoints +- renderer UI is dumb and presentational +- at least the main domain and application rules are tested +- architecture is enforced by lint rules +- feature has a concise standard or plan doc if it introduces a new pattern + +## Recommended Test Coverage + +For medium and large features, cover at least: + +- domain policy tests +- application use case tests +- critical renderer interaction utilities +- one adapter-level mapping test + +## Recent Projects As The Reference + +`src/features/recent-projects` is the first slice that follows this standard end-to-end. + +Use it as the example for: + +- contracts ownership +- core/application separation +- composition-root wiring +- renderer dumb UI + hook orchestration +- browser-friendly transport direction +- feature-level lint guard rails diff --git a/docs/research/codex-dashboard-recent-projects-plan.md b/docs/research/codex-dashboard-recent-projects-plan.md new file mode 100644 index 00000000..85f01393 --- /dev/null +++ b/docs/research/codex-dashboard-recent-projects-plan.md @@ -0,0 +1,1260 @@ +# Codex Dashboard Recent Projects Plan + +**Дата**: 2026-04-14 +**Статус**: Reference-quality architecture plan +**Goal**: сделать `Dashboard -> Recent Projects` эталонной feature по `SOLID`, `Clean Architecture`, `Ports/Adapters`, `DRY` +**Canonical standard**: этот документ фиксирует рекомендуемый shape для будущих feature в проекте + +## Executive Summary + +Выбранный вариант: + +`Full vertical slice in src/features with core separated from process adapters` +`🎯 9 🛡️ 10 🧠 8` +Примерно `700-1000` строк изменений + +Это решение фиксируется как **предпочтительный architectural template для будущих feature**, если feature: + +- затрагивает более одного process boundary +- содержит заметную бизнес-логику +- имеет отдельный use case, который хочется развивать независимо + +### Что это значит + +- создаём feature `src/features/recent-projects` +- внутри feature есть **свои слои**: + - `contracts` + - `core/domain` + - `core/application` + - `main/composition` + - `main/adapters` + - `main/infrastructure` + - `preload` + - `renderer` +- app shell только **собирает** feature, но не владеет её логикой + +### Главная корректировка относительно прошлого плана + +Прошлая версия уже была неплохой, но для "эталонной" фичи ей не хватало жёсткости в четырёх местах: + +1. use case был описан слишком близко к DTO, а не как application core +2. adapters и infrastructure были недостаточно разведены +3. не был явно сформулирован набор архитектурных запретов +4. inner circles были слишком близко привязаны к `main`, хотя это не main-specific business logic + +Этот документ исправляет именно это. + +--- + +## 1. Top 3 Architecture Options + +### 1. Full vertical slice + strict Clean Architecture inside the feature + +`🎯 9 🛡️ 10 🧠 8` +Примерно `700-1000` строк + +Идея: + +- feature lives in one place +- core/domain and core/application isolated from process details +- ports explicit +- adapters explicit +- process boundaries preserved inside the feature + +Почему это лучший вариант: + +- лучший long-term shape +- легко расширять новыми providers +- легче удерживать логику фичи в одном bounded context +- это реально можно показывать как reference implementation + +### 2. Vertical slice, но без жёсткого разделения `application / adapters / infrastructure` + +`🎯 7 🛡️ 7 🧠 6` +Примерно `500-750` строк + +Плюсы: + +- меньше файлов +- быстрее в реализации + +Минусы: + +- быстро поплывут ответственности +- adapters начнут смешиваться с use case +- через несколько итераций feature станет "папкой со всем подряд" + +### 3. Renderer feature + main service outside feature + +`🎯 8 🛡️ 8 🧠 6` +Примерно `450-700` строк + +Плюсы: + +- проще +- достаточно хорошо для одной задачи + +Минусы: + +- хуже ощущается как единая feature +- логика снова размазывается по repo + +### Final choice + +Берём **вариант 1**. + +--- + +## 2. Product Goal + +Когда пользователь открывает homepage, он должен увидеть **последние проекты, где недавно реально работал**, включая: + +- Claude activity +- native Codex activity +- merged карточки, если это один repo или folder + +### Acceptance criteria + +1. Claude-only пользователь не видит поведенческой регрессии. +2. Codex-only пользователь видит свои проекты на homepage. +3. Claude + Codex в одном repo не создают дубликатов. +4. Карточка показывает provider logos. +5. Если standalone `codex` отсутствует или `codex app-server` не стартует, homepage спокойно деградирует в Claude-only. +6. В `ssh` context локальные native Codex проекты не подмешиваются. +7. `DashboardView` остаётся экраном-компоновщиком, а не бизнес-слоем. + +--- + +## 3. Non-Goals For Phase 1 + +Не делаем: + +- native Codex sessions в sidebar +- native Codex session detail opening +- model badges +- file-based Codex session index +- persistent `codex app-server` +- live notifications from Codex +- global provider-agnostic session index +- injection Codex data into `repositoryGroups` + +--- + +## 4. Architecture Standards For This Feature + +## 4.1 Single Responsibility + +Каждый модуль меняется по одной причине: + +- `domain` - business rules recent projects +- `application` - orchestration use case +- `adapters` - translation in/out +- `infrastructure` - конкретные технологии и внешние системы +- `input adapters` - IPC/HTTP wiring +- `preload` - renderer bridge +- `renderer/ui` - rendering +- `renderer/hooks` - interaction orchestration + +## 4.2 Open / Closed + +Новый provider должен подключаться новым source adapter, а не переписыванием use case. + +## 4.3 Liskov + +Любой source adapter должен удовлетворять одному и тому же порту: + +- Claude source +- Codex source +- будущий Gemini source + +## 4.4 Interface Segregation + +Порты должны быть узкими: + +- `RecentProjectsSourcePort` +- `RecentProjectsCachePort` +- `ClockPort` +- `LoggerPort` +- `ListDashboardRecentProjectsOutputPort` + +Не должно быть толстых универсальных сервисов. + +## 4.5 Dependency Inversion + +Use case зависит только от портов. + +Use case **не знает** про: + +- `ipcMain` +- `Fastify` +- `ipcRenderer` +- `child_process` +- `electron` +- Zustand +- React + +## 4.6 DRY + +Нельзя дублировать: + +- merge rules +- provider presentation mapping +- navigation fallback flow +- task/team aggregation by associated paths +- Codex thread fetch policy + +## 4.7 Clean Architecture Rule + +Допустимое направление зависимостей: + +`main/adapters/input -> core/application -> core/domain` +`main/adapters/output -> core/application ports` +`main/infrastructure -> core/application ports` +`renderer/hooks -> renderer/adapters -> contracts` +`preload -> contracts` + +Недопустимо: + +- `core/domain -> core/application` +- `core/application -> main/infrastructure` +- `core/application -> main/adapters` +- `renderer/ui -> store/api` +- `shared/app-shell -> feature internals` + +--- + +## 5. Hard Architectural Corrections + +Это места, где нужно быть особенно аккуратным. + +### Correction 1 - Feature contracts are not the same thing as app shell contracts + +Ошибка, которую легко сделать: + +- положить DTO в feature +- а потом заставить весь `src/shared/types/api.ts` зависеть от feature internals как от обычного shared-layer + +Правильнее: + +- `feature contracts` живут внутри feature и описывают форму recent-projects +- `app shell` может **композировать** feature API fragment, но не должен владеть feature моделью + +Это тонкая, но важная разница. + +### Correction 2 - Use case must not return infrastructure-shaped data + +Use case работает с `core/application response model`, а не с raw app-server rows и не с renderer card model. + +### Correction 3 - UI must not own navigation policy + +`RecentProjectCard` не должен знать, как работает: + +- worktree match +- refresh repository groups +- add custom path +- synthetic fallback group + +Это responsibility interaction adapter/hook. + +### Correction 4 - `DashboardView` must become a composition root for the screen + +Он не должен: + +- ходить в API +- мержить данные +- решать navigation flow +- агрегировать task/team decorations + +--- + +## 6. Recommended Feature Structure + +```text +src/features/recent-projects/ + contracts/ + index.ts + dto.ts + channels.ts + api.ts + core/ + domain/ + RecentProjectCandidate.ts + RecentProjectAggregate.ts + ProviderId.ts + policies/ + mergeRecentProjectCandidates.ts + application/ + use-cases/ + ListDashboardRecentProjectsUseCase.ts + ports/ + ClockPort.ts + LoggerPort.ts + RecentProjectsCachePort.ts + ListDashboardRecentProjectsOutputPort.ts + RecentProjectsSourcePort.ts + models/ + ListDashboardRecentProjectsResponse.ts + main/ + index.ts + composition/ + createRecentProjectsFeature.ts + adapters/ + input/ + ipc/ + registerRecentProjectsIpc.ts + http/ + registerRecentProjectsHttp.ts + output/ + presenters/ + DashboardRecentProjectsPresenter.ts + sources/ + ClaudeRecentProjectsSourceAdapter.ts + CodexRecentProjectsSourceAdapter.ts + infrastructure/ + cache/ + InMemoryRecentProjectsCache.ts + identity/ + RecentProjectIdentityResolver.ts + codex/ + CodexBinaryResolver.ts + CodexAppServerClient.ts + JsonRpcStdioClient.ts + preload/ + index.ts + createRecentProjectsBridge.ts + renderer/ + index.ts + adapters/ + RecentProjectsSectionAdapter.ts + hooks/ + useRecentProjectsSection.ts + useOpenRecentProject.ts + ui/ + RecentProjectsSection.tsx + RecentProjectCard.tsx + utils/ + navigation.ts + projectDecorations.ts +``` + +### Why this structure is the cleanest + +- `contracts` - cross-process public contract +- `core/domain` - invariant business rules +- `core/application` - use case orchestration through ports +- `main/adapters/input` - driving adapters +- `main/adapters/output` - driven adapters +- `main/infrastructure` - concrete implementations +- `main/composition` - feature composition root +- `preload` - isolated renderer bridge +- `renderer` - feature presentation + +Это уже не просто "feature folder", а полноценный vertical slice. + +### Canonical template for future features + +Для будущих feature в проекте фиксируем такой шаблон как базовый: + +```text +src/features// + contracts/ + core/ + domain/ + application/ + main/ + composition/ + adapters/ + input/ + output/ + infrastructure/ + preload/ + renderer/ +``` + +Использовать этот шаблон по умолчанию **для feature среднего и большого размера**. + +Если feature маленькая и: + +- не имеет отдельного use case +- не вводит новый bridge/API +- не содержит сложной business logic + +то допускается упрощённый вариант без полного `core/main/preload` набора. + +### What is mandatory + +- `contracts/` +- `core/domain/` +- `core/application/` +- `main/composition/` +- хотя бы один из: + - `main/adapters/input/` + - `main/adapters/output/` +- `renderer/`, если у feature есть UI + +### What is optional + +- `preload/`, если feature не требует нового bridge/API +- `main/infrastructure/`, если feature не ходит во внешние runtime dependencies +- `renderer/adapters/`, если feature очень маленькая +- `renderer/hooks/`, если feature purely presentational + +### When `core/` is required + +`core/` обязателен, если feature: + +- содержит независимые business rules +- имеет merge/filter/decision policy +- имеет хотя бы один use case, который хочется тестировать изолированно +- потенциально будет расширяться новыми providers / sources / policies + +### When `core/` may be skipped + +`core/` можно не создавать, если feature: + +- purely presentational +- только прокидывает данные без трансформации правил +- не имеет собственного use case +- по сути является thin UI wrapper around existing app logic + +### What should not be used as the default standard + +- `main/domain/application/presentation` +- `preload/domain/application/presentation` +- `ui/domain/application/presentation` + +Причина: + +- это смешивает process axis и architecture axis +- это создаёт ложную симметрию +- это размывает единственный business core + +--- + +## 7. Dependency Matrix + +### Allowed imports + +#### `contracts` + +Can import: + +- nothing app-specific +- TS built-ins only + +#### `core/domain` + +Can import: + +- `contracts` if types are pure and stable +- other `core/domain` files + +Cannot import: + +- `core/application` +- `adapters` +- `infrastructure` +- `electron` +- `fastify` +- `@main/*` + +#### `core/application` + +Can import: + +- `core/domain` +- `core/application/ports` +- `contracts` + +Cannot import: + +- `ipcMain` +- `Fastify` +- `child_process` +- `@renderer/*` + +#### `main/adapters/input` + +Can import: + +- `core/application` +- `core/domain` +- `contracts` + +Cannot import: + +- renderer code + +#### `main/adapters/output` + +Can import: + +- `core/application` +- `core/domain` +- `contracts` +- `main/infrastructure` + +Cannot import: + +- renderer code + +#### `main/infrastructure` + +Can import: + +- `core/application/ports` +- `core/domain` +- `contracts` +- technology-specific libs + +#### `main/composition` + +Can import: + +- `core/application` +- `main/adapters/input` +- `main/adapters/output` +- `main/infrastructure` +- `contracts` +- `@main/*` + +#### `preload` + +Can import: + +- `contracts` +- feature preload helpers +- Electron preload APIs + +Cannot import: + +- `main/*` +- renderer code + +#### `renderer/ui` + +Can import: + +- renderer hooks +- renderer adapters +- simple feature-local UI utilities + +Cannot import: + +- store directly +- API directly +- main code + +#### `renderer/hooks` + +Can import: + +- `api` +- `useStore` +- feature contracts +- feature adapters/utils + +--- + +## 8. Ports And Adapters Design + +## 8.1 Domain Model + +### `RecentProjectCandidate` + +Это provider-agnostic внутренний объект, из которого потом строится response. + +```ts +export interface RecentProjectCandidate { + identity: string; + displayName: string; + primaryPath: string; + associatedPaths: string[]; + lastActivityAt: number; + providerIds: ProviderId[]; + sourceKind: 'claude' | 'codex'; + openTarget: + | { type: 'existing-worktree'; repositoryId: string; worktreeId: string } + | { type: 'synthetic-path'; path: string }; + branchName?: string; +} +``` + +### `RecentProjectAggregate` + +Это уже merged domain shape до presenter mapping. + +```ts +export interface RecentProjectAggregate { + identity: string; + displayName: string; + primaryPath: string; + associatedPaths: string[]; + lastActivityAt: number; + providerIds: ProviderId[]; + source: 'claude' | 'codex' | 'mixed'; + openTarget: + | { type: 'existing-worktree'; repositoryId: string; worktreeId: string } + | { type: 'synthetic-path'; path: string }; + branchName?: string; +} +``` + +### Merge policy + +`core/domain/policies/mergeRecentProjectCandidates.ts` + +Это чистая функция без side effects. + +Именно тут живёт истинная business rule: + +- dedupe by identity +- prefer `existing-worktree` +- merge provider ids +- max by recency +- unset conflicting branch + +--- + +## 8.2 Application Ports + +### `RecentProjectsSourcePort` + +```ts +export interface RecentProjectsSourcePort { + list(): Promise; +} +``` + +### `RecentProjectsCachePort` + +```ts +export interface RecentProjectsCachePort { + get(key: string): Promise; + set(key: string, value: T, ttlMs: number): Promise; +} +``` + +### `ClockPort` + +```ts +export interface ClockPort { + now(): number; +} +``` + +### `LoggerPort` + +```ts +export interface LoggerPort { + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + error(message: string, meta?: Record): void; +} +``` + +### `ListDashboardRecentProjectsOutputPort` + +```ts +export interface ListDashboardRecentProjectsOutputPort { + present(aggregates: RecentProjectAggregate[]): TViewModel; +} +``` + +Это важный момент. +Если хотим действительно clean use case, use case не должен знать финальный transport DTO напрямую. + +--- + +## 8.3 Application Use Case + +`core/application/use-cases/ListDashboardRecentProjectsUseCase.ts` + +```ts +export interface ListDashboardRecentProjectsDeps { + sources: RecentProjectsSourcePort[]; + cache: RecentProjectsCachePort; + output: ListDashboardRecentProjectsOutputPort; + clock: ClockPort; + logger: LoggerPort; +} + +export class ListDashboardRecentProjectsUseCase { + constructor(private readonly deps: ListDashboardRecentProjectsDeps) {} + + async execute(cacheKey: string): Promise { + const cached = await this.deps.cache.get(cacheKey); + if (cached) { + return cached; + } + + const batches = await Promise.all(this.deps.sources.map((s) => s.list())); + const aggregates = mergeRecentProjectCandidates(batches.flat()); + const viewModel = this.deps.output.present(aggregates); + + await this.deps.cache.set(cacheKey, viewModel, 10_000); + return viewModel; + } +} +``` + +Почему это лучше прошлого варианта: + +- use case orchestration не знает ни про renderer, ни про IPC DTO +- output model отдаётся через presenter port +- cache тоже внешний порт, а не private field in use case + +Это уже настоящий `ports/adapters` shape. + +--- + +## 8.4 Adapters + +### `DashboardRecentProjectsPresenter` + +Responsibility: + +- `RecentProjectAggregate[] -> DashboardRecentProject[]` + +Это output adapter, реализующий `ListDashboardRecentProjectsOutputPort`. + +### `ClaudeRecentProjectsSourceAdapter` + +Responsibility: + +- взять active Claude context +- получить grouped repos +- превратить их в `RecentProjectCandidate[]` + +Это driven/output adapter, реализующий `RecentProjectsSourcePort`. + +### `CodexRecentProjectsSourceAdapter` + +Responsibility: + +- если context не local -> `[]` +- если `codex` missing -> `[]` +- через `codex app-server` достать recent thread summaries +- применить identity resolver +- превратить это в `RecentProjectCandidate[]` + +Это тоже driven/output adapter, реализующий `RecentProjectsSourcePort`. + +### Why adapters are separate from infrastructure + +Потому что adapter переводит **между моделями и портами**, а infrastructure решает **как технически достать данные**. + +Пример: + +- `CodexAppServerClient` - infrastructure +- `CodexRecentProjectsSourceAdapter` - adapter + +Это разные причины для изменения. + +--- + +## 8.5 Infrastructure + +### `CodexBinaryResolver` + +Только определяет доступен ли standalone `codex`. + +### `CodexAppServerClient` + +Только транспорт к `codex app-server`. + +### `JsonRpcStdioClient` + +Только generic stdio JSON-RPC plumbing. + +### `RecentProjectIdentityResolver` + +Хотя он помогает бизнес-логике, по природе он infrastructure helper, потому что зависит на git/path environment semantics. + +### `InMemoryRecentProjectsCache` + +Concrete cache adapter. + +--- + +## 9. Contracts Layer + +Feature contracts должны содержать только: + +- публичные DTO +- API fragment interface +- channel constants + +### Recommended files + +```text +src/features/recent-projects/contracts/ + dto.ts + api.ts + channels.ts + index.ts +``` + +### DTO + +```ts +export type DashboardProviderId = 'anthropic' | 'codex' | 'gemini'; + +export type DashboardRecentProjectOpenTarget = + | { type: 'existing-worktree'; repositoryId: string; worktreeId: string } + | { type: 'synthetic-path'; path: string }; + +export interface DashboardRecentProject { + id: string; + name: string; + primaryPath: string; + associatedPaths: string[]; + mostRecentActivity: number; + providerIds: DashboardProviderId[]; + source: 'claude' | 'codex' | 'mixed'; + openTarget: DashboardRecentProjectOpenTarget; + primaryBranch?: string; +} +``` + +### API fragment + +```ts +export interface RecentProjectsElectronApi { + getDashboardRecentProjects(): Promise; +} +``` + +### Channel constant + +```ts +export const GET_DASHBOARD_RECENT_PROJECTS = 'get-dashboard-recent-projects'; +``` + +### Important shell rule + +`src/shared/types/api.ts` не должен "владеть" этой feature. +Он может только **композировать** feature fragment: + +```ts +import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts'; + +export interface ElectronAPI extends RecentProjectsElectronApi { + // existing app methods... +} +``` + +Это намного чище, чем дублировать feature method shape внутри `shared/types/api.ts`. + +--- + +## 10. Public API, Input Adapters And Composition Root + +## 10.1 Feature Main Public API + +`src/features/recent-projects/main/index.ts` + +Должен экспортировать только: + +- use case factory/composer +- input adapter registration helpers +- necessary public ports if really needed + +Лучше не экспортировать наружу все внутренние классы поштучно без необходимости. + +### Better pattern + +```ts +export { createRecentProjectsFeature } from './composition/createRecentProjectsFeature'; +export { registerRecentProjectsIpc } from './adapters/input/ipc/registerRecentProjectsIpc'; +export { registerRecentProjectsHttp } from './adapters/input/http/registerRecentProjectsHttp'; +``` + +### Why factory is better than many exports + +Это уменьшает coupling composition root к внутреннему строению feature. + +## 10.2 Feature Composition + +Добавить: + +```text +src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +``` + +```ts +export function createRecentProjectsFeature(deps: { + getActiveContext: () => ServiceContext; + getLocalContext: () => ServiceContext | undefined; + logger: LoggerPort; +}) { + // instantiate infrastructure + // instantiate adapters + // instantiate presenter + // instantiate cache + // instantiate use case + // return entrypoint-facing surface +} +``` + +Именно это делает feature self-contained и масштабируемой. + +## 10.3 IPC input adapter + +`main/adapters/input/ipc/registerRecentProjectsIpc.ts` + +Он не должен знать, как строится Codex client, cache или merge policy. + +Он должен только: + +- принять feature facade +- зарегистрировать handler +- прокинуть `cacheKey` + +## 10.4 HTTP input adapter + +То же самое для Fastify route. + +## 10.5 Preload bridge + +`preload/createRecentProjectsBridge.ts` + +Bridge должен возвращать только feature API fragment: + +```ts +export function createRecentProjectsBridge(): RecentProjectsElectronApi { + return { + getDashboardRecentProjects: () => + ipcRenderer.invoke(GET_DASHBOARD_RECENT_PROJECTS), + }; +} +``` + +Это хороший пример thin interface adapter. + +--- + +## 11. Renderer Architecture + +### 11.1 Renderer public entrypoint + +`src/features/recent-projects/renderer/index.ts` + +```ts +export { RecentProjectsSection } from './ui/RecentProjectsSection'; +``` + +Внешний мир должен импортировать только это. + +### 11.2 `useRecentProjectsSection` + +Responsibility: + +- загрузить `DashboardRecentProject[]` +- взять decorations из store +- через adapter получить card models +- отдать state for UI + +### 11.3 `RecentProjectsSectionAdapter` + +Responsibility: + +- `DashboardRecentProject[] + task/team decorations -> RecentProjectCardModel[]` + +Он не должен: + +- выполнять fetch +- открывать проекты + +### 11.4 `useOpenRecentProject` + +Responsibility: + +- encapsulate navigation policy + +Это interaction adapter, а не UI helper. + +Сюда выносится: + +- `findMatchingWorktree` +- refresh repository groups +- `addCustomProjectPath` +- synthetic fallback group + +### 11.5 `RecentProjectCard` + +Responsibility: + +- чистая presentation + +Не импортирует: + +- `useStore` +- `api` +- navigation utils + +### 11.6 `RecentProjectsSection` + +Responsibility: + +- layout +- error/loading/empty states +- search filtering +- cards grid + +--- + +## 12. DRY Rules For This Feature + +### Rule 1 + +Одна merge policy в `domain/policies/mergeRecentProjectCandidates.ts` + +### Rule 2 + +Одна navigation policy в `renderer/hooks/useOpenRecentProject.ts` + +### Rule 3 + +Одна provider presentation mapping function в `renderer/utils/projectDecorations.ts` + +### Rule 4 + +Одна Codex fetch policy в `CodexRecentProjectsSourceAdapter` + +### Rule 5 + +Одна cache policy в `InMemoryRecentProjectsCache` + +--- + +## 13. Tech Decisions For Codex + +### Decision + +Для phase 1 использовать: + +- ephemeral `codex app-server` +- only `sourceKinds: ['vscode', 'cli']` +- include live + archived +- no file parsing + +### Why + +- этого достаточно для homepage use case +- не тянем слишком рискованную нативную storage-зависимость +- уменьшаем complexity + +### Timeouts + +- initialize: `3_000 ms` +- each list call: `3_000 ms` +- total Codex fetch: `8_000 ms` +- cache TTL: `10_000 ms` + +--- + +## 14. Guard Rails + +Если хотим, чтобы feature правда была эталонной, нужны guard rails. + +### Recommended rule set + +1. Outside feature import only public feature entrypoints: + - `@features/recent-projects/contracts` + - `@features/recent-projects/main` + - `@features/recent-projects/preload` + - `@features/recent-projects/renderer` + +2. No deep imports from one process subtree into another. + +3. `renderer/ui` components cannot import store or API directly. + +4. `core/application` cannot import `electron`, `fastify`, `child_process`. + +5. `core/domain` must remain side-effect free. + +### Optional but recommended later + +- add `no-restricted-imports` rules to enforce this automatically + +--- + +## 15. Implementation Sequence + +### Step 1 + +Create feature tree: + +- `src/features/recent-projects` + +### Step 2 + +Add alias support: + +- `tsconfig.json` +- `tsconfig.node.json` +- `electron.vite.config.ts` + +### Step 3 + +Create contracts: + +- DTO +- API fragment +- channel constant + +### Step 4 + +Create domain: + +- candidate +- aggregate +- provider id +- merge policy + +### Step 5 + +Create application layer: + +- use case +- ports +- response model + +### Step 6 + +Create output adapters: + +- Claude source adapter +- Codex source adapter +- presenter + +### Step 7 + +Create infrastructure: + +- cache +- identity resolver +- Codex binary/client/json-rpc + +### Step 8 + +Create feature composition root: + +- `createRecentProjectsFeature` + +### Step 9 + +Create driving input adapters: + +- IPC +- HTTP + +### Step 10 + +Create preload bridge + +### Step 11 + +Create renderer: + +- section adapter +- section hook +- open hook +- card UI +- section UI + +### Step 12 + +Integrate into app shell: + +- `src/main/index.ts` +- `src/main/ipc/handlers.ts` +- `src/main/http/index.ts` +- `src/preload/index.ts` +- `src/renderer/components/dashboard/DashboardView.tsx` + +### Step 13 + +Add tests and architecture review pass + +--- + +## 16. Test Strategy + +### Domain tests + +- merge policy +- branch conflict behaviour +- openTarget preference +- provider dedupe + +### Application tests + +- use case orchestrates sources + cache + presenter +- cache hit path +- cache miss path +- degraded Codex path still returns Claude result + +### Adapter tests + +- Claude source mapping +- Codex source mapping +- presenter mapping +- renderer section adapter mapping +- `useOpenRecentProject` navigation policy + +### Infrastructure tests + +- in-memory cache TTL +- Codex binary resolver success/failure +- app-server client request/timeout handling + +### Renderer tests + +- loading / empty / error states +- provider logos shown +- task/team decorations aggregated by `associatedPaths` + +### Manual checks + +1. local context without `codex` +2. local context with `codex` +3. mixed Claude + Codex repo +4. Codex-only non-git folder +5. ssh context + +--- + +## 17. Anti-Patterns To Avoid + +Не делать: + +- одну папку feature без внутренних слоёв +- use case, который напрямую знает про `CodexAppServerClient` +- presenter logic inside React components +- store/API imports inside `RecentProjectCard` +- дублирование navigation fallback +- прямой import feature internals из app shell +- подмешивание recent-projects logic обратно в `DashboardView` + +--- + +## 18. Final Recommendation + +Если эта feature должна быть эталоном, то лучший shape такой: + +- **feature-центричная структура** +- **строгий ports/adapters** +- **composition root внутри самой feature** +- **тонкий app shell** +- **тонкие input adapters и preload bridge** +- **чистый renderer UI без бизнес-логики** + +Итоговый выбранный подход: + +`Full vertical slice in src/features with core separated from process adapters` +`🎯 9 🛡️ 10 🧠 8` +Примерно `700-1000` строк + +Это дороже, чем прагматичный hybrid, но это уже действительно можно считать reference implementation по `SOLID`, `Clean Architecture`, `Ports/Adapters` и `DRY`. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 67727de3..fc6d5aeb 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -73,6 +73,7 @@ export default defineConfig({ }, resolve: { alias: { + '@features': resolve(__dirname, 'src/features'), '@main': resolve(__dirname, 'src/main'), '@shared': resolve(__dirname, 'src/shared'), '@preload': resolve(__dirname, 'src/preload') @@ -111,6 +112,7 @@ export default defineConfig({ preload: { resolve: { alias: { + '@features': resolve(__dirname, 'src/features'), '@preload': resolve(__dirname, 'src/preload'), '@shared': resolve(__dirname, 'src/shared'), '@main': resolve(__dirname, 'src/main') @@ -141,6 +143,7 @@ export default defineConfig({ }, resolve: { alias: { + '@features': resolve(__dirname, 'src/features'), '@renderer': resolve(__dirname, 'src/renderer'), '@shared': resolve(__dirname, 'src/shared'), '@main': resolve(__dirname, 'src/main'), diff --git a/eslint.config.js b/eslint.config.js index a26f1660..cbb8fac6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -78,7 +78,7 @@ export default defineConfig([ // Import plugin configuration - Renderer (uses tsconfig.json) { name: 'import-plugin-renderer', - files: ['src/renderer/**/*.{ts,tsx}'], + files: ['src/renderer/**/*.{ts,tsx}', 'src/features/**/*.{ts,tsx}'], plugins: { import: importPlugin, }, @@ -97,6 +97,137 @@ export default defineConfig([ }, }, + // Feature-specific architecture guard rails - recent-projects + { + name: 'feature-recent-projects-public-entrypoints', + files: ['src/**/*.{ts,tsx}'], + ignores: ['src/features/recent-projects/**/*'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@features/recent-projects/contracts/**', + '@features/recent-projects/core/**', + '@features/recent-projects/main/**', + '@features/recent-projects/preload/**', + '@features/recent-projects/renderer/**', + ], + message: + 'Import recent-projects only through its public entrypoints: @features/recent-projects/contracts, @features/recent-projects/main, @features/recent-projects/preload, or @features/recent-projects/renderer.', + }, + ], + }, + ], + }, + }, + { + name: 'feature-recent-projects-core-domain-guards', + files: ['src/features/recent-projects/core/domain/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@features/recent-projects/core/application/**', + '@features/recent-projects/main/**', + '@features/recent-projects/preload/**', + '@features/recent-projects/renderer/**', + '@main/**', + '@renderer/**', + '@preload/**', + 'electron', + 'fastify', + 'child_process', + 'node:child_process', + ], + message: + 'recent-projects core/domain must stay side-effect free and cannot depend on application, adapters, infrastructure, or platform code.', + }, + ], + }, + ], + }, + }, + { + name: 'feature-recent-projects-core-application-guards', + files: ['src/features/recent-projects/core/application/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@features/recent-projects/main/**', + '@features/recent-projects/preload/**', + '@features/recent-projects/renderer/**', + '@renderer/**', + 'electron', + 'fastify', + 'child_process', + 'node:child_process', + ], + message: + 'recent-projects core/application may depend only on domain, contracts, and application ports - not on adapters or runtime frameworks.', + }, + ], + }, + ], + }, + }, + { + name: 'feature-recent-projects-preload-guards', + files: ['src/features/recent-projects/preload/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@features/recent-projects/main/**', + '@main/**', + '@renderer/**', + ], + message: + 'recent-projects preload may depend only on contracts and preload-local bridge helpers.', + }, + ], + }, + ], + }, + }, + { + name: 'feature-recent-projects-renderer-ui-guards', + files: ['src/features/recent-projects/renderer/ui/**/*.tsx'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@renderer/api', + '@renderer/api/**', + '@renderer/store', + '@renderer/store/**', + '@main/**', + 'electron', + ], + message: + 'recent-projects renderer/ui must stay presentational. Move transport, store access, and navigation logic into hooks or adapters.', + }, + ], + }, + ], + }, + }, + // Module boundaries - Enforce Electron three-process architecture { name: 'module-boundaries', @@ -548,7 +679,7 @@ export default defineConfig([ // === Import Restrictions === // Note: boundaries/element-types handles main/renderer separation - 'no-restricted-imports': 'warn', + 'no-restricted-imports': 'off', // === Mutation Prevention === 'no-param-reassign': 'warn', diff --git a/src/features/README.md b/src/features/README.md new file mode 100644 index 00000000..c6bf5609 --- /dev/null +++ b/src/features/README.md @@ -0,0 +1,19 @@ +# Features + +This directory contains the canonical home for medium and large feature slices. + +Before creating or refactoring a feature, read: +- [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md) +- [Feature-local agent guidance](./CLAUDE.md) + +Reference implementation: +- `src/features/recent-projects` + +Use `src/features//` by default when the work introduces: +- a new use case or business policy +- transport wiring +- more than one process boundary +- more than one adapter or provider + +Do not duplicate architecture rules in feature folders. +Keep the standard centralized in [../../docs/FEATURE_ARCHITECTURE_STANDARD.md](../../docs/FEATURE_ARCHITECTURE_STANDARD.md). diff --git a/src/features/recent-projects/contracts/api.ts b/src/features/recent-projects/contracts/api.ts new file mode 100644 index 00000000..285ce11e --- /dev/null +++ b/src/features/recent-projects/contracts/api.ts @@ -0,0 +1,5 @@ +import type { DashboardRecentProject } from './dto'; + +export interface RecentProjectsElectronApi { + getDashboardRecentProjects(): Promise; +} diff --git a/src/features/recent-projects/contracts/channels.ts b/src/features/recent-projects/contracts/channels.ts new file mode 100644 index 00000000..ce463578 --- /dev/null +++ b/src/features/recent-projects/contracts/channels.ts @@ -0,0 +1,2 @@ +export const GET_DASHBOARD_RECENT_PROJECTS = 'get-dashboard-recent-projects'; +export const DASHBOARD_RECENT_PROJECTS_ROUTE = '/api/dashboard/recent-projects'; diff --git a/src/features/recent-projects/contracts/dto.ts b/src/features/recent-projects/contracts/dto.ts new file mode 100644 index 00000000..253ac36e --- /dev/null +++ b/src/features/recent-projects/contracts/dto.ts @@ -0,0 +1,19 @@ +export type DashboardProviderId = 'anthropic' | 'codex' | 'gemini'; + +export type DashboardRecentProjectSource = 'claude' | 'codex' | 'mixed'; + +export type DashboardRecentProjectOpenTarget = + | { type: 'existing-worktree'; repositoryId: string; worktreeId: string } + | { type: 'synthetic-path'; path: string }; + +export interface DashboardRecentProject { + id: string; + name: string; + primaryPath: string; + associatedPaths: string[]; + mostRecentActivity: number; + providerIds: DashboardProviderId[]; + source: DashboardRecentProjectSource; + openTarget: DashboardRecentProjectOpenTarget; + primaryBranch?: string; +} diff --git a/src/features/recent-projects/contracts/index.ts b/src/features/recent-projects/contracts/index.ts new file mode 100644 index 00000000..69f32f5a --- /dev/null +++ b/src/features/recent-projects/contracts/index.ts @@ -0,0 +1,3 @@ +export type * from './api'; +export * from './channels'; +export type * from './dto'; diff --git a/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts b/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts new file mode 100644 index 00000000..7016a81f --- /dev/null +++ b/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts @@ -0,0 +1,5 @@ +import type { RecentProjectAggregate } from '../../domain/models/RecentProjectAggregate'; + +export interface ListDashboardRecentProjectsResponse { + projects: RecentProjectAggregate[]; +} diff --git a/src/features/recent-projects/core/application/ports/ClockPort.ts b/src/features/recent-projects/core/application/ports/ClockPort.ts new file mode 100644 index 00000000..b7b6878b --- /dev/null +++ b/src/features/recent-projects/core/application/ports/ClockPort.ts @@ -0,0 +1,3 @@ +export interface ClockPort { + now(): number; +} diff --git a/src/features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort.ts b/src/features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort.ts new file mode 100644 index 00000000..b3fec9d4 --- /dev/null +++ b/src/features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort.ts @@ -0,0 +1,5 @@ +import type { ListDashboardRecentProjectsResponse } from '../models/ListDashboardRecentProjectsResponse'; + +export interface ListDashboardRecentProjectsOutputPort { + present(response: ListDashboardRecentProjectsResponse): TViewModel; +} diff --git a/src/features/recent-projects/core/application/ports/LoggerPort.ts b/src/features/recent-projects/core/application/ports/LoggerPort.ts new file mode 100644 index 00000000..d265d820 --- /dev/null +++ b/src/features/recent-projects/core/application/ports/LoggerPort.ts @@ -0,0 +1,5 @@ +export interface LoggerPort { + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + error(message: string, meta?: Record): void; +} diff --git a/src/features/recent-projects/core/application/ports/RecentProjectsCachePort.ts b/src/features/recent-projects/core/application/ports/RecentProjectsCachePort.ts new file mode 100644 index 00000000..2a76dd00 --- /dev/null +++ b/src/features/recent-projects/core/application/ports/RecentProjectsCachePort.ts @@ -0,0 +1,4 @@ +export interface RecentProjectsCachePort { + get(key: string): Promise; + set(key: string, value: T, ttlMs: number): Promise; +} diff --git a/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts b/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts new file mode 100644 index 00000000..004a8d72 --- /dev/null +++ b/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts @@ -0,0 +1,7 @@ +import type { RecentProjectCandidate } from '../../domain/models/RecentProjectCandidate'; + +export interface RecentProjectsSourcePort { + readonly sourceId?: string; + readonly timeoutMs?: number; + list(): Promise; +} diff --git a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts new file mode 100644 index 00000000..15c4295a --- /dev/null +++ b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts @@ -0,0 +1,150 @@ +import { mergeRecentProjectCandidates } from '../../domain/policies/mergeRecentProjectCandidates'; + +import type { RecentProjectCandidate } from '../../domain/models/RecentProjectCandidate'; +import type { ListDashboardRecentProjectsResponse } from '../models/ListDashboardRecentProjectsResponse'; +import type { ClockPort } from '../ports/ClockPort'; +import type { ListDashboardRecentProjectsOutputPort } from '../ports/ListDashboardRecentProjectsOutputPort'; +import type { LoggerPort } from '../ports/LoggerPort'; +import type { RecentProjectsCachePort } from '../ports/RecentProjectsCachePort'; +import type { RecentProjectsSourcePort } from '../ports/RecentProjectsSourcePort'; + +const DEFAULT_CACHE_TTL_MS = 10_000; +const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500; + +interface SourceLoadResult { + candidates: RecentProjectCandidate[]; + degraded: boolean; +} + +export interface ListDashboardRecentProjectsDeps { + sources: RecentProjectsSourcePort[]; + cache: RecentProjectsCachePort; + output: ListDashboardRecentProjectsOutputPort; + clock: ClockPort; + logger: LoggerPort; + cacheTtlMs?: number; + degradedCacheTtlMs?: number; +} + +export class ListDashboardRecentProjectsUseCase { + readonly #cacheTtlMs: number; + readonly #degradedCacheTtlMs: number; + + constructor(private readonly deps: ListDashboardRecentProjectsDeps) { + this.#cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; + this.#degradedCacheTtlMs = deps.degradedCacheTtlMs ?? DEFAULT_DEGRADED_CACHE_TTL_MS; + } + + async execute(cacheKey: string): Promise { + const cached = await this.deps.cache.get(cacheKey); + if (cached) { + return cached; + } + + const startedAt = this.deps.clock.now(); + const results = await Promise.all( + this.deps.sources.map((source, index) => this.#loadSource(source, index)) + ); + + const successful = results.flatMap((result) => result.candidates); + const hasDegradedSources = results.some((result) => result.degraded); + + const response: ListDashboardRecentProjectsResponse = { + projects: mergeRecentProjectCandidates(successful), + }; + const viewModel = this.deps.output.present(response); + const cacheTtlMs = hasDegradedSources + ? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs) + : this.#cacheTtlMs; + + await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs); + this.deps.logger.info('recent-projects loaded', { + cacheKey, + count: response.projects.length, + degradedSources: results.filter((result) => result.degraded).length, + cacheTtlMs, + durationMs: this.deps.clock.now() - startedAt, + }); + + return viewModel; + } + + async #loadSource( + source: RecentProjectsSourcePort, + sourceIndex: number + ): Promise { + const sourceId = source.sourceId ?? `source-${sourceIndex}`; + if (!source.timeoutMs || source.timeoutMs <= 0) { + return this.#loadSourceWithoutTimeout(source, sourceId, sourceIndex); + } + + let timer: ReturnType | null = null; + try { + const result = await Promise.race([ + source + .list() + .then( + (candidates) => + ({ + kind: 'success', + candidates, + }) as const + ) + .catch( + (error: unknown) => + ({ + kind: 'error', + error, + }) as const + ), + new Promise<{ kind: 'timeout' }>((resolve) => { + timer = setTimeout(() => resolve({ kind: 'timeout' }), source.timeoutMs); + }), + ]); + + if (result.kind === 'success') { + return { candidates: result.candidates, degraded: false }; + } + + if (result.kind === 'timeout') { + this.deps.logger.warn('recent-projects source timed out', { + sourceId, + sourceIndex, + timeoutMs: source.timeoutMs, + }); + return { candidates: [], degraded: true }; + } + + this.deps.logger.warn('recent-projects source failed', { + sourceId, + sourceIndex, + error: result.error instanceof Error ? result.error.message : String(result.error), + }); + return { candidates: [], degraded: true }; + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + + async #loadSourceWithoutTimeout( + source: RecentProjectsSourcePort, + sourceId: string, + sourceIndex: number + ): Promise { + try { + return { + candidates: await source.list(), + degraded: false, + }; + } catch (error) { + this.deps.logger.warn('recent-projects source failed', { + sourceId, + sourceIndex, + error: error instanceof Error ? error.message : String(error), + }); + return { candidates: [], degraded: true }; + } + } +} diff --git a/src/features/recent-projects/core/domain/models/ProviderId.ts b/src/features/recent-projects/core/domain/models/ProviderId.ts new file mode 100644 index 00000000..d55bf3cd --- /dev/null +++ b/src/features/recent-projects/core/domain/models/ProviderId.ts @@ -0,0 +1 @@ +export type ProviderId = 'anthropic' | 'codex' | 'gemini'; diff --git a/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts b/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts new file mode 100644 index 00000000..9c098a65 --- /dev/null +++ b/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts @@ -0,0 +1,14 @@ +import type { ProviderId } from './ProviderId'; +import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget'; + +export interface RecentProjectAggregate { + identity: string; + displayName: string; + primaryPath: string; + associatedPaths: string[]; + lastActivityAt: number; + providerIds: ProviderId[]; + source: 'claude' | 'codex' | 'mixed'; + openTarget: RecentProjectOpenTarget; + branchName?: string; +} diff --git a/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts b/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts new file mode 100644 index 00000000..2abc5315 --- /dev/null +++ b/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts @@ -0,0 +1,14 @@ +import type { ProviderId } from './ProviderId'; +import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget'; + +export interface RecentProjectCandidate { + identity: string; + displayName: string; + primaryPath: string; + associatedPaths: string[]; + lastActivityAt: number; + providerIds: ProviderId[]; + sourceKind: 'claude' | 'codex'; + openTarget: RecentProjectOpenTarget; + branchName?: string; +} diff --git a/src/features/recent-projects/core/domain/models/RecentProjectOpenTarget.ts b/src/features/recent-projects/core/domain/models/RecentProjectOpenTarget.ts new file mode 100644 index 00000000..c4ae3214 --- /dev/null +++ b/src/features/recent-projects/core/domain/models/RecentProjectOpenTarget.ts @@ -0,0 +1,3 @@ +export type RecentProjectOpenTarget = + | { type: 'existing-worktree'; repositoryId: string; worktreeId: string } + | { type: 'synthetic-path'; path: string }; diff --git a/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts b/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts new file mode 100644 index 00000000..ce3be598 --- /dev/null +++ b/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts @@ -0,0 +1,88 @@ +import type { ProviderId } from '../models/ProviderId'; +import type { RecentProjectAggregate } from '../models/RecentProjectAggregate'; +import type { RecentProjectCandidate } from '../models/RecentProjectCandidate'; + +function uniquePaths(paths: readonly string[], primaryPath: string): string[] { + const ordered = [primaryPath, ...paths]; + const seen = new Set(); + const result: string[] = []; + + for (const path of ordered) { + if (!path || seen.has(path)) { + continue; + } + seen.add(path); + result.push(path); + } + + return result; +} + +function uniqueProviders(providerIds: readonly ProviderId[]): ProviderId[] { + return Array.from(new Set(providerIds)); +} + +function selectPreferredCandidate( + candidates: readonly RecentProjectCandidate[] +): RecentProjectCandidate { + const existingWorktreeCandidates = candidates.filter( + (candidate) => candidate.openTarget.type === 'existing-worktree' + ); + const pool = existingWorktreeCandidates.length > 0 ? existingWorktreeCandidates : candidates; + + return [...pool].sort((left, right) => { + if (right.lastActivityAt !== left.lastActivityAt) { + return right.lastActivityAt - left.lastActivityAt; + } + return left.displayName.localeCompare(right.displayName); + })[0]; +} + +function mergeBranchName(candidates: readonly RecentProjectCandidate[]): string | undefined { + const branchNames = Array.from( + new Set(candidates.map((candidate) => candidate.branchName?.trim()).filter(Boolean)) + ); + + return branchNames.length === 1 ? branchNames[0] : undefined; +} + +export function mergeRecentProjectCandidates( + candidates: readonly RecentProjectCandidate[] +): RecentProjectAggregate[] { + const grouped = new Map(); + + for (const candidate of candidates) { + if (!candidate.identity || candidate.lastActivityAt <= 0) { + continue; + } + const bucket = grouped.get(candidate.identity); + if (bucket) { + bucket.push(candidate); + } else { + grouped.set(candidate.identity, [candidate]); + } + } + + const aggregates = Array.from(grouped.values()).map((group): RecentProjectAggregate => { + const preferred = selectPreferredCandidate(group); + const providerIds = uniqueProviders(group.flatMap((candidate) => candidate.providerIds)); + const sourceKinds = new Set(group.map((candidate) => candidate.sourceKind)); + + return { + identity: preferred.identity, + displayName: preferred.displayName, + primaryPath: preferred.primaryPath, + associatedPaths: uniquePaths( + group.flatMap((candidate) => candidate.associatedPaths), + preferred.primaryPath + ), + lastActivityAt: Math.max(...group.map((candidate) => candidate.lastActivityAt)), + providerIds, + source: sourceKinds.size > 1 ? 'mixed' : sourceKinds.has('codex') ? 'codex' : 'claude', + openTarget: preferred.openTarget, + branchName: mergeBranchName(group), + }; + }); + + return aggregates.sort((left, right) => right.lastActivityAt - left.lastActivityAt); +} diff --git a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts new file mode 100644 index 00000000..991cab02 --- /dev/null +++ b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts @@ -0,0 +1,24 @@ +import { + DASHBOARD_RECENT_PROJECTS_ROUTE, + type DashboardRecentProject, +} from '@features/recent-projects/contracts'; +import { createLogger } from '@shared/utils/logger'; + +import type { RecentProjectsFeatureFacade } from '@features/recent-projects/main/composition/createRecentProjectsFeature'; +import type { FastifyInstance } from 'fastify'; + +const logger = createLogger('Feature:RecentProjects:HTTP'); + +export function registerRecentProjectsHttp( + app: FastifyInstance, + feature: RecentProjectsFeatureFacade +): void { + app.get(DASHBOARD_RECENT_PROJECTS_ROUTE, async (): Promise => { + try { + return await feature.listDashboardRecentProjects(); + } catch (error) { + logger.error('Failed to load dashboard recent projects via HTTP', error); + return []; + } + }); +} diff --git a/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts new file mode 100644 index 00000000..906c97b5 --- /dev/null +++ b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts @@ -0,0 +1,25 @@ +import { GET_DASHBOARD_RECENT_PROJECTS } from '@features/recent-projects/contracts'; +import { createLogger } from '@shared/utils/logger'; + +import type { RecentProjectsFeatureFacade } from '@features/recent-projects/main/composition/createRecentProjectsFeature'; +import type { IpcMain } from 'electron'; + +const logger = createLogger('Feature:RecentProjects:IPC'); + +export function registerRecentProjectsIpc( + ipcMain: IpcMain, + feature: RecentProjectsFeatureFacade +): void { + ipcMain.handle(GET_DASHBOARD_RECENT_PROJECTS, async () => { + try { + return await feature.listDashboardRecentProjects(); + } catch (error) { + logger.error('Failed to load dashboard recent projects via IPC', error); + return []; + } + }); +} + +export function removeRecentProjectsIpc(ipcMain: IpcMain): void { + ipcMain.removeHandler(GET_DASHBOARD_RECENT_PROJECTS); +} diff --git a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts new file mode 100644 index 00000000..638c662e --- /dev/null +++ b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts @@ -0,0 +1,21 @@ +import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { ListDashboardRecentProjectsResponse } from '@features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse'; +import type { ListDashboardRecentProjectsOutputPort } from '@features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort'; + +export class DashboardRecentProjectsPresenter implements ListDashboardRecentProjectsOutputPort< + DashboardRecentProject[] +> { + present(response: ListDashboardRecentProjectsResponse): DashboardRecentProject[] { + return response.projects.map((aggregate) => ({ + id: aggregate.identity, + name: aggregate.displayName, + primaryPath: aggregate.primaryPath, + associatedPaths: aggregate.associatedPaths, + mostRecentActivity: aggregate.lastActivityAt, + providerIds: aggregate.providerIds, + source: aggregate.source, + openTarget: aggregate.openTarget, + primaryBranch: aggregate.branchName, + })); + } +} diff --git a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts new file mode 100644 index 00000000..2d5aa3c5 --- /dev/null +++ b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts @@ -0,0 +1,80 @@ +import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath'; +import { WorktreeGrouper } from '@main/services/discovery/WorktreeGrouper'; +import { getProjectsBasePath } from '@main/utils/pathDecoder'; + +import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; +import type { RecentProjectsSourcePort } from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; +import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; +import type { ServiceContext } from '@main/services'; +import type { RepositoryGroup, Worktree } from '@main/types'; + +function selectPreferredWorktree(worktrees: readonly Worktree[]): Worktree | undefined { + return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0]; +} + +function toCandidate(repo: RepositoryGroup): RecentProjectCandidate | null { + if (!repo.worktrees.length || !repo.mostRecentSession) { + return null; + } + + const preferredWorktree = selectPreferredWorktree(repo.worktrees); + if (!preferredWorktree) { + return null; + } + + return { + identity: repo.identity?.id ?? `path:${normalizeIdentityPath(preferredWorktree.path)}`, + displayName: repo.name, + primaryPath: preferredWorktree.path, + associatedPaths: repo.worktrees.map((worktree) => worktree.path), + lastActivityAt: repo.mostRecentSession, + providerIds: ['anthropic'], + sourceKind: 'claude', + openTarget: { + type: 'existing-worktree', + repositoryId: repo.id, + worktreeId: preferredWorktree.id, + }, + branchName: preferredWorktree.gitBranch, + }; +} + +export class ClaudeRecentProjectsSourceAdapter implements RecentProjectsSourcePort { + readonly #localWorktreeGrouper = new WorktreeGrouper(getProjectsBasePath()); + + constructor( + private readonly getActiveContext: () => ServiceContext, + private readonly logger: LoggerPort + ) {} + + async list(): Promise { + const activeContext = this.getActiveContext(); + const groups = + activeContext.type === 'local' + ? await this.#groupLocalProjects(activeContext) + : await activeContext.projectScanner.scanWithWorktreeGrouping(); + + const candidates = groups + .map((group) => toCandidate(group)) + .filter((candidate): candidate is RecentProjectCandidate => candidate !== null); + + this.logger.info('claude recent-projects source loaded', { + count: candidates.length, + contextId: activeContext.id, + }); + + return candidates; + } + + async #groupLocalProjects(activeContext: ServiceContext): Promise { + try { + const projects = await activeContext.projectScanner.scan(); + return await this.#localWorktreeGrouper.groupByRepository(projects); + } catch (error) { + this.logger.warn('claude recent-projects fell back to simplified grouping', { + error: error instanceof Error ? error.message : String(error), + }); + return activeContext.projectScanner.scanWithWorktreeGrouping(); + } + } +} diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts new file mode 100644 index 00000000..4430b8bb --- /dev/null +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -0,0 +1,177 @@ +import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath'; +import path from 'path'; + +import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; +import type { RecentProjectsSourcePort } from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; +import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; +import type { + CodexAppServerClient, + CodexThreadSummary, +} from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient'; +import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver'; +import type { ServiceContext } from '@main/services'; + +const CODEX_THREAD_LIMIT = 40; +const CODEX_LIVE_FETCH_TIMEOUT_MS = 1_200; +const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 1_800; +const CODEX_REQUEST_TIMEOUT_MS = 1_800; +const CODEX_SOURCE_TIMEOUT_MS = 1_500; +const FAST_ARCHIVED_MERGE_TIMEOUT_MS = 150; + +function isInteractiveSource(source: unknown): boolean { + return source === 'vscode' || source === 'cli'; +} + +function normalizeTimestamp(value: number | undefined): number { + if (!value) { + return 0; + } + return value < 1_000_000_000_000 ? value * 1000 : value; +} + +export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort { + readonly sourceId = 'codex'; + readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS; + + constructor( + private readonly deps: { + getActiveContext: () => ServiceContext; + getLocalContext: () => ServiceContext | undefined; + resolveBinary: () => Promise; + appServerClient: CodexAppServerClient; + identityResolver: RecentProjectIdentityResolver; + logger: LoggerPort; + } + ) {} + + async list(): Promise { + const activeContext = this.deps.getActiveContext(); + const localContext = this.deps.getLocalContext(); + + if (activeContext.type !== 'local' || activeContext.id !== localContext?.id) { + return []; + } + + const binaryPath = await this.deps.resolveBinary(); + if (!binaryPath) { + this.deps.logger.info('codex recent-projects source skipped - binary unavailable'); + return []; + } + + const liveThreads = await this.#listThreadsSegmentSafe(binaryPath, 'live', { + archived: false, + totalTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS, + }); + const archivedPromise = this.#listThreadsSegmentSafe(binaryPath, 'archived', { + archived: true, + totalTimeoutMs: CODEX_ARCHIVED_FETCH_TIMEOUT_MS, + }); + const archivedThreads = + liveThreads.length > 0 + ? await this.#awaitWithTimeout(archivedPromise, FAST_ARCHIVED_MERGE_TIMEOUT_MS) + : await archivedPromise; + + const interactiveThreads = [...liveThreads, ...archivedThreads].filter( + (thread) => Boolean(thread.cwd) && isInteractiveSource(thread.source) + ); + + const candidates = ( + await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread))) + ).filter((candidate): candidate is RecentProjectCandidate => candidate !== null); + + this.deps.logger.info('codex recent-projects source loaded', { + count: candidates.length, + }); + + return candidates; + } + + async #listThreadsSegment( + binaryPath: string, + segment: 'live' | 'archived', + options: { + archived: boolean; + totalTimeoutMs: number; + } + ): Promise { + const result = await this.deps.appServerClient.listThreads(binaryPath, { + archived: options.archived, + limit: CODEX_THREAD_LIMIT, + requestTimeoutMs: CODEX_REQUEST_TIMEOUT_MS, + totalTimeoutMs: options.totalTimeoutMs, + }); + + this.deps.logger.info('codex recent-projects thread list loaded', { + segment, + count: result.length, + }); + return result; + } + + async #awaitWithTimeout( + promise: Promise, + timeoutMs: number + ): Promise { + let timer: ReturnType | null = null; + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve([]), timeoutMs); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + + #unwrapThreadListError(error: unknown, segment: 'live' | 'archived'): CodexThreadSummary[] { + this.deps.logger.warn('codex recent-projects thread list failed', { + segment, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + + async #listThreadsSegmentSafe( + binaryPath: string, + segment: 'live' | 'archived', + options: { + archived: boolean; + totalTimeoutMs: number; + } + ): Promise { + try { + return await this.#listThreadsSegment(binaryPath, segment, options); + } catch (error) { + return this.#unwrapThreadListError(error, segment); + } + } + + async #toCandidate(thread: CodexThreadSummary): Promise { + const cwd = thread.cwd?.trim(); + if (!cwd) { + return null; + } + + const identity = await this.deps.identityResolver.resolve(cwd); + const displayName = identity?.name ?? path.basename(cwd) ?? thread.name?.trim() ?? cwd; + + return { + identity: identity?.id ?? `path:${normalizeIdentityPath(cwd)}`, + displayName, + primaryPath: cwd, + associatedPaths: [cwd], + lastActivityAt: normalizeTimestamp(thread.updatedAt ?? thread.createdAt), + providerIds: ['codex'], + sourceKind: 'codex', + openTarget: { + type: 'synthetic-path', + path: cwd, + }, + branchName: thread.gitInfo?.branch ?? undefined, + }; + } +} diff --git a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts new file mode 100644 index 00000000..effb5a8c --- /dev/null +++ b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts @@ -0,0 +1,56 @@ +import { ListDashboardRecentProjectsUseCase } from '../../core/application/use-cases/ListDashboardRecentProjectsUseCase'; +import { DashboardRecentProjectsPresenter } from '../adapters/output/presenters/DashboardRecentProjectsPresenter'; +import { ClaudeRecentProjectsSourceAdapter } from '../adapters/output/sources/ClaudeRecentProjectsSourceAdapter'; +import { CodexRecentProjectsSourceAdapter } from '../adapters/output/sources/CodexRecentProjectsSourceAdapter'; +import { InMemoryRecentProjectsCache } from '../infrastructure/cache/InMemoryRecentProjectsCache'; +import { CodexAppServerClient } from '../infrastructure/codex/CodexAppServerClient'; +import { CodexBinaryResolver } from '../infrastructure/codex/CodexBinaryResolver'; +import { JsonRpcStdioClient } from '../infrastructure/codex/JsonRpcStdioClient'; +import { RecentProjectIdentityResolver } from '../infrastructure/identity/RecentProjectIdentityResolver'; + +import type { ClockPort } from '../../core/application/ports/ClockPort'; +import type { LoggerPort } from '../../core/application/ports/LoggerPort'; +import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { ServiceContext } from '@main/services'; + +export interface RecentProjectsFeatureFacade { + listDashboardRecentProjects(): Promise; +} + +export function createRecentProjectsFeature(deps: { + getActiveContext: () => ServiceContext; + getLocalContext: () => ServiceContext | undefined; + logger: LoggerPort; +}): RecentProjectsFeatureFacade { + const cache = new InMemoryRecentProjectsCache(); + const presenter = new DashboardRecentProjectsPresenter(); + const clock: ClockPort = { now: () => Date.now() }; + const jsonRpcStdioClient = new JsonRpcStdioClient(deps.logger); + const codexAppServerClient = new CodexAppServerClient(jsonRpcStdioClient); + const identityResolver = new RecentProjectIdentityResolver(); + const sources = [ + new ClaudeRecentProjectsSourceAdapter(deps.getActiveContext, deps.logger), + new CodexRecentProjectsSourceAdapter({ + getActiveContext: deps.getActiveContext, + getLocalContext: deps.getLocalContext, + resolveBinary: () => CodexBinaryResolver.resolve(), + appServerClient: codexAppServerClient, + identityResolver, + logger: deps.logger, + }), + ]; + const useCase = new ListDashboardRecentProjectsUseCase({ + sources, + cache, + output: presenter, + clock, + logger: deps.logger, + }); + + return { + listDashboardRecentProjects: () => { + const activeContext = deps.getActiveContext(); + return useCase.execute(`dashboard-recent-projects:${activeContext.id}`); + }, + }; +} diff --git a/src/features/recent-projects/main/index.ts b/src/features/recent-projects/main/index.ts new file mode 100644 index 00000000..bdf2a07b --- /dev/null +++ b/src/features/recent-projects/main/index.ts @@ -0,0 +1,7 @@ +export { registerRecentProjectsHttp } from './adapters/input/http/registerRecentProjectsHttp'; +export { + registerRecentProjectsIpc, + removeRecentProjectsIpc, +} from './adapters/input/ipc/registerRecentProjectsIpc'; +export type { RecentProjectsFeatureFacade } from './composition/createRecentProjectsFeature'; +export { createRecentProjectsFeature } from './composition/createRecentProjectsFeature'; diff --git a/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts b/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts new file mode 100644 index 00000000..ff0a0a39 --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts @@ -0,0 +1,31 @@ +import type { RecentProjectsCachePort } from '../../../core/application/ports/RecentProjectsCachePort'; + +interface CacheEntry { + value: T; + expiresAt: number; +} + +export class InMemoryRecentProjectsCache implements RecentProjectsCachePort { + readonly #entries = new Map>(); + + async get(key: string): Promise { + const entry = this.#entries.get(key); + if (!entry) { + return null; + } + + if (entry.expiresAt <= Date.now()) { + this.#entries.delete(key); + return null; + } + + return entry.value; + } + + async set(key: string, value: T, ttlMs: number): Promise { + this.#entries.set(key, { + value, + expiresAt: Date.now() + ttlMs, + }); + } +} diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts new file mode 100644 index 00000000..a7cab3e2 --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts @@ -0,0 +1,97 @@ +import type { JsonRpcStdioClient } from './JsonRpcStdioClient'; + +const DEFAULT_REQUEST_TIMEOUT_MS = 3_000; +const DEFAULT_TOTAL_TIMEOUT_MS = 8_000; +const SUPPRESSED_NOTIFICATION_METHODS = [ + 'thread/started', + 'thread/status/changed', + 'thread/archived', + 'thread/unarchived', + 'thread/closed', + 'thread/name/updated', + 'turn/started', + 'turn/completed', + 'item/agentMessage/delta', + 'item/agentReasoning/delta', + 'item/execCommandOutputDelta', +]; + +interface ThreadListResponse { + data?: CodexThreadSummary[]; +} + +interface CodexGitInfo { + branch?: string | null; + originUrl?: string | null; + sha?: string | null; +} + +export interface CodexThreadSummary { + id: string; + createdAt?: number; + updatedAt?: number; + cwd?: string | null; + source?: unknown; + modelProvider?: string | null; + gitInfo?: CodexGitInfo | null; + name?: string | null; + path?: string | null; +} + +export class CodexAppServerClient { + constructor(private readonly rpcClient: JsonRpcStdioClient) {} + + async listThreads( + binaryPath: string, + options: { + archived: boolean; + limit: number; + requestTimeoutMs?: number; + totalTimeoutMs?: number; + } + ): Promise { + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const totalTimeoutMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS; + + return this.rpcClient.withSession( + { + binaryPath, + args: ['app-server'], + requestTimeoutMs, + totalTimeoutMs, + label: 'codex app-server thread/list', + }, + async (session) => { + await session.request( + 'initialize', + { + clientInfo: { + name: 'claude-agent-teams-ui', + title: 'Claude Agent Teams UI', + version: '0.1.0', + }, + capabilities: { + experimentalApi: false, + optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS, + }, + }, + requestTimeoutMs + ); + + await session.notify('initialized'); + + const response = await session.request( + 'thread/list', + { + archived: options.archived, + limit: options.limit, + sortKey: 'updated_at', + }, + requestTimeoutMs + ); + + return response.data ?? []; + } + ); + } +} diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts b/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts new file mode 100644 index 00000000..7239af90 --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts @@ -0,0 +1,71 @@ +import { execCli } from '@main/utils/childProcess'; + +const CACHE_VERIFY_TTL_MS = 30_000; + +let cachedBinaryPath: string | null | undefined; +let cacheVerifiedAt = 0; +let resolveInFlight: Promise | null = null; + +async function verifyBinary(candidate: string): Promise { + try { + await execCli(candidate, ['--version'], { timeout: 2_000, windowsHide: true }); + return candidate; + } catch { + return null; + } +} + +export class CodexBinaryResolver { + static clearCache(): void { + cachedBinaryPath = undefined; + cacheVerifiedAt = 0; + resolveInFlight = null; + } + + static async resolve(): Promise { + if (cachedBinaryPath !== undefined) { + if (cachedBinaryPath === null) { + return null; + } + + if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) { + return cachedBinaryPath; + } + + const verified = await verifyBinary(cachedBinaryPath); + if (verified) { + cacheVerifiedAt = Date.now(); + return verified; + } + + cachedBinaryPath = undefined; + cacheVerifiedAt = 0; + } + + if (!resolveInFlight) { + resolveInFlight = CodexBinaryResolver.runResolve().finally(() => { + resolveInFlight = null; + }); + } + + return resolveInFlight; + } + + private static async runResolve(): Promise { + const override = process.env.CODEX_CLI_PATH?.trim(); + const candidates = override ? [override, 'codex'] : ['codex']; + + for (const candidate of candidates) { + const resolved = await verifyBinary(candidate); + if (resolved) { + cachedBinaryPath = resolved; + cacheVerifiedAt = Date.now(); + return resolved; + } + } + + cachedBinaryPath = null; + cacheVerifiedAt = Date.now(); + return null; + } +} diff --git a/src/features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient.ts b/src/features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient.ts new file mode 100644 index 00000000..afd3dc6a --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient.ts @@ -0,0 +1,211 @@ +import { once } from 'node:events'; +import readline from 'node:readline'; + +import { killProcessTree, spawnCli } from '@main/utils/childProcess'; + +import type { LoggerPort } from '../../../core/application/ports/LoggerPort'; + +const DEFAULT_REQUEST_TIMEOUT_MS = 3_000; +const DEFAULT_TOTAL_TIMEOUT_MS = 8_000; + +interface JsonRpcErrorPayload { + code?: number; + message?: string; +} + +interface JsonRpcResponse { + id?: number; + result?: T; + error?: JsonRpcErrorPayload; +} + +export interface JsonRpcSession { + request(method: string, params?: unknown, timeoutMs?: number): Promise; + notify(method: string, params?: unknown): Promise; +} + +function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + let timeoutId: ReturnType | null = null; + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), + timeoutMs + ); + }); + + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }) as Promise; +} + +export class JsonRpcStdioClient { + constructor(private readonly logger: LoggerPort) {} + + async withSession( + options: { + binaryPath: string; + args: string[]; + requestTimeoutMs?: number; + totalTimeoutMs?: number; + label: string; + }, + handler: (session: JsonRpcSession) => Promise + ): Promise { + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const totalTimeoutMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS; + + return withTimeout( + this.#runSession(options.binaryPath, options.args, requestTimeoutMs, handler), + totalTimeoutMs, + options.label + ); + } + + async #runSession( + binaryPath: string, + args: string[], + requestTimeoutMs: number, + handler: (session: JsonRpcSession) => Promise + ): Promise { + const child = spawnCli(binaryPath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + const lineReader = readline.createInterface({ input: child.stdout! }); + child.stderr?.on('data', () => { + // Keep stderr drained so process warnings do not block the pipe. + }); + + const pending = new Map< + number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeoutId: ReturnType; + } + >(); + + let nextRequestId = 1; + + const rejectAll = (error: Error): void => { + for (const [id, entry] of pending) { + clearTimeout(entry.timeoutId); + entry.reject(error); + pending.delete(id); + } + }; + + lineReader.on('line', (line) => { + let message: JsonRpcResponse; + try { + message = JSON.parse(line) as JsonRpcResponse; + } catch (error) { + this.logger.warn('json-rpc stdio emitted non-json line', { + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + if (typeof message.id !== 'number') { + return; + } + + const entry = pending.get(message.id); + if (!entry) { + return; + } + + clearTimeout(entry.timeoutId); + pending.delete(message.id); + + if (message.error) { + entry.reject(new Error(message.error.message ?? 'Unknown JSON-RPC error')); + return; + } + + entry.resolve(message.result); + }); + + child.once('error', (error) => { + rejectAll(error instanceof Error ? error : new Error(String(error))); + }); + + child.once('exit', (code, signal) => { + if (pending.size === 0) { + return; + } + + rejectAll( + new Error( + `JSON-RPC process exited unexpectedly (code=${code ?? 'null'} signal=${signal ?? 'null'})` + ) + ); + }); + + const session: JsonRpcSession = { + request: ( + method: string, + params?: unknown, + timeoutMs = requestTimeoutMs + ): Promise => + new Promise((resolve, reject) => { + if (!child.stdin) { + reject(new Error('JSON-RPC stdin is not available')); + return; + } + + const id = nextRequestId++; + const timeoutId = setTimeout(() => { + pending.delete(id); + reject(new Error(`JSON-RPC request timed out: ${method}`)); + }, timeoutMs); + + pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timeoutId }); + + child.stdin.write(`${JSON.stringify({ id, method, params })}\n`, (error) => { + if (!error) { + return; + } + + clearTimeout(timeoutId); + pending.delete(id); + reject(error instanceof Error ? error : new Error(String(error))); + }); + }), + + notify: async (method: string, params?: unknown): Promise => { + if (!child.stdin) { + throw new Error('JSON-RPC stdin is not available'); + } + + await new Promise((resolve, reject) => { + child.stdin!.write(`${JSON.stringify({ method, params })}\n`, (error) => { + if (error) { + reject(error instanceof Error ? error : new Error(String(error))); + return; + } + resolve(); + }); + }); + }, + }; + + try { + return await handler(session); + } finally { + rejectAll(new Error('JSON-RPC session closed')); + lineReader.close(); + if (child.stdin && !child.stdin.destroyed) { + child.stdin.end(); + } + killProcessTree(child); + try { + await once(child, 'close'); + } catch { + this.logger.warn('json-rpc close wait failed'); + } + } + } +} diff --git a/src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts b/src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts new file mode 100644 index 00000000..f7dc39d3 --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts @@ -0,0 +1,20 @@ +import { gitIdentityResolver } from '@main/services/parsing/GitIdentityResolver'; + +export interface RecentProjectIdentity { + id: string; + name?: string; +} + +export class RecentProjectIdentityResolver { + async resolve(projectPath: string): Promise { + const identity = await gitIdentityResolver.resolveIdentity(projectPath); + if (!identity) { + return null; + } + + return { + id: identity.id, + name: identity.name, + }; + } +} diff --git a/src/features/recent-projects/main/infrastructure/identity/normalizeIdentityPath.ts b/src/features/recent-projects/main/infrastructure/identity/normalizeIdentityPath.ts new file mode 100644 index 00000000..5115440e --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/identity/normalizeIdentityPath.ts @@ -0,0 +1,10 @@ +import path from 'path'; + +export function normalizeIdentityPath(projectPath: string): string { + let normalized = path.normalize(projectPath); + while (normalized.length > 1 && (normalized.endsWith('/') || normalized.endsWith('\\'))) { + normalized = normalized.slice(0, -1); + } + + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} diff --git a/src/features/recent-projects/preload/createRecentProjectsBridge.ts b/src/features/recent-projects/preload/createRecentProjectsBridge.ts new file mode 100644 index 00000000..f5494bd9 --- /dev/null +++ b/src/features/recent-projects/preload/createRecentProjectsBridge.ts @@ -0,0 +1,11 @@ +import { + GET_DASHBOARD_RECENT_PROJECTS, + type RecentProjectsElectronApi, +} from '@features/recent-projects/contracts'; +import { ipcRenderer } from 'electron'; + +export function createRecentProjectsBridge(): RecentProjectsElectronApi { + return { + getDashboardRecentProjects: () => ipcRenderer.invoke(GET_DASHBOARD_RECENT_PROJECTS), + }; +} diff --git a/src/features/recent-projects/preload/index.ts b/src/features/recent-projects/preload/index.ts new file mode 100644 index 00000000..c68458b2 --- /dev/null +++ b/src/features/recent-projects/preload/index.ts @@ -0,0 +1 @@ +export { createRecentProjectsBridge } from './createRecentProjectsBridge'; diff --git a/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts b/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts new file mode 100644 index 00000000..58b0cd79 --- /dev/null +++ b/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts @@ -0,0 +1,130 @@ +import { formatProjectPath } from '@renderer/utils/pathDisplay'; +import { normalizePath, type TaskStatusCounts } from '@renderer/utils/pathNormalize'; +import { formatDistanceToNow } from 'date-fns'; + +import { sortDashboardProviderIds } from '../utils/projectDecorations'; + +import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { TeamSummary } from '@shared/types'; + +export interface RecentProjectCardModel { + id: string; + project: DashboardRecentProject; + name: string; + formattedPath: string; + lastActivityLabel: string; + providerIds: DashboardRecentProject['providerIds']; + primaryBranch?: string; + taskCounts?: TaskStatusCounts; + tasksLoading: boolean; + activeTeams?: TeamSummary[]; + additionalPathCount: number; + pathSummary?: { + badgeLabel: string; + description: string; + paths: { + label: string; + fullPath: string; + }[]; + }; +} + +interface RecentProjectsSectionAdapterInput { + projects: DashboardRecentProject[]; + taskCountsByProject: Map; + activeTeamsByProject: Map; + tasksLoading: boolean; +} + +function sumTaskCounts( + project: DashboardRecentProject, + taskCountsByProject: Map +): TaskStatusCounts | undefined { + const total = project.associatedPaths.reduce( + (counts, currentPath) => { + const next = taskCountsByProject.get(normalizePath(currentPath)); + if (!next) { + return counts; + } + + return { + pending: counts.pending + next.pending, + inProgress: counts.inProgress + next.inProgress, + completed: counts.completed + next.completed, + }; + }, + { pending: 0, inProgress: 0, completed: 0 } + ); + + return total.pending > 0 || total.inProgress > 0 || total.completed > 0 ? total : undefined; +} + +function collectActiveTeams( + project: DashboardRecentProject, + activeTeamsByProject: Map +): TeamSummary[] | undefined { + const seen = new Set(); + const activeTeams: TeamSummary[] = []; + + for (const projectPath of project.associatedPaths) { + const teams = activeTeamsByProject.get(normalizePath(projectPath)); + if (!teams) { + continue; + } + + for (const team of teams) { + if (seen.has(team.teamName)) { + continue; + } + + seen.add(team.teamName); + activeTeams.push(team); + } + } + + return activeTeams.length > 0 ? activeTeams : undefined; +} + +function buildPathSummary( + project: DashboardRecentProject +): RecentProjectCardModel['pathSummary'] | undefined { + const orderedPaths = [project.primaryPath, ...project.associatedPaths].filter(Boolean); + const uniquePaths = Array.from(new Set(orderedPaths)); + + if (uniquePaths.length <= 1) { + return undefined; + } + + return { + badgeLabel: `${uniquePaths.length} paths`, + description: 'This card merges recent activity from related worktrees and project paths.', + paths: uniquePaths.map((fullPath, index) => ({ + label: index === 0 ? 'Primary path' : `Related path ${index}`, + fullPath, + })), + }; +} + +export function adaptRecentProjectsSection({ + projects, + taskCountsByProject, + activeTeamsByProject, + tasksLoading, +}: RecentProjectsSectionAdapterInput): RecentProjectCardModel[] { + return projects.map((project) => ({ + id: project.id, + project, + name: project.name, + formattedPath: formatProjectPath(project.primaryPath), + lastActivityLabel: formatDistanceToNow(new Date(project.mostRecentActivity), { + addSuffix: true, + }), + providerIds: sortDashboardProviderIds(project.providerIds), + primaryBranch: project.primaryBranch, + taskCounts: sumTaskCounts(project, taskCountsByProject), + tasksLoading, + activeTeams: collectActiveTeams(project, activeTeamsByProject), + additionalPathCount: Math.max(0, project.associatedPaths.length - 1), + pathSummary: buildPathSummary(project), + })); +} diff --git a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts new file mode 100644 index 00000000..e1904882 --- /dev/null +++ b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts @@ -0,0 +1,125 @@ +import { useCallback } from 'react'; + +import { + type DashboardRecentProject, + type DashboardRecentProjectOpenTarget, +} from '@features/recent-projects/contracts'; +import { api } from '@renderer/api'; +import { useStore } from '@renderer/store'; +import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers'; +import { createLogger } from '@shared/utils/logger'; +import { useShallow } from 'zustand/react/shallow'; + +import { + buildSyntheticRepositoryGroup, + findMatchingWorktree, + type WorktreeMatch, +} from '../utils/navigation'; + +const logger = createLogger('Feature:RecentProjects:open'); + +export function useOpenRecentProject(): { + openRecentProject: (project: DashboardRecentProject) => Promise; + openProjectPath: (projectPath: string) => Promise; + selectProjectFolder: () => Promise; +} { + const { repositoryGroups, fetchRepositoryGroups, openTeamsTab } = useStore( + useShallow((state) => ({ + repositoryGroups: state.repositoryGroups, + fetchRepositoryGroups: state.fetchRepositoryGroups, + openTeamsTab: state.openTeamsTab, + })) + ); + + const navigateToMatch = useCallback( + (match: WorktreeMatch): void => { + useStore.setState(getWorktreeNavigationState(match.repoId, match.worktreeId)); + void useStore.getState().fetchSessionsInitial(match.worktreeId); + openTeamsTab(); + }, + [openTeamsTab] + ); + + const openSyntheticPath = useCallback( + async (path: string, associatedPaths: readonly string[]): Promise => { + const candidatePaths = associatedPaths.length > 0 ? associatedPaths : [path]; + + const initialMatch = findMatchingWorktree(repositoryGroups, candidatePaths); + if (initialMatch) { + navigateToMatch(initialMatch); + return; + } + + await fetchRepositoryGroups(); + const refreshedGroups = useStore.getState().repositoryGroups; + const refreshedMatch = findMatchingWorktree(refreshedGroups, candidatePaths); + if (refreshedMatch) { + navigateToMatch(refreshedMatch); + return; + } + + await api.config.addCustomProjectPath(path); + + useStore.setState((state) => ({ + repositoryGroups: [buildSyntheticRepositoryGroup(path), ...state.repositoryGroups], + })); + + const encodedId = path.replace(/[/\\]/g, '-'); + navigateToMatch({ repoId: encodedId, worktreeId: encodedId }); + }, + [fetchRepositoryGroups, navigateToMatch, repositoryGroups] + ); + + const openTarget = useCallback( + async ( + target: DashboardRecentProjectOpenTarget, + associatedPaths: readonly string[] + ): Promise => { + if (target.type === 'existing-worktree') { + navigateToMatch({ + repoId: target.repositoryId, + worktreeId: target.worktreeId, + }); + return; + } + + await openSyntheticPath(target.path, associatedPaths); + }, + [navigateToMatch, openSyntheticPath] + ); + + const openRecentProject = useCallback( + async (project: DashboardRecentProject): Promise => { + try { + await openTarget(project.openTarget, project.associatedPaths); + } catch (error) { + logger.error('Failed to open recent project', error); + } + }, + [openTarget] + ); + + const openProjectPath = useCallback(async (projectPath: string): Promise => { + try { + await api.openPath(projectPath, projectPath); + } catch (error) { + logger.error('Failed to open project path', error); + } + }, []); + + const selectProjectFolder = useCallback(async (): Promise => { + try { + const selectedPaths = await api.config.selectFolders(); + const selectedPath = selectedPaths[0]; + if (!selectedPath) { + return; + } + + await openSyntheticPath(selectedPath, [selectedPath]); + } catch (error) { + logger.error('Failed to select project folder', error); + } + }, [openSyntheticPath]); + + return { openRecentProject, openProjectPath, selectProjectFolder }; +} diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts new file mode 100644 index 00000000..8b4af403 --- /dev/null +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { type DashboardRecentProject } from '@features/recent-projects/contracts'; +import { api, isElectronMode } from '@renderer/api'; +import { useStore } from '@renderer/store'; +import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNormalize'; +import { useShallow } from 'zustand/react/shallow'; + +import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter'; + +import { useOpenRecentProject } from './useOpenRecentProject'; + +import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter'; +import type { TeamSummary } from '@shared/types'; + +const INITIAL_RECENT_PROJECTS = 11; +const LOAD_MORE_STEP = 8; + +function matchesSearch(project: DashboardRecentProject, query: string): boolean { + if (!query) { + return true; + } + + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return true; + } + + return ( + project.name.toLowerCase().includes(normalizedQuery) || + project.primaryPath.toLowerCase().includes(normalizedQuery) || + project.associatedPaths.some((projectPath) => + projectPath.toLowerCase().includes(normalizedQuery) + ) || + project.primaryBranch?.toLowerCase().includes(normalizedQuery) === true + ); +} + +export function useRecentProjectsSection( + searchQuery: string, + maxProjects = INITIAL_RECENT_PROJECTS +): { + cards: RecentProjectCardModel[]; + loading: boolean; + error: string | null; + canLoadMore: boolean; + isElectron: boolean; + loadMore: () => void; + reload: () => Promise; + openRecentProject: (project: DashboardRecentProject) => Promise; + openProjectPath: (projectPath: string) => Promise; + selectProjectFolder: () => Promise; +} { + const { globalTasks, globalTasksLoading, fetchAllTasks, teams } = useStore( + useShallow((state) => ({ + globalTasks: state.globalTasks, + globalTasksLoading: state.globalTasksLoading, + fetchAllTasks: state.fetchAllTasks, + teams: state.teams, + })) + ); + const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject(); + const [recentProjects, setRecentProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [visibleProjects, setVisibleProjects] = useState(maxProjects); + const [aliveTeams, setAliveTeams] = useState([]); + const hasFetchedTasksRef = useRef(false); + + const reload = useCallback(async (): Promise => { + setLoading(true); + setError(null); + try { + const projects = await api.getDashboardRecentProjects(); + setRecentProjects(projects); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + useEffect(() => { + if (recentProjects.length === 0 || hasFetchedTasksRef.current) { + return; + } + + hasFetchedTasksRef.current = true; + void fetchAllTasks(); + }, [fetchAllTasks, recentProjects.length]); + + useEffect(() => { + let cancelled = false; + + void api.teams + .aliveList() + .then((teamNames) => { + if (!cancelled) { + setAliveTeams(teamNames); + } + }) + .catch(() => undefined); + + return () => { + cancelled = true; + }; + }, [teams]); + + useEffect(() => { + if (!searchQuery.trim()) { + setVisibleProjects(maxProjects); + } + }, [maxProjects, searchQuery]); + + const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]); + + const activeTeamsByProject = useMemo(() => { + const aliveSet = new Set(aliveTeams); + const teamsByProject = new Map(); + + for (const team of teams) { + if (!team.projectPath || !aliveSet.has(team.teamName)) { + continue; + } + + const key = normalizePath(team.projectPath); + const existing = teamsByProject.get(key); + if (existing) { + existing.push(team); + } else { + teamsByProject.set(key, [team]); + } + } + + return teamsByProject; + }, [aliveTeams, teams]); + + const decoratedCards = useMemo( + () => + adaptRecentProjectsSection({ + projects: recentProjects, + taskCountsByProject, + activeTeamsByProject, + tasksLoading: globalTasksLoading, + }), + [activeTeamsByProject, globalTasksLoading, recentProjects, taskCountsByProject] + ); + + const filteredCards = useMemo( + () => decoratedCards.filter((card) => matchesSearch(card.project, searchQuery)), + [decoratedCards, searchQuery] + ); + + const cards = useMemo(() => { + if (searchQuery.trim()) { + return filteredCards; + } + + return filteredCards.slice(0, visibleProjects); + }, [filteredCards, searchQuery, visibleProjects]); + + return { + cards, + loading, + error, + canLoadMore: !searchQuery.trim() && filteredCards.length > visibleProjects, + isElectron: isElectronMode(), + loadMore: () => setVisibleProjects((current) => current + LOAD_MORE_STEP), + reload, + openRecentProject, + openProjectPath, + selectProjectFolder, + }; +} diff --git a/src/features/recent-projects/renderer/index.ts b/src/features/recent-projects/renderer/index.ts new file mode 100644 index 00000000..325e169a --- /dev/null +++ b/src/features/recent-projects/renderer/index.ts @@ -0,0 +1 @@ +export { RecentProjectsSection } from './ui/RecentProjectsSection'; diff --git a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx new file mode 100644 index 00000000..61fdbd3f --- /dev/null +++ b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx @@ -0,0 +1,221 @@ +import { useMemo, useState } from 'react'; + +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { projectColor } from '@renderer/utils/projectColor'; +import { FolderGit2, FolderOpen, GitBranch, Terminal } from 'lucide-react'; + +import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter'; + +interface RecentProjectCardProps { + card: RecentProjectCardModel; + onClick: () => void; + onOpenPath: () => void; +} + +export const RecentProjectCard = ({ + card, + onClick, + onOpenPath, +}: Readonly): React.JSX.Element => { + const color = useMemo(() => projectColor(card.name), [card.name]); + const [isHovered, setIsHovered] = useState(false); + + return ( + + ); +}; diff --git a/src/features/recent-projects/renderer/ui/RecentProjectsSection.tsx b/src/features/recent-projects/renderer/ui/RecentProjectsSection.tsx new file mode 100644 index 00000000..e351eac2 --- /dev/null +++ b/src/features/recent-projects/renderer/ui/RecentProjectsSection.tsx @@ -0,0 +1,169 @@ +import { Button } from '@renderer/components/ui/button'; +import { FolderGit2, FolderOpen, Search } from 'lucide-react'; + +import { useRecentProjectsSection } from '../hooks/useRecentProjectsSection'; + +import { RecentProjectCard } from './RecentProjectCard'; + +interface RecentProjectsSectionProps { + searchQuery: string; +} + +const titleWidths = [60, 66, 50, 55, 75, 45, 40, 65]; +const pathWidths = [80, 75, 85, 66, 70, 80, 60, 72]; + +function SelectProjectFolderCard({ + onClick, +}: Readonly<{ + onClick: () => void; +}>): React.JSX.Element { + return ( + + ); +} + +export const RecentProjectsSection = ({ + searchQuery, +}: Readonly): React.JSX.Element => { + const { + cards, + loading, + error, + canLoadMore, + isElectron, + loadMore, + reload, + openRecentProject, + openProjectPath, + selectProjectFolder, + } = useRecentProjectsSection(searchQuery); + + if (loading) { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ); + } + + if (error && cards.length === 0) { + return ( +
+
+ +
+
+

Failed to load projects

+

{error}

+
+ +
+ ); + } + + if (cards.length === 0 && searchQuery.trim()) { + return ( +
+
+ +
+

No projects found

+

No matches for "{searchQuery}"

+
+ ); + } + + if (cards.length === 0) { + return ( +
+
+ +
+

No recent projects found

+

+ Recent Claude and Codex activity will appear here. +

+
+ ); + } + + return ( +
+
+ {!searchQuery.trim() && isElectron && ( + void selectProjectFolder()} /> + )} + {cards.map((card) => ( + void openRecentProject(card.project)} + onOpenPath={() => void openProjectPath(card.project.primaryPath)} + /> + ))} +
+ + {canLoadMore && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/features/recent-projects/renderer/utils/navigation.ts b/src/features/recent-projects/renderer/utils/navigation.ts new file mode 100644 index 00000000..3dff676a --- /dev/null +++ b/src/features/recent-projects/renderer/utils/navigation.ts @@ -0,0 +1,51 @@ +import { normalizePath } from '@renderer/utils/pathNormalize'; + +import type { RepositoryGroup } from '@renderer/types/data'; + +export interface WorktreeMatch { + repoId: string; + worktreeId: string; +} + +export function findMatchingWorktree( + groups: RepositoryGroup[], + candidatePaths: readonly string[] +): WorktreeMatch | null { + const normalizedPaths = new Set(candidatePaths.map((projectPath) => normalizePath(projectPath))); + + for (const repo of groups) { + for (const worktree of repo.worktrees) { + if (normalizedPaths.has(normalizePath(worktree.path))) { + return { repoId: repo.id, worktreeId: worktree.id }; + } + } + } + + return null; +} + +export function buildSyntheticRepositoryGroup(selectedPath: string): RepositoryGroup { + const encodedId = selectedPath.replace(/[/\\]/g, '-'); + const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath; + const now = Date.now(); + + return { + id: encodedId, + identity: null, + worktrees: [ + { + id: encodedId, + path: selectedPath, + name: folderName, + isMainWorktree: true, + source: 'unknown', + sessions: [], + totalSessions: 0, + createdAt: now, + }, + ], + name: folderName, + mostRecentSession: undefined, + totalSessions: 0, + }; +} diff --git a/src/features/recent-projects/renderer/utils/projectDecorations.ts b/src/features/recent-projects/renderer/utils/projectDecorations.ts new file mode 100644 index 00000000..e22cd68b --- /dev/null +++ b/src/features/recent-projects/renderer/utils/projectDecorations.ts @@ -0,0 +1,11 @@ +import type { DashboardProviderId } from '@features/recent-projects/contracts'; + +const PROVIDER_ORDER: DashboardProviderId[] = ['anthropic', 'codex', 'gemini']; + +export function sortDashboardProviderIds( + providerIds: readonly DashboardProviderId[] +): DashboardProviderId[] { + return [...providerIds].sort( + (left, right) => PROVIDER_ORDER.indexOf(left) - PROVIDER_ORDER.indexOf(right) + ); +} diff --git a/src/main/http/index.ts b/src/main/http/index.ts index 49c64058..c5914839 100644 --- a/src/main/http/index.ts +++ b/src/main/http/index.ts @@ -5,6 +5,10 @@ * Each route file mirrors the corresponding IPC handler. */ +import { + type RecentProjectsFeatureFacade, + registerRecentProjectsHttp, +} from '@features/recent-projects/main'; import { createLogger } from '@shared/utils/logger'; import { registerConfigRoutes } from './config'; @@ -40,6 +44,7 @@ export interface HttpServices { subagentResolver: SubagentResolver; chunkBuilder: ChunkBuilder; dataCache: DataCache; + recentProjectsFeature?: RecentProjectsFeatureFacade; updaterService: UpdaterService; sshConnectionManager: SshConnectionManager; teamProvisioningService?: TeamProvisioningService; @@ -63,6 +68,9 @@ export function registerHttpRoutes( registerUtilityRoutes(app); registerSshRoutes(app, services.sshConnectionManager, sshModeSwitchCallback); registerUpdaterRoutes(app, services); + if (services.recentProjectsFeature) { + registerRecentProjectsHttp(app, services.recentProjectsFeature); + } registerEventRoutes(app); logger.info('All HTTP routes registered'); diff --git a/src/main/index.ts b/src/main/index.ts index 832baa3e..fb6f1855 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,6 +19,12 @@ process.env.UV_THREADPOOL_SIZE ??= '16'; // Sentry must be the first import to capture early errors. import './sentry'; +import { + createRecentProjectsFeature, + type RecentProjectsFeatureFacade, + registerRecentProjectsIpc, + removeRecentProjectsIpc, +} from '@features/recent-projects/main'; import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository'; import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor'; import { SchedulerService } from '@main/services/schedule/SchedulerService'; @@ -54,7 +60,7 @@ import { import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics'; import { parseInboxJson } from '@shared/utils/inboxNoise'; import { createLogger } from '@shared/utils/logger'; -import { app, BrowserWindow } from 'electron'; +import { app, BrowserWindow, ipcMain } from 'electron'; import { existsSync } from 'fs'; import { join } from 'path'; @@ -102,8 +108,8 @@ import { } from './utils/safeWebContentsSend'; import { syncTelemetryFlag } from './sentry'; import { - BoardTaskActivityRecordSource, BoardTaskActivityDetailService, + BoardTaskActivityRecordSource, BoardTaskActivityService, BoardTaskExactLogDetailService, BoardTaskExactLogsService, @@ -399,6 +405,7 @@ let contextRegistry: ServiceContextRegistry; let notificationManager: NotificationManager; let updaterService: UpdaterService; let sshConnectionManager: SshConnectionManager; +let recentProjectsFeature: RecentProjectsFeatureFacade; let teamDataService: TeamDataService; let teamProvisioningService: TeamProvisioningService; let cliInstallerService: CliInstallerService; @@ -927,6 +934,11 @@ async function initializeServices(): Promise { }); teamProvisioningService.setMainWindow(mainWindow); + recentProjectsFeature = createRecentProjectsFeature({ + getActiveContext: () => contextRegistry.getActive(), + getLocalContext: () => contextRegistry.get('local'), + logger: createLogger('Feature:RecentProjects'), + }); // startProcessHealthPolling() is deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. @@ -980,6 +992,7 @@ async function initializeServices(): Promise { crossTeamService, teamBackupService ?? undefined ); + registerRecentProjectsIpc(ipcMain, recentProjectsFeature); // Forward SSH state changes to renderer and HTTP SSE clients sshConnectionManager.on('state-change', (status: unknown) => { @@ -1028,6 +1041,7 @@ async function startHttpServer( subagentResolver: activeContext.subagentResolver, chunkBuilder: activeContext.chunkBuilder, dataCache: activeContext.dataCache, + recentProjectsFeature, updaterService, sshConnectionManager, teamProvisioningService, @@ -1119,6 +1133,7 @@ function shutdownServices(): void { // Remove IPC handlers removeIpcHandlers(); + removeRecentProjectsIpc(ipcMain); // Dispose backup service timers teamBackupService?.dispose(); diff --git a/src/main/standalone.ts b/src/main/standalone.ts index 39d44938..0a3e1083 100644 --- a/src/main/standalone.ts +++ b/src/main/standalone.ts @@ -16,6 +16,7 @@ // runtime which is unavailable in standalone (pure Node.js) mode. Standalone // error tracking can be added later with @sentry/node if needed. +import { createRecentProjectsFeature } from '@features/recent-projects/main'; import { createLogger } from '@shared/utils/logger'; import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider'; @@ -130,6 +131,11 @@ async function start(): Promise { // Create HTTP server httpServer = new HttpServer(); + const recentProjectsFeature = createRecentProjectsFeature({ + getActiveContext: () => localContext, + getLocalContext: () => localContext, + logger: createLogger('Feature:RecentProjects'), + }); // Wire file watcher events to SSE broadcast localContext.fileWatcher.on('file-change', (event: unknown) => { @@ -157,6 +163,7 @@ async function start(): Promise { subagentResolver: localContext.subagentResolver, chunkBuilder: localContext.chunkBuilder, dataCache: localContext.dataCache, + recentProjectsFeature, updaterService: updaterServiceStub, sshConnectionManager: sshConnectionManagerStub, }; diff --git a/src/preload/index.ts b/src/preload/index.ts index 79c12a8d..bab93351 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,3 +1,4 @@ +import { createRecentProjectsBridge } from '@features/recent-projects/preload'; import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants'; import { contextBridge, ipcRenderer, webUtils } from 'electron'; @@ -243,6 +244,7 @@ import type { ClaudeRootInfo, CliInstallationStatus, CliInstallerProgress, + CliProviderId, ConflictCheckResult, ContextInfo, CreateScheduleInput, @@ -448,6 +450,7 @@ ipcRenderer.on( // Expose protected methods that allow the renderer process to use // the ipcRenderer without exposing the entire object const electronAPI: ElectronAPI = { + ...createRecentProjectsBridge(), getAppVersion: () => ipcRenderer.invoke('get-app-version'), getProjects: () => ipcRenderer.invoke('get-projects'), getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId), @@ -1408,7 +1411,7 @@ const electronAPI: ElectronAPI = { getStatus: async (): Promise => { return invokeIpcWithResult(CLI_INSTALLER_GET_STATUS); }, - getProviderStatus: async (providerId: import('@shared/types').CliProviderId) => { + getProviderStatus: async (providerId: CliProviderId) => { return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId); }, install: async (): Promise => { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index b205a4f6..8320bc5b 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -6,6 +6,7 @@ * to run in a regular browser connected to an HTTP server. */ +import type { DashboardRecentProject } from '@features/recent-projects/contracts'; import type { AppConfig, AttachmentFileData, @@ -214,6 +215,9 @@ export class HttpAPIClient implements ElectronAPI { getAppVersion = (): Promise => this.get('/api/version'); + getDashboardRecentProjects = (): Promise => + this.get('/api/dashboard/recent-projects'); + getProjects = (): Promise => this.get('/api/projects'); getSessions = (projectId: string): Promise => @@ -1218,7 +1222,7 @@ export class HttpAPIClient implements ElectronAPI { }, }; - schedules = { + schedules: ElectronAPI['schedules'] = { list: async () => { console.warn('Schedules not available in browser mode'); return [] as Schedule[]; diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 3ec47ef8..ad1f4d68 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -1,53 +1,20 @@ /** - * DashboardView - Main dashboard with "Productivity Luxury" aesthetic. - * Inspired by Linear, Vercel, and Raycast design patterns. - * Features: - * - Subtle spotlight gradient - * - Centralized command search with inline project filtering - * - Border-first project cards with minimal backgrounds + * DashboardView - Main dashboard shell. + * Keeps only screen composition and delegates recent-projects logic to the feature slice. */ -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { api } from '@renderer/api'; -import { Button } from '@renderer/components/ui/button'; +import { RecentProjectsSection } from '@features/recent-projects/renderer'; import { useStore } from '@renderer/store'; -import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers'; -import { formatProjectPath } from '@renderer/utils/pathDisplay'; -import { - buildTaskCountsByProject, - normalizePath, - type TaskStatusCounts, -} from '@renderer/utils/pathNormalize'; -import { projectColor } from '@renderer/utils/projectColor'; import { formatShortcut } from '@renderer/utils/stringUtils'; -import { createLogger } from '@shared/utils/logger'; +import { Command, Search, Users } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -const logger = createLogger('Component:DashboardView'); -import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { formatDistanceToNow } from 'date-fns'; -import { - Command, - FolderGit2, - FolderOpen, - GitBranch, - GitFork, - Search, - Terminal, - Users, -} from 'lucide-react'; - import { CliStatusBanner } from './CliStatusBanner'; import { DashboardUpdateBanner } from './DashboardUpdateBanner'; import { TmuxStatusBanner } from './TmuxStatusBanner'; - -import type { RepositoryGroup } from '@renderer/types/data'; -import type { TeamSummary } from '@shared/types'; - -// ============================================================================= -// Command Search Input -// ============================================================================= +import { WebPreviewBanner } from './WebPreviewBanner'; interface CommandSearchProps { value: string; @@ -58,17 +25,16 @@ const CommandSearch = ({ value, onChange }: Readonly): React const [isFocused, setIsFocused] = useState(false); const inputRef = useRef(null); const { openCommandPalette, selectedProjectId } = useStore( - useShallow((s) => ({ - openCommandPalette: s.openCommandPalette, - selectedProjectId: s.selectedProjectId, + useShallow((state) => ({ + openCommandPalette: state.openCommandPalette, + selectedProjectId: state.selectedProjectId, })) ); - // Handle Cmd+K to open full command palette useEffect(() => { - const handleKeyDown = (e: KeyboardEvent): void => { - if ((e.metaKey || e.ctrlKey) && e.code === 'KeyK') { - e.preventDefault(); + const handleKeyDown = (event: KeyboardEvent): void => { + if ((event.metaKey || event.ctrlKey) && event.code === 'KeyK') { + event.preventDefault(); openCommandPalette(); } }; @@ -77,24 +43,24 @@ const CommandSearch = ({ value, onChange }: Readonly): React return () => window.removeEventListener('keydown', handleKeyDown); }, [openCommandPalette]); - // Focus search when the dashboard mounts (packaged Electron can skip native autoFocus). useLayoutEffect(() => { - const el = inputRef.current; - if (!el) { + const input = inputRef.current; + if (!input) { return; } - el.focus({ preventScroll: true }); - const t = window.setTimeout(() => { - if (document.activeElement !== el) { - el.focus({ preventScroll: true }); + + input.focus({ preventScroll: true }); + const timeoutId = window.setTimeout(() => { + if (document.activeElement !== input) { + input.focus({ preventScroll: true }); } }, 50); - return () => window.clearTimeout(t); + + return () => window.clearTimeout(timeoutId); }, []); return (
- {/* Search container with glow effect on focus */}
): React ref={inputRef} type="text" value={value} - onChange={(e) => onChange(e.target.value)} + onChange={(event) => onChange(event.target.value)} placeholder="Search projects..." className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-muted" onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} /> - {/* Keyboard shortcut badge - opens full command palette */} - ); -}; - -// ============================================================================= -// Ghost Card (New Project) -// ============================================================================= - -interface WorktreeMatch { - repoId: string; - worktreeId: string; -} - -function findMatchingWorktree( - groups: RepositoryGroup[], - selectedPath: string -): WorktreeMatch | null { - const norm = normalizePath(selectedPath); - for (const repo of groups) { - for (const worktree of repo.worktrees) { - if (normalizePath(worktree.path) === norm) { - return { repoId: repo.id, worktreeId: worktree.id }; - } - } - } - return null; -} - -const NewProjectCard = (): React.JSX.Element => { - const { repositoryGroups, fetchRepositoryGroups, openTeamsTab } = useStore( - useShallow((s) => ({ - repositoryGroups: s.repositoryGroups, - fetchRepositoryGroups: s.fetchRepositoryGroups, - openTeamsTab: s.openTeamsTab, - })) - ); - - const navigateToMatch = (match: WorktreeMatch): void => { - useStore.setState(getWorktreeNavigationState(match.repoId, match.worktreeId)); - void useStore.getState().fetchSessionsInitial(match.worktreeId); - }; - - const handleClick = async (): Promise => { - try { - const selectedPaths = await api.config.selectFolders(); - if (!selectedPaths || selectedPaths.length === 0) { - return; // User cancelled - } - - const selectedPath = selectedPaths[0]; - - // Match selected path against known repository worktrees (normalized comparison) - const match = findMatchingWorktree(repositoryGroups, selectedPath); - if (match) { - navigateToMatch(match); - openTeamsTab(); - return; - } - - // No match — refresh repository groups and retry - await fetchRepositoryGroups(); - const refreshedGroups = useStore.getState().repositoryGroups; - const matchAfterRefresh = findMatchingWorktree(refreshedGroups, selectedPath); - if (matchAfterRefresh) { - navigateToMatch(matchAfterRefresh); - openTeamsTab(); - return; - } - - // Still no match — create a synthetic group for this new folder and navigate to it. - // This allows launching teams in projects that don't have Claude sessions yet. - // Persist the path so it survives app restarts. - await api.config.addCustomProjectPath(selectedPath); - - const encodedId = selectedPath.replace(/[/\\]/g, '-'); - const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath; - const now = Date.now(); - - const syntheticGroup: RepositoryGroup = { - id: encodedId, - identity: null, - worktrees: [ - { - id: encodedId, - path: selectedPath, - name: folderName, - isMainWorktree: true, - source: 'unknown', - sessions: [], - totalSessions: 0, - createdAt: now, - }, - ], - name: folderName, - mostRecentSession: undefined, - totalSessions: 0, - }; - - useStore.setState((state) => ({ - repositoryGroups: [syntheticGroup, ...state.repositoryGroups], - })); - navigateToMatch({ repoId: encodedId, worktreeId: encodedId }); - openTeamsTab(); - } catch (error) { - logger.error('Error selecting folder:', error); - } - }; - - return ( - - ); -}; - -// ============================================================================= -// Projects Grid -// ============================================================================= - -interface ProjectsGridProps { - searchQuery: string; - maxProjects?: number; -} - -const INITIAL_RECENT_PROJECTS = 11; -const LOAD_MORE_STEP = 8; - -const ProjectsGrid = ({ - searchQuery, - maxProjects = INITIAL_RECENT_PROJECTS, -}: Readonly): React.JSX.Element => { - const { - repositoryGroups, - repositoryGroupsLoading, - repositoryGroupsError, - fetchRepositoryGroups, - selectRepository, - globalTasks, - globalTasksLoading, - fetchAllTasks, - openTeamsTab, - teams, - } = useStore( - useShallow((s) => ({ - repositoryGroups: s.repositoryGroups, - repositoryGroupsLoading: s.repositoryGroupsLoading, - repositoryGroupsError: s.repositoryGroupsError, - fetchRepositoryGroups: s.fetchRepositoryGroups, - selectRepository: s.selectRepository, - globalTasks: s.globalTasks, - globalTasksLoading: s.globalTasksLoading, - fetchAllTasks: s.fetchAllTasks, - openTeamsTab: s.openTeamsTab, - teams: s.teams, - })) - ); - - const hasFetchedTasksRef = React.useRef(false); - const [visibleProjects, setVisibleProjects] = useState(maxProjects); - const [aliveTeams, setAliveTeams] = useState([]); - - useEffect(() => { - if (repositoryGroups.length === 0 && !repositoryGroupsLoading && !repositoryGroupsError) { - void fetchRepositoryGroups(); - } - }, [ - repositoryGroups.length, - repositoryGroupsLoading, - repositoryGroupsError, - fetchRepositoryGroups, - ]); - - useEffect(() => { - if (repositoryGroups.length > 0 && !hasFetchedTasksRef.current && !repositoryGroupsLoading) { - hasFetchedTasksRef.current = true; - void fetchAllTasks(); - } - }, [repositoryGroups.length, repositoryGroupsLoading, fetchAllTasks]); - - // Fetch alive teams for online indicators - useEffect(() => { - let cancelled = false; - void api.teams - .aliveList() - .then((list) => { - if (!cancelled) setAliveTeams(list); - }) - .catch(() => undefined); - return () => { - cancelled = true; - }; - }, [teams]); - - // Map: normalizedProjectPath → alive TeamSummary[] - const activeTeamsByProject = useMemo(() => { - const aliveSet = new Set(aliveTeams); - const map = new Map(); - for (const team of teams) { - if (!aliveSet.has(team.teamName) || !team.projectPath) continue; - const key = normalizePath(team.projectPath); - const arr = map.get(key); - if (arr) { - arr.push(team); - } else { - map.set(key, [team]); - } - } - return map; - }, [teams, aliveTeams]); - - useEffect(() => { - if (!searchQuery.trim()) { - setVisibleProjects(maxProjects); - } - }, [searchQuery, maxProjects]); - - const taskCountsMap = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]); - - // Filter projects based on search query - const filteredRepos = useMemo(() => { - const query = searchQuery.toLowerCase().trim(); - return repositoryGroups.filter((repo) => { - if (!query) return true; - // Match by name - if (repo.name.toLowerCase().includes(query)) return true; - // Match by path - const path = repo.worktrees[0]?.path || ''; - if (path.toLowerCase().includes(query)) return true; - return false; - }); - }, [repositoryGroups, searchQuery]); - - const displayedRepos = useMemo(() => { - if (searchQuery.trim()) { - return filteredRepos; - } - return filteredRepos.slice(0, visibleProjects); - }, [filteredRepos, searchQuery, visibleProjects]); - - const canLoadMore = !searchQuery.trim() && filteredRepos.length > visibleProjects; - - if (repositoryGroupsLoading) { - // Organic widths per card — no repeating stamp - const titleWidths = [60, 66, 50, 55, 75, 45, 40, 65]; - const pathWidths = [80, 75, 85, 66, 70, 80, 60, 72]; - - return ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
- {/* Icon placeholder */} -
- {/* Title placeholder */} -
- {/* Path placeholder */} -
- {/* Meta row placeholder */} -
-
-
-
-
- ))} -
- ); - } - - if (repositoryGroupsError && repositoryGroups.length === 0) { - return ( -
-
- -
-
-

Failed to load projects

-

{repositoryGroupsError}

-
- -
- ); - } - - if (filteredRepos.length === 0 && searchQuery.trim()) { - return ( -
-
- -
-

No projects found

-

No matches for "{searchQuery}"

-
- ); - } - - if (repositoryGroups.length === 0) { - return ( -
-
- -
-

No projects found

-

~/.claude/projects/

-
- ); - } - - return ( -
-
- {!searchQuery.trim() && } - {displayedRepos.map((repo) => { - const counts = repo.worktrees.reduce( - (acc, wt) => { - const c = taskCountsMap.get(normalizePath(wt.path)); - if (c) { - acc.pending += c.pending; - acc.inProgress += c.inProgress; - acc.completed += c.completed; - } - return acc; - }, - { pending: 0, inProgress: 0, completed: 0 } - ); - // Collect active teams for this project (deduplicated by teamName) - const seen = new Set(); - const repoActiveTeams: TeamSummary[] = []; - for (const wt of repo.worktrees) { - const matched = activeTeamsByProject.get(normalizePath(wt.path)); - if (matched) { - for (const t of matched) { - if (!seen.has(t.teamName)) { - seen.add(t.teamName); - repoActiveTeams.push(t); - } - } - } - } - return ( - { - selectRepository(repo.id); - openTeamsTab(); - }} - isHighlighted={!!searchQuery.trim()} - taskCounts={globalTasksLoading ? undefined : counts} - tasksLoading={globalTasksLoading} - activeTeams={repoActiveTeams.length > 0 ? repoActiveTeams : undefined} - /> - ); - })} -
- - {canLoadMore && ( -
- -
- )} -
- ); -}; - -// ============================================================================= -// Dashboard View -// ============================================================================= - export const DashboardView = (): React.JSX.Element => { const [searchQuery, setSearchQuery] = useState(''); - const openTeamsTab = useStore((s) => s.openTeamsTab); + const openTeamsTab = useStore((state) => state.openTeamsTab); return (
- {/* Spotlight gradient background */}
); diff --git a/src/renderer/components/dashboard/WebPreviewBanner.tsx b/src/renderer/components/dashboard/WebPreviewBanner.tsx new file mode 100644 index 00000000..2b4467c3 --- /dev/null +++ b/src/renderer/components/dashboard/WebPreviewBanner.tsx @@ -0,0 +1,27 @@ +import { isElectronMode } from '@renderer/api'; +import { FlaskConical } from 'lucide-react'; + +export const WebPreviewBanner = (): React.JSX.Element | null => { + if (isElectronMode()) { + return null; + } + + return ( +
+ +
+

Web version is still in development

+

+ Some desktop features are not available in the browser yet. Project actions, integrations, + and live status data may be limited or not work as expected. +

+
+
+ ); +}; diff --git a/src/renderer/features/CLAUDE.md b/src/renderer/features/CLAUDE.md index 50d59279..ae859ae1 100644 --- a/src/renderer/features/CLAUDE.md +++ b/src/renderer/features/CLAUDE.md @@ -1,494 +1,19 @@ -# Features Directory — Architecture Guide +# Renderer Features - Legacy Note -All new renderer features live here. Each feature is a self-contained module following **Clean Architecture**, **SOLID**, and **class-based** patterns. +This directory contains older renderer-local slices and integrations. ---- +For new medium and large features, use the canonical standard instead: +- [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md) +- [Canonical feature root](../README.md) +- [Feature-local guidance](../CLAUDE.md) -## Quick Start +Default location for new feature work: +- `src/features//` -```bash -mkdir -p src/renderer/features//{ports,adapters,domain,ui,hooks,__tests__} -``` +Reference implementation: +- `src/features/recent-projects` ---- - -## Directory Structure - -### Full Feature - -``` -src/renderer/features// - ├── ports/ # Interfaces (contracts) — NO implementations - │ ├── DataPort.ts # What data the feature needs (input) - │ ├── EventPort.ts # Callbacks the feature fires (output) - │ ├── ConfigPort.ts# Configuration / theme overrides - │ └── types.ts # Domain value types for this feature - │ - ├── adapters/ # Bridge between project infrastructure and feature - │ └── Adapter.ts # Zustand store → DataPort (ONLY place that imports store) - │ - ├── domain/ # Business logic — pure TS, no React, no UI - │ ├── models/ # Domain entities and value objects (classes) - │ └── services/ # Domain services and use cases (classes) - │ - ├── ui/ # React components — presentation only - │ ├── View.tsx # Main component (orchestrator, entry point) - │ ├── Overlay.tsx # Full-screen overlay variant (if applicable) - │ └── Tab.tsx # Tab wrapper variant (if applicable) - │ - ├── hooks/ # React hooks — thin bridges to domain classes - │ └── use.ts # Instantiates domain services, subscribes to store - │ - ├── __tests__/ # Tests colocated with feature - │ ├── adapters.test.ts # Adapter mapping correctness - │ ├── domain.test.ts # Domain logic unit tests - │ └── ports.test.ts # Port type validation - │ - └── index.ts # Public API barrel — exports ONLY from ui/ and ports/ -``` - -### Minimal Feature (no domain layer) - -Small features that don't need business logic: - -``` -src/renderer/features// - ├── Adapter.ts # Zustand → feature data - ├── View.tsx # Main component - └── index.ts # Public API -``` - -### When to Extract a Workspace Package - -Some features benefit from a separate `packages//` workspace package: - -| Keep in `features/` | Extract to `packages/` | -|---------------------|----------------------| -| Tightly coupled to our UI | Reusable in other projects | -| Uses our Zustand store | Framework-agnostic (only React peer dep) | -| Small (<500 LOC) | Large (>1000 LOC of core logic) | -| No external deps | Has its own dependencies (d3-force, etc.) | - -Example: `agent-graph` has BOTH: -- `packages/agent-graph/` — Canvas rendering, d3-force simulation (reusable, no project coupling) -- `features/agent-graph/` — Adapter + overlay + tab (thin integration, imports from store) - ---- - -## Real-World Example: agent-graph - -``` -features/agent-graph/ ← Integration layer (3 files) - ├── useTeamGraphAdapter.ts ← Adapter: TeamData → GraphDataPort - ├── TeamGraphOverlay.tsx ← UI: full-screen overlay - └── TeamGraphTab.tsx ← UI: tab wrapper - -packages/agent-graph/ ← Isolated package (34 files) - ├── src/ports/ ← GraphDataPort, GraphEventPort, types - ├── src/canvas/ ← Canvas 2D renderers - ├── src/strategies/ ← Strategy pattern per node kind - ├── src/hooks/ ← Simulation, camera, interaction - └── src/components/ ← GraphView, GraphCanvas, Controls -``` - -The adapter (`useTeamGraphAdapter.ts`) is the **only file** that imports from `@renderer/store`. Everything else depends only on port interfaces. - ---- - -## SOLID Principles - -### S — Single Responsibility - -Each layer has exactly one reason to change: - -| Layer | Changes when... | Does NOT change when... | -|-------|----------------|------------------------| -| `ports/` | Feature contract changes | Store structure changes | -| `adapters/` | Store data model changes | Canvas rendering changes | -| `domain/` | Business rules change | React version updates | -| `ui/` | UX/layout changes | Data mapping changes | - -### O — Open-Closed - -Extend via new classes, never modify existing ones: - -```typescript -// ✅ New node kind = new class, zero changes to existing code -class ReviewNodeRenderer implements NodeRenderer { ... } - -// Register it — the registry and canvas loop don't change -NodeRendererRegistry.register(new ReviewNodeRenderer()); -``` - -### L — Liskov Substitution - -Any implementation of a port can replace another without breaking the feature: - -```typescript -// Both adapters satisfy GraphDataPort — feature works with either -class LiveTeamAdapter implements GraphDataPort { ... } // Real-time Zustand data -class MockTeamAdapter implements GraphDataPort { ... } // Static test data -class ReplayTeamAdapter implements GraphDataPort { ... } // Recorded session playback - -// Feature doesn't know or care which one it gets -const view = ; -``` - -### I — Interface Segregation - -Split ports by consumer. Each consumer depends only on what it needs: - -```typescript -// ✅ Three small ports -interface GraphDataPort { nodes: GraphNode[]; edges: GraphEdge[]; } -interface GraphEventPort { onNodeClick?(ref: DomainRef): void; } -interface GraphConfigPort { bloomIntensity?: number; showTasks?: boolean; } - -// ❌ One massive interface — forces every consumer to know about everything -interface GraphPort { - nodes: GraphNode[]; edges: GraphEdge[]; - onNodeClick?(ref: DomainRef): void; - bloomIntensity?: number; showTasks?: boolean; -} -``` - -### D — Dependency Inversion - -High-level modules (feature UI) depend on abstractions (ports), not on low-level modules (Zustand store). - -``` -UI → depends on → Port interface ← implemented by ← Adapter → depends on → Store - -Feature code never touches the store. The adapter translates in both directions. -``` - ---- - -## Class-Based Patterns - -Prefer **classes** over functions for domain logic, services, adapters, and stateful code. Use the **latest ECMAScript class features** (ES2024+). - -### Modern Class Syntax - -```typescript -class TeamGraphAdapter implements GraphDataPort { - // ─── ES private fields (NOT TypeScript `private`) ───────────── - readonly #store: StoreApi; - #cachedNodes: GraphNode[] = []; - #lastTeamName = ''; - - // ─── Static factory (prefer for complex initialization) ─────── - static create(store: StoreApi): TeamGraphAdapter { - return new TeamGraphAdapter(store); - } - - // ─── Constructor with DI ────────────────────────────────────── - constructor(store: StoreApi) { - this.#store = store; - } - - // ─── Accessors (get/set) ────────────────────────────────────── - get nodes(): readonly GraphNode[] { - return this.#cachedNodes; - } - - // ─── Public method (port contract) ──────────────────────────── - adapt(teamData: TeamData): GraphDataPort { - if (teamData.teamName === this.#lastTeamName) return this; - this.#lastTeamName = teamData.teamName; - this.#cachedNodes = this.#buildNodes(teamData); - return this; - } - - // ─── ES private method ──────────────────────────────────────── - #buildNodes(data: TeamData): GraphNode[] { - return data.members.map(m => ({ id: m.name, kind: 'member', ... })); - } - - // ─── Disposable (cleanup) ───────────────────────────────────── - [Symbol.dispose](): void { - this.#cachedNodes = []; - } -} -``` - -### Key Rules - -| Rule | Do | Don't | -|------|-----|-------| -| Private fields | `#field` (ES private) | `private field` (TS keyword) | -| Private methods | `#method()` | `private method()` | -| Readonly fields | `readonly #field` | Mutable when immutability intended | -| Static factory | `static create()` | Complex constructor logic | -| Disposal | `[Symbol.dispose]()` or `dispose()` | Forgetting cleanup | -| Type narrowing | `instanceof` checks | `as` casts | - -### When to Use Classes vs Functions - -| Use Case | Pattern | Why | -|----------|---------|-----| -| Domain models with state | **Class** | Encapsulation, lifecycle | -| Adapters (data mapping) | **Class** with caching | State for memoization | -| Services (business logic) | **Class** with DI | Testable, injectable | -| Canvas renderers | **Class** implementing strategy | Polymorphism | -| React components | **Function component** | React requires it | -| React hooks | **Function** | React requires it | -| Pure stateless utilities | **Function** | Simpler, no overhead | -| Constants | `as const` object | Immutable | - -### Dependency Injection - -Always inject dependencies through the constructor: - -```typescript -class FeatureService { - readonly #data: FeatureDataPort; - readonly #events: FeatureEventPort; - - constructor(data: FeatureDataPort, events: FeatureEventPort) { - this.#data = data; - this.#events = events; - } - - execute(): void { - const result = this.#data.getNodes(); - this.#events.onResult?.(result); - } -} - -// Wiring in a hook: -function useFeature(): FeatureService { - const adapter = useMemo(() => FeatureAdapter.create(store), [store]); - return useMemo(() => new FeatureService(adapter, eventHandler), [adapter]); -} -``` - -### Strategy Pattern - -```typescript -interface NodeRenderer { - readonly kind: string; - draw(ctx: CanvasRenderingContext2D, node: Node): void; - hitTest(node: Node, x: number, y: number): boolean; -} - -class MemberNodeRenderer implements NodeRenderer { - readonly kind = 'member'; - draw(ctx: CanvasRenderingContext2D, node: Node): void { /* ... */ } - hitTest(node: Node, x: number, y: number): boolean { /* ... */ } -} - -class NodeRendererRegistry { - readonly #renderers = new Map(); - - register(renderer: NodeRenderer): this { - this.#renderers.set(renderer.kind, renderer); - return this; - } - - get(kind: string): NodeRenderer | undefined { - return this.#renderers.get(kind); - } -} - -// Usage: -const registry = new NodeRendererRegistry() - .register(new MemberNodeRenderer()) - .register(new TaskNodeRenderer()); -``` - ---- - -## Error Handling - -```typescript -// Domain errors — typed, not string messages -class FeatureError extends Error { - constructor( - readonly code: 'INVALID_DATA' | 'RENDER_FAILED' | 'ADAPTER_ERROR', - message: string, - readonly cause?: unknown, - ) { - super(message); - this.name = 'FeatureError'; - } -} - -// In adapters — catch and wrap external errors -class FeatureAdapter { - adapt(data: unknown): FeatureDataPort { - try { - return this.#transform(data); - } catch (err) { - throw new FeatureError('ADAPTER_ERROR', 'Failed to adapt data', err); - } - } -} - -// In UI — catch at boundary, show fallback -function FeatureView({ data }: Props) { - // React error boundary or try/catch in event handlers - // Never let feature errors crash the host app -} -``` - ---- - -## Inter-Feature Communication - -Features MUST NOT import from each other directly. If two features need to share data: - -``` -Feature A → emits event → Host app (TeamDetailView) → passes data → Feature B -``` - -Pattern: use `CustomEvent` on `window` (same as keyboard shortcuts): - -```typescript -// Feature A fires: -window.dispatchEvent(new CustomEvent('feature-a:data-ready', { detail: { ... } })); - -// Host app listens and passes to Feature B via props/ports -``` - ---- - -## Testing - -Tests live in `__tests__/` inside the feature directory. - -```typescript -// __tests__/adapters.test.ts — test data mapping -describe('FeatureAdapter', () => { - it('maps TeamData members to GraphNodes', () => { - const adapter = new FeatureAdapter(); - const result = adapter.adapt(mockTeamData); - expect(result.nodes).toHaveLength(3); - expect(result.nodes[0].kind).toBe('lead'); - }); -}); - -// __tests__/domain.test.ts — test business logic -describe('SimulationService', () => { - it('applies orbit force to task nodes', () => { - const service = new SimulationService(mockConfig); - service.tick(0.016); - expect(service.nodes[0].x).toBeDefined(); - }); -}); -``` - -Run: `pnpm test -- --testPathPattern=features/` - ---- - -## Integration with Main App - -Features connect through minimal **registration points** in shared files: - -### Tab Registration (3 files) - -```typescript -// 1. src/renderer/types/tabs.ts — add to union -type: '...' | ''; - -// 2. src/renderer/components/layout/PaneContent.tsx — add route -{tab.type === '' && ( - - - -)} - -// 3. src/renderer/components/layout/SortableTab.tsx — add icon -: SomeIcon, -``` - -### Overlay Registration (1 file) - -```typescript -// In host component (e.g., TeamDetailView.tsx): -const FeatureOverlay = lazy(() => - import('@renderer/features//ui/FeatureOverlay') - .then(m => ({ default: m.FeatureOverlay })) -); -``` - -### Keyboard Shortcut (1 file) - -```typescript -// In useKeyboardShortcuts.ts: -if (key === '' && event.shiftKey && !event.altKey) { - window.dispatchEvent(new CustomEvent('toggle-', { detail })); -} -``` - ---- - -## Naming Conventions - -| Entity | Convention | Example | -|--------|-----------|---------| -| Feature directory | `kebab-case` | `agent-graph/` | -| Port interfaces | `PascalCase` + `Port` suffix | `GraphDataPort` | -| Domain classes | `PascalCase` | `SimulationService` | -| Adapter classes | `PascalCase` + `Adapter` suffix | `TeamGraphAdapter` | -| UI components | `PascalCase` | `GraphView`, `GraphOverlay` | -| Hooks | `camelCase` + `use` prefix | `useTeamGraphAdapter` | -| Test files | `.test.ts` | `adapters.test.ts` | -| Type files | `camelCase` or `types.ts` | `types.ts` | -| Barrel | `index.ts` | `index.ts` | - ---- - -## Existing Features - -| Feature | Path | Companion Package | Description | -|---------|------|-------------------|-------------| -| `agent-graph` | `features/agent-graph/` | `packages/agent-graph/` | Force-directed graph visualization | - ---- - -## Anti-Patterns - -```typescript -// ❌ Feature imports from another feature -import { X } from '@renderer/features/other-feature/X'; - -// ❌ UI component imports store directly (only adapters may) -import { useStore } from '@renderer/store'; - -// ❌ Feature imports from @renderer/components/* -import { KanbanBoard } from '@renderer/components/team/kanban/KanbanBoard'; - -// ❌ TypeScript `private` instead of ES #private -class Bad { private field = 1; } // Use: #field = 1; - -// ❌ Mutable global state -let globalCache = {}; - -// ❌ `any` or `as any` -const data = response as any; - -// ❌ God-class with mixed responsibilities -class FeatureManager { - fetchData() { ... } - renderUI() { ... } - handleClick() { ... } - saveToStorage() { ... } -} -``` - ---- - -## Checklist for New Feature PR - -- [ ] Feature lives in `src/renderer/features//` -- [ ] Port interfaces defined (`DataPort`, `EventPort` at minimum) -- [ ] Adapter is the ONLY file importing from `@renderer/store` -- [ ] No cross-feature imports -- [ ] Classes use ES `#private` fields, not TypeScript `private` -- [ ] `index.ts` exports only public API (ui components + port types) -- [ ] Integration points documented (which shared files were modified) -- [ ] Tests in `__tests__/` for adapter and domain logic -- [ ] Typecheck passes: `pnpm typecheck` -- [ ] Build passes: `pnpm build` +Keep `src/renderer/features/*` for: +- existing legacy slices +- renderer-only thin integrations +- work that does not introduce a new use case, transport boundary, or cross-process architecture diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 938e3bfc..afc2aae7 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -39,8 +39,8 @@ import type { import type { AddMemberRequest, AddTaskCommentRequest, - BoardTaskActivityDetailResult, AttachmentFileData, + BoardTaskActivityDetailResult, BoardTaskActivityEntry, BoardTaskExactLogDetailResult, BoardTaskExactLogSummariesResponse, @@ -89,6 +89,7 @@ import type { import type { TerminalAPI } from './terminal'; import type { TmuxAPI } from './tmux'; import type { WaterfallData } from './visualization'; +import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts'; import type { ConversationGroup, FileChangeEvent, @@ -721,7 +722,7 @@ export interface ReviewAPI { /** * Complete Electron API exposed to the renderer process via preload script. */ -export interface ElectronAPI { +export interface ElectronAPI extends RecentProjectsElectronApi { getAppVersion: () => Promise; getProjects: () => Promise; getSessions: (projectId: string) => Promise; diff --git a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts new file mode 100644 index 00000000..11273746 --- /dev/null +++ b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ListDashboardRecentProjectsUseCase } from '@features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase'; + +import type { ListDashboardRecentProjectsResponse } from '@features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse'; +import type { ListDashboardRecentProjectsOutputPort } from '@features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort'; +import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; +import type { RecentProjectsCachePort } from '@features/recent-projects/core/application/ports/RecentProjectsCachePort'; +import type { RecentProjectsSourcePort } from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; +import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; + +interface TestViewModel { + ids: string[]; + sources: string[]; +} + +function makeCandidate(overrides: Partial = {}): RecentProjectCandidate { + return { + identity: 'repo:alpha', + displayName: 'alpha', + primaryPath: '/workspace/alpha', + associatedPaths: ['/workspace/alpha'], + lastActivityAt: 1_000, + providerIds: ['anthropic'], + sourceKind: 'claude', + openTarget: { + type: 'existing-worktree', + repositoryId: 'repo-alpha', + worktreeId: 'wt-alpha', + }, + branchName: 'main', + ...overrides, + }; +} + +function createLogger(): LoggerPort & { + info: ReturnType; + warn: ReturnType; + error: ReturnType; +} { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +describe('ListDashboardRecentProjectsUseCase', () => { + it('returns cached data without calling sources or presenter', async () => { + const cached: TestViewModel = { ids: ['cached'], sources: ['cached'] }; + const cache: RecentProjectsCachePort = { + get: vi.fn().mockResolvedValue(cached), + set: vi.fn(), + }; + const output: ListDashboardRecentProjectsOutputPort = { + present: vi.fn(), + }; + const source: RecentProjectsSourcePort = { + list: vi.fn(), + }; + const logger = createLogger(); + + const useCase = new ListDashboardRecentProjectsUseCase({ + sources: [source], + cache, + output, + clock: { now: () => 1_000 }, + logger, + }); + + await expect(useCase.execute('recent-projects:cache')).resolves.toEqual(cached); + expect(source.list).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('merges successful sources, degrades failed sources, and caches presenter output', async () => { + const cache: RecentProjectsCachePort = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + }; + const output: ListDashboardRecentProjectsOutputPort = { + present: vi.fn((response: ListDashboardRecentProjectsResponse) => ({ + ids: response.projects.map((project) => project.identity), + sources: response.projects.map((project) => project.source), + })), + }; + const sources: RecentProjectsSourcePort[] = [ + { + list: vi.fn().mockResolvedValue([ + makeCandidate({ + identity: 'repo:alpha', + lastActivityAt: 2_000, + providerIds: ['anthropic'], + sourceKind: 'claude', + }), + ]), + }, + { + list: vi.fn().mockRejectedValue(new Error('codex unavailable')), + }, + { + list: vi.fn().mockResolvedValue([ + makeCandidate({ + identity: 'repo:alpha', + lastActivityAt: 4_000, + providerIds: ['codex'], + sourceKind: 'codex', + openTarget: { + type: 'synthetic-path', + path: '/workspace/alpha', + }, + }), + ]), + }, + ]; + const logger = createLogger(); + let now = 10_000; + + const useCase = new ListDashboardRecentProjectsUseCase({ + sources, + cache, + output, + clock: { + now: () => { + const current = now; + now += 250; + return current; + }, + }, + logger, + }); + + const result = await useCase.execute('recent-projects:fresh'); + + expect(result).toEqual({ + ids: ['repo:alpha'], + sources: ['mixed'], + }); + expect(output.present).toHaveBeenCalledWith({ + projects: [ + expect.objectContaining({ + identity: 'repo:alpha', + source: 'mixed', + providerIds: ['anthropic', 'codex'], + lastActivityAt: 4_000, + openTarget: { + type: 'existing-worktree', + repositoryId: 'repo-alpha', + worktreeId: 'wt-alpha', + }, + }), + ], + }); + expect(cache.set).toHaveBeenCalledWith('recent-projects:fresh', result, 1_500); + expect(logger.warn).toHaveBeenCalledWith('recent-projects source failed', { + sourceId: 'source-1', + sourceIndex: 1, + error: 'codex unavailable', + }); + expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', { + cacheKey: 'recent-projects:fresh', + count: 1, + degradedSources: 1, + cacheTtlMs: 1_500, + durationMs: 250, + }); + }); + + it('returns fast sources without waiting for a timed out source', async () => { + vi.useFakeTimers(); + try { + const cache: RecentProjectsCachePort = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + }; + const output: ListDashboardRecentProjectsOutputPort = { + present: vi.fn((response: ListDashboardRecentProjectsResponse) => ({ + ids: response.projects.map((project) => project.identity), + sources: response.projects.map((project) => project.source), + })), + }; + const slowSource: RecentProjectsSourcePort = { + sourceId: 'codex', + timeoutMs: 50, + list: vi.fn( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve([ + makeCandidate({ + identity: 'repo:codex-only', + providerIds: ['codex'], + sourceKind: 'codex', + openTarget: { + type: 'synthetic-path', + path: '/workspace/codex-only', + }, + }), + ]), + 500 + ); + }) + ), + }; + const fastSource: RecentProjectsSourcePort = { + sourceId: 'claude', + list: vi.fn().mockResolvedValue([ + makeCandidate({ + identity: 'repo:fast', + providerIds: ['anthropic'], + sourceKind: 'claude', + }), + ]), + }; + const logger = createLogger(); + const useCase = new ListDashboardRecentProjectsUseCase({ + sources: [fastSource, slowSource], + cache, + output, + clock: { now: () => 2_000 }, + logger, + }); + + const execution = useCase.execute('recent-projects:timeout'); + await vi.advanceTimersByTimeAsync(60); + + await expect(execution).resolves.toEqual({ + ids: ['repo:fast'], + sources: ['claude'], + }); + expect(logger.warn).toHaveBeenCalledWith('recent-projects source timed out', { + sourceId: 'codex', + sourceIndex: 1, + timeoutMs: 50, + }); + expect(cache.set).toHaveBeenCalledWith( + 'recent-projects:timeout', + { ids: ['repo:fast'], sources: ['claude'] }, + 1_500 + ); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts b/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts new file mode 100644 index 00000000..fd6e655e --- /dev/null +++ b/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeRecentProjectCandidates } from '@features/recent-projects/core/domain/policies/mergeRecentProjectCandidates'; + +import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; + +function makeCandidate(overrides: Partial = {}): RecentProjectCandidate { + return { + identity: 'repo:alpha', + displayName: 'alpha', + primaryPath: '/workspace/alpha', + associatedPaths: ['/workspace/alpha'], + lastActivityAt: 1_000, + providerIds: ['anthropic'], + sourceKind: 'claude', + openTarget: { + type: 'existing-worktree', + repositoryId: 'repo-alpha', + worktreeId: 'wt-alpha', + }, + branchName: 'main', + ...overrides, + }; +} + +describe('mergeRecentProjectCandidates', () => { + it('merges providers, keeps latest activity, and prefers existing worktree targets', () => { + const result = mergeRecentProjectCandidates([ + makeCandidate({ + associatedPaths: ['/workspace/alpha', '/workspace/alpha-main'], + lastActivityAt: 2_000, + }), + makeCandidate({ + providerIds: ['codex'], + sourceKind: 'codex', + associatedPaths: ['/workspace/alpha-feature'], + lastActivityAt: 3_000, + openTarget: { + type: 'synthetic-path', + path: '/workspace/alpha', + }, + }), + ]); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + identity: 'repo:alpha', + source: 'mixed', + lastActivityAt: 3_000, + providerIds: ['anthropic', 'codex'], + openTarget: { + type: 'existing-worktree', + repositoryId: 'repo-alpha', + worktreeId: 'wt-alpha', + }, + branchName: 'main', + }); + expect(result[0].associatedPaths).toEqual([ + '/workspace/alpha', + '/workspace/alpha-main', + '/workspace/alpha-feature', + ]); + }); + + it('drops invalid candidates and clears conflicting branches', () => { + const result = mergeRecentProjectCandidates([ + makeCandidate({ + identity: '', + lastActivityAt: 1_000, + }), + makeCandidate({ + identity: 'repo:beta', + displayName: 'beta', + primaryPath: '/workspace/beta', + associatedPaths: ['/workspace/beta'], + branchName: 'main', + }), + makeCandidate({ + identity: 'repo:beta', + displayName: 'beta', + primaryPath: '/workspace/beta', + associatedPaths: ['/workspace/beta-worktree'], + branchName: 'release', + lastActivityAt: 5_000, + }), + makeCandidate({ + identity: 'repo:ignored', + displayName: 'ignored', + primaryPath: '/workspace/ignored', + associatedPaths: ['/workspace/ignored'], + lastActivityAt: 0, + }), + ]); + + expect(result).toHaveLength(1); + expect(result[0].identity).toBe('repo:beta'); + expect(result[0].branchName).toBeUndefined(); + }); +}); diff --git a/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts b/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts new file mode 100644 index 00000000..90e65166 --- /dev/null +++ b/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { adaptRecentProjectsSection } from '@features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter'; + +import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { TeamSummary } from '@shared/types'; + +describe('adaptRecentProjectsSection', () => { + it('sorts providers, aggregates decorations, and builds a path summary for merged cards', () => { + const project: DashboardRecentProject = { + id: 'repo:alpha', + name: 'alpha', + primaryPath: '/Users/test/alpha', + associatedPaths: ['/Users/test/alpha', '/Users/test/alpha-worktree'], + mostRecentActivity: Date.parse('2026-04-14T12:00:00Z'), + providerIds: ['codex', 'anthropic'], + source: 'mixed', + openTarget: { + type: 'existing-worktree', + repositoryId: 'repo-alpha', + worktreeId: 'wt-alpha', + }, + primaryBranch: 'main', + }; + + const activeTeam: TeamSummary = { + teamName: 'alpha-team', + displayName: 'Alpha Team', + description: 'Alpha team', + memberCount: 0, + taskCount: 0, + projectPath: '/Users/test/alpha-worktree', + lastActivity: null, + }; + + const cards = adaptRecentProjectsSection({ + projects: [project], + taskCountsByProject: new Map([ + ['/users/test/alpha', { pending: 1, inProgress: 2, completed: 3 }], + ['/users/test/alpha-worktree', { pending: 4, inProgress: 5, completed: 6 }], + ]), + activeTeamsByProject: new Map([ + ['/users/test/alpha', [activeTeam]], + ['/users/test/alpha-worktree', [activeTeam]], + ]), + tasksLoading: false, + }); + + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + providerIds: ['anthropic', 'codex'], + taskCounts: { pending: 5, inProgress: 7, completed: 9 }, + additionalPathCount: 1, + primaryBranch: 'main', + activeTeams: [activeTeam], + pathSummary: { + badgeLabel: '2 paths', + description: + 'This card merges recent activity from related worktrees and project paths.', + paths: [ + { + label: 'Primary path', + fullPath: '/Users/test/alpha', + }, + { + label: 'Related path 1', + fullPath: '/Users/test/alpha-worktree', + }, + ], + }, + }); + }); +}); diff --git a/test/features/recent-projects/renderer/utils/navigation.test.ts b/test/features/recent-projects/renderer/utils/navigation.test.ts new file mode 100644 index 00000000..61da4f9b --- /dev/null +++ b/test/features/recent-projects/renderer/utils/navigation.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + buildSyntheticRepositoryGroup, + findMatchingWorktree, +} from '@features/recent-projects/renderer/utils/navigation'; + +import type { RepositoryGroup } from '@renderer/types/data'; + +describe('recent-projects navigation utils', () => { + it('finds a matching worktree across normalized candidate paths', () => { + const groups: RepositoryGroup[] = [ + { + id: 'repo-alpha', + identity: null, + name: 'alpha', + mostRecentSession: 1_000, + totalSessions: 2, + worktrees: [ + { + id: 'wt-alpha', + path: '/Users/test/Alpha', + name: 'alpha', + isMainWorktree: true, + source: 'unknown', + sessions: [], + totalSessions: 2, + createdAt: 1_000, + }, + ], + }, + ]; + + expect( + findMatchingWorktree(groups, ['/users/test/alpha/', '/users/test/other']) + ).toEqual({ + repoId: 'repo-alpha', + worktreeId: 'wt-alpha', + }); + }); + + it('builds a synthetic repository group with encoded repo and worktree ids', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-14T12:00:00Z')); + + const group = buildSyntheticRepositoryGroup('/Users/test/dev/my project'); + + expect(group.id).toBe('-Users-test-dev-my project'); + expect(group.name).toBe('my project'); + expect(group.worktrees).toHaveLength(1); + expect(group.worktrees[0]).toMatchObject({ + id: '-Users-test-dev-my project', + path: '/Users/test/dev/my project', + name: 'my project', + isMainWorktree: true, + totalSessions: 0, + }); + expect(group.worktrees[0].createdAt).toBe(Date.parse('2026-04-14T12:00:00Z')); + + vi.useRealTimers(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d1b421bc..029359fe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "noEmit": true, "baseUrl": ".", "paths": { + "@features/*": ["./src/features/*"], "@main/*": ["./src/main/*"], "@renderer/*": ["./src/renderer/*"], "@preload/*": ["./src/preload/*"], diff --git a/tsconfig.node.json b/tsconfig.node.json index 65b03beb..95df2128 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -12,11 +12,19 @@ "noEmit": true, "baseUrl": ".", "paths": { + "@features/*": ["./src/features/*"], "@main/*": ["./src/main/*"], "@preload/*": ["./src/preload/*"], "@shared/*": ["./src/shared/*"] }, "types": ["node"] }, - "include": ["electron.vite.config.ts", "src/main/**/*", "src/preload/**/*"] + "include": [ + "electron.vite.config.ts", + "src/main/**/*", + "src/preload/**/*", + "src/features/*/contracts/**/*", + "src/features/*/main/**/*", + "src/features/*/preload/**/*" + ] } diff --git a/vitest.config.ts b/vitest.config.ts index 6d1ad570..92574741 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ }, resolve: { alias: { + '@features': resolve(__dirname, 'src/features'), '@shared': resolve(__dirname, 'src/shared'), '@main': resolve(__dirname, 'src/main'), '@renderer': resolve(__dirname, 'src/renderer'),