feat: add dashboard recent projects feature slice
This commit is contained in:
parent
ce0eb75429
commit
fad89e71da
63 changed files with 4411 additions and 1251 deletions
16
AGENTS.md
Normal file
16
AGENTS.md
Normal file
|
|
@ -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/<feature-name>/`
|
||||||
|
- 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).
|
||||||
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -57,8 +57,21 @@ Use path aliases for imports:
|
||||||
- `@preload/*` → `src/preload/*`
|
- `@preload/*` → `src/preload/*`
|
||||||
|
|
||||||
## Features Architecture
|
## Features Architecture
|
||||||
**All new features MUST be created in `src/renderer/features/<feature-name>/`.**
|
**All new medium and large features should follow the canonical slice standard in [`docs/FEATURE_ARCHITECTURE_STANDARD.md`](docs/FEATURE_ARCHITECTURE_STANDARD.md).**
|
||||||
See `src/renderer/features/CLAUDE.md` for the full guide on creating features with Clean Architecture, SOLID, and class-based patterns.
|
|
||||||
|
Default location:
|
||||||
|
- `src/features/<feature-name>/`
|
||||||
|
|
||||||
|
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
|
## Data Sources
|
||||||
~/.claude/projects/{encoded-path}/*.jsonl - Session files
|
~/.claude/projects/{encoded-path}/*.jsonl - Session files
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -94,6 +94,7 @@ No prerequisites - the app can detect supported runtimes/providers and guide set
|
||||||
- [Comparison](#comparison)
|
- [Comparison](#comparison)
|
||||||
- [Quick start](#quick-start)
|
- [Quick start](#quick-start)
|
||||||
- [FAQ](#faq)
|
- [FAQ](#faq)
|
||||||
|
- [Developer architecture docs](#developer-architecture-docs)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
- [Tech stack](#tech-stack)
|
- [Tech stack](#tech-stack)
|
||||||
- [Build for distribution](#build-for-distribution)
|
- [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
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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
|
||||||
|
|
||||||
|
|
|
||||||
297
docs/FEATURE_ARCHITECTURE_STANDARD.md
Normal file
297
docs/FEATURE_ARCHITECTURE_STANDARD.md
Normal file
|
|
@ -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/<feature-name>/
|
||||||
|
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/<feature>/contracts`
|
||||||
|
- `@features/<feature>/main`
|
||||||
|
- `@features/<feature>/preload`
|
||||||
|
- `@features/<feature>/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
|
||||||
1260
docs/research/codex-dashboard-recent-projects-plan.md
Normal file
1260
docs/research/codex-dashboard-recent-projects-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -73,6 +73,7 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
'@features': resolve(__dirname, 'src/features'),
|
||||||
'@main': resolve(__dirname, 'src/main'),
|
'@main': resolve(__dirname, 'src/main'),
|
||||||
'@shared': resolve(__dirname, 'src/shared'),
|
'@shared': resolve(__dirname, 'src/shared'),
|
||||||
'@preload': resolve(__dirname, 'src/preload')
|
'@preload': resolve(__dirname, 'src/preload')
|
||||||
|
|
@ -111,6 +112,7 @@ export default defineConfig({
|
||||||
preload: {
|
preload: {
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
'@features': resolve(__dirname, 'src/features'),
|
||||||
'@preload': resolve(__dirname, 'src/preload'),
|
'@preload': resolve(__dirname, 'src/preload'),
|
||||||
'@shared': resolve(__dirname, 'src/shared'),
|
'@shared': resolve(__dirname, 'src/shared'),
|
||||||
'@main': resolve(__dirname, 'src/main')
|
'@main': resolve(__dirname, 'src/main')
|
||||||
|
|
@ -141,6 +143,7 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
'@features': resolve(__dirname, 'src/features'),
|
||||||
'@renderer': resolve(__dirname, 'src/renderer'),
|
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||||
'@shared': resolve(__dirname, 'src/shared'),
|
'@shared': resolve(__dirname, 'src/shared'),
|
||||||
'@main': resolve(__dirname, 'src/main'),
|
'@main': resolve(__dirname, 'src/main'),
|
||||||
|
|
|
||||||
135
eslint.config.js
135
eslint.config.js
|
|
@ -78,7 +78,7 @@ export default defineConfig([
|
||||||
// Import plugin configuration - Renderer (uses tsconfig.json)
|
// Import plugin configuration - Renderer (uses tsconfig.json)
|
||||||
{
|
{
|
||||||
name: 'import-plugin-renderer',
|
name: 'import-plugin-renderer',
|
||||||
files: ['src/renderer/**/*.{ts,tsx}'],
|
files: ['src/renderer/**/*.{ts,tsx}', 'src/features/**/*.{ts,tsx}'],
|
||||||
plugins: {
|
plugins: {
|
||||||
import: importPlugin,
|
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
|
// Module boundaries - Enforce Electron three-process architecture
|
||||||
{
|
{
|
||||||
name: 'module-boundaries',
|
name: 'module-boundaries',
|
||||||
|
|
@ -548,7 +679,7 @@ export default defineConfig([
|
||||||
|
|
||||||
// === Import Restrictions ===
|
// === Import Restrictions ===
|
||||||
// Note: boundaries/element-types handles main/renderer separation
|
// Note: boundaries/element-types handles main/renderer separation
|
||||||
'no-restricted-imports': 'warn',
|
'no-restricted-imports': 'off',
|
||||||
|
|
||||||
// === Mutation Prevention ===
|
// === Mutation Prevention ===
|
||||||
'no-param-reassign': 'warn',
|
'no-param-reassign': 'warn',
|
||||||
|
|
|
||||||
19
src/features/README.md
Normal file
19
src/features/README.md
Normal file
|
|
@ -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/<feature-name>/` 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).
|
||||||
5
src/features/recent-projects/contracts/api.ts
Normal file
5
src/features/recent-projects/contracts/api.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { DashboardRecentProject } from './dto';
|
||||||
|
|
||||||
|
export interface RecentProjectsElectronApi {
|
||||||
|
getDashboardRecentProjects(): Promise<DashboardRecentProject[]>;
|
||||||
|
}
|
||||||
2
src/features/recent-projects/contracts/channels.ts
Normal file
2
src/features/recent-projects/contracts/channels.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const GET_DASHBOARD_RECENT_PROJECTS = 'get-dashboard-recent-projects';
|
||||||
|
export const DASHBOARD_RECENT_PROJECTS_ROUTE = '/api/dashboard/recent-projects';
|
||||||
19
src/features/recent-projects/contracts/dto.ts
Normal file
19
src/features/recent-projects/contracts/dto.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
3
src/features/recent-projects/contracts/index.ts
Normal file
3
src/features/recent-projects/contracts/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export type * from './api';
|
||||||
|
export * from './channels';
|
||||||
|
export type * from './dto';
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { RecentProjectAggregate } from '../../domain/models/RecentProjectAggregate';
|
||||||
|
|
||||||
|
export interface ListDashboardRecentProjectsResponse {
|
||||||
|
projects: RecentProjectAggregate[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface ClockPort {
|
||||||
|
now(): number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { ListDashboardRecentProjectsResponse } from '../models/ListDashboardRecentProjectsResponse';
|
||||||
|
|
||||||
|
export interface ListDashboardRecentProjectsOutputPort<TViewModel> {
|
||||||
|
present(response: ListDashboardRecentProjectsResponse): TViewModel;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export interface LoggerPort {
|
||||||
|
info(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
warn(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
error(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface RecentProjectsCachePort<T> {
|
||||||
|
get(key: string): Promise<T | null>;
|
||||||
|
set(key: string, value: T, ttlMs: number): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { RecentProjectCandidate } from '../../domain/models/RecentProjectCandidate';
|
||||||
|
|
||||||
|
export interface RecentProjectsSourcePort {
|
||||||
|
readonly sourceId?: string;
|
||||||
|
readonly timeoutMs?: number;
|
||||||
|
list(): Promise<RecentProjectCandidate[]>;
|
||||||
|
}
|
||||||
|
|
@ -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<TViewModel> {
|
||||||
|
sources: RecentProjectsSourcePort[];
|
||||||
|
cache: RecentProjectsCachePort<TViewModel>;
|
||||||
|
output: ListDashboardRecentProjectsOutputPort<TViewModel>;
|
||||||
|
clock: ClockPort;
|
||||||
|
logger: LoggerPort;
|
||||||
|
cacheTtlMs?: number;
|
||||||
|
degradedCacheTtlMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListDashboardRecentProjectsUseCase<TViewModel> {
|
||||||
|
readonly #cacheTtlMs: number;
|
||||||
|
readonly #degradedCacheTtlMs: number;
|
||||||
|
|
||||||
|
constructor(private readonly deps: ListDashboardRecentProjectsDeps<TViewModel>) {
|
||||||
|
this.#cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
||||||
|
this.#degradedCacheTtlMs = deps.degradedCacheTtlMs ?? DEFAULT_DEGRADED_CACHE_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(cacheKey: string): Promise<TViewModel> {
|
||||||
|
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<SourceLoadResult> {
|
||||||
|
const sourceId = source.sourceId ?? `source-${sourceIndex}`;
|
||||||
|
if (!source.timeoutMs || source.timeoutMs <= 0) {
|
||||||
|
return this.#loadSourceWithoutTimeout(source, sourceId, sourceIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | 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<SourceLoadResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export type ProviderId = 'anthropic' | 'codex' | 'gemini';
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export type RecentProjectOpenTarget =
|
||||||
|
| { type: 'existing-worktree'; repositoryId: string; worktreeId: string }
|
||||||
|
| { type: 'synthetic-path'; path: string };
|
||||||
|
|
@ -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<string>();
|
||||||
|
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<string, RecentProjectCandidate[]>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -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<DashboardRecentProject[]> => {
|
||||||
|
try {
|
||||||
|
return await feature.listDashboardRecentProjects();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load dashboard recent projects via HTTP', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<RecentProjectCandidate[]> {
|
||||||
|
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<RepositoryGroup[]> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<string | null>;
|
||||||
|
appServerClient: CodexAppServerClient;
|
||||||
|
identityResolver: RecentProjectIdentityResolver;
|
||||||
|
logger: LoggerPort;
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(): Promise<RecentProjectCandidate[]> {
|
||||||
|
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<CodexThreadSummary[]> {
|
||||||
|
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<CodexThreadSummary[]>,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<CodexThreadSummary[]> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<CodexThreadSummary[]>((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<CodexThreadSummary[]> {
|
||||||
|
try {
|
||||||
|
return await this.#listThreadsSegment(binaryPath, segment, options);
|
||||||
|
} catch (error) {
|
||||||
|
return this.#unwrapThreadListError(error, segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #toCandidate(thread: CodexThreadSummary): Promise<RecentProjectCandidate | null> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<DashboardRecentProject[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecentProjectsFeature(deps: {
|
||||||
|
getActiveContext: () => ServiceContext;
|
||||||
|
getLocalContext: () => ServiceContext | undefined;
|
||||||
|
logger: LoggerPort;
|
||||||
|
}): RecentProjectsFeatureFacade {
|
||||||
|
const cache = new InMemoryRecentProjectsCache<DashboardRecentProject[]>();
|
||||||
|
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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
7
src/features/recent-projects/main/index.ts
Normal file
7
src/features/recent-projects/main/index.ts
Normal file
|
|
@ -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';
|
||||||
31
src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts
vendored
Normal file
31
src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { RecentProjectsCachePort } from '../../../core/application/ports/RecentProjectsCachePort';
|
||||||
|
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
value: T;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InMemoryRecentProjectsCache<T> implements RecentProjectsCachePort<T> {
|
||||||
|
readonly #entries = new Map<string, CacheEntry<T>>();
|
||||||
|
|
||||||
|
async get(key: string): Promise<T | null> {
|
||||||
|
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<void> {
|
||||||
|
this.#entries.set(key, {
|
||||||
|
value,
|
||||||
|
expiresAt: Date.now() + ttlMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<CodexThreadSummary[]> {
|
||||||
|
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<ThreadListResponse>(
|
||||||
|
'thread/list',
|
||||||
|
{
|
||||||
|
archived: options.archived,
|
||||||
|
limit: options.limit,
|
||||||
|
sortKey: 'updated_at',
|
||||||
|
},
|
||||||
|
requestTimeoutMs
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data ?? [];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<string | null> | null = null;
|
||||||
|
|
||||||
|
async function verifyBinary(candidate: string): Promise<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<T> {
|
||||||
|
id?: number;
|
||||||
|
result?: T;
|
||||||
|
error?: JsonRpcErrorPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JsonRpcSession {
|
||||||
|
request<TResult>(method: string, params?: unknown, timeoutMs?: number): Promise<TResult>;
|
||||||
|
notify(method: string, params?: unknown): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const timeoutPromise = new Promise<T>((_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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JsonRpcStdioClient {
|
||||||
|
constructor(private readonly logger: LoggerPort) {}
|
||||||
|
|
||||||
|
async withSession<T>(
|
||||||
|
options: {
|
||||||
|
binaryPath: string;
|
||||||
|
args: string[];
|
||||||
|
requestTimeoutMs?: number;
|
||||||
|
totalTimeoutMs?: number;
|
||||||
|
label: string;
|
||||||
|
},
|
||||||
|
handler: (session: JsonRpcSession) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>(
|
||||||
|
binaryPath: string,
|
||||||
|
args: string[],
|
||||||
|
requestTimeoutMs: number,
|
||||||
|
handler: (session: JsonRpcSession) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
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<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
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<unknown>;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(line) as JsonRpcResponse<unknown>;
|
||||||
|
} 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: <TResult>(
|
||||||
|
method: string,
|
||||||
|
params?: unknown,
|
||||||
|
timeoutMs = requestTimeoutMs
|
||||||
|
): Promise<TResult> =>
|
||||||
|
new Promise<TResult>((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<void> => {
|
||||||
|
if (!child.stdin) {
|
||||||
|
throw new Error('JSON-RPC stdin is not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<RecentProjectIdentity | null> {
|
||||||
|
const identity = await gitIdentityResolver.resolveIdentity(projectPath);
|
||||||
|
if (!identity) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: identity.id,
|
||||||
|
name: identity.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/features/recent-projects/preload/index.ts
Normal file
1
src/features/recent-projects/preload/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { createRecentProjectsBridge } from './createRecentProjectsBridge';
|
||||||
|
|
@ -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<string, TaskStatusCounts>;
|
||||||
|
activeTeamsByProject: Map<string, TeamSummary[]>;
|
||||||
|
tasksLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumTaskCounts(
|
||||||
|
project: DashboardRecentProject,
|
||||||
|
taskCountsByProject: Map<string, TaskStatusCounts>
|
||||||
|
): TaskStatusCounts | undefined {
|
||||||
|
const total = project.associatedPaths.reduce<TaskStatusCounts>(
|
||||||
|
(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<string, TeamSummary[]>
|
||||||
|
): TeamSummary[] | undefined {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
@ -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<void>;
|
||||||
|
openProjectPath: (projectPath: string) => Promise<void>;
|
||||||
|
selectProjectFolder: () => Promise<void>;
|
||||||
|
} {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
try {
|
||||||
|
await api.openPath(projectPath, projectPath);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to open project path', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectProjectFolder = useCallback(async (): Promise<void> => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -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<void>;
|
||||||
|
openRecentProject: (project: DashboardRecentProject) => Promise<void>;
|
||||||
|
openProjectPath: (projectPath: string) => Promise<void>;
|
||||||
|
selectProjectFolder: () => Promise<void>;
|
||||||
|
} {
|
||||||
|
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<DashboardRecentProject[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [visibleProjects, setVisibleProjects] = useState(maxProjects);
|
||||||
|
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
||||||
|
const hasFetchedTasksRef = useRef(false);
|
||||||
|
|
||||||
|
const reload = useCallback(async (): Promise<void> => {
|
||||||
|
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<string, TeamSummary[]>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/features/recent-projects/renderer/index.ts
Normal file
1
src/features/recent-projects/renderer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { RecentProjectsSection } from './ui/RecentProjectsSection';
|
||||||
221
src/features/recent-projects/renderer/ui/RecentProjectCard.tsx
Normal file
221
src/features/recent-projects/renderer/ui/RecentProjectCard.tsx
Normal file
|
|
@ -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<RecentProjectCardProps>): React.JSX.Element => {
|
||||||
|
const color = useMemo(() => projectColor(card.name), [card.name]);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
className="bg-surface/50 group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-l-[3px] border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis hover:bg-surface-raised"
|
||||||
|
style={{
|
||||||
|
borderLeftColor: color.border,
|
||||||
|
boxShadow: isHovered ? `inset 3px 0 12px -4px ${color.glow}` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.activeTeams && card.activeTeams.length > 0 && (
|
||||||
|
<span className="absolute right-3 top-3 inline-flex size-2.5">
|
||||||
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||||
|
<span className="relative inline-flex size-2.5 rounded-full bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-1 flex items-center gap-2.5">
|
||||||
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
|
||||||
|
<FolderGit2
|
||||||
|
className="size-4 transition-colors group-hover:text-text"
|
||||||
|
style={{ color: color.icon }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
|
||||||
|
{card.name}
|
||||||
|
</h3>
|
||||||
|
{card.pathSummary && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex shrink-0 items-center rounded-full bg-surface-overlay px-1.5 py-0.5 text-[9px] font-medium text-text-muted">
|
||||||
|
{card.pathSummary.badgeLabel}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="start" className="max-w-sm">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[11px] leading-relaxed text-text-secondary">
|
||||||
|
{card.pathSummary.description}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{card.pathSummary.paths.map((pathItem) => (
|
||||||
|
<div key={`${pathItem.label}:${pathItem.fullPath}`} className="space-y-0.5">
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wide text-text-muted">
|
||||||
|
{pathItem.label}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-[11px] text-text">{pathItem.fullPath}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-1.5">
|
||||||
|
{card.providerIds.map((providerId) => (
|
||||||
|
<span
|
||||||
|
key={providerId}
|
||||||
|
className="bg-surface-overlay/80 inline-flex items-center rounded-full border border-border p-1"
|
||||||
|
title={providerId}
|
||||||
|
>
|
||||||
|
<ProviderBrandLogo providerId={providerId} className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full min-w-0 items-center gap-1 font-mono text-[10px] text-text-muted">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onOpenPath();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onOpenPath();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="shrink-0 cursor-pointer rounded p-0.5 transition-colors hover:bg-white/5 hover:text-text-secondary"
|
||||||
|
>
|
||||||
|
<FolderOpen className="size-3" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Open</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="truncate">{card.formattedPath}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="start">
|
||||||
|
<p className="font-mono text-[11px]">{card.project.primaryPath}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{card.primaryBranch ? (
|
||||||
|
<div className="mb-auto mt-1 flex items-center gap-1.5 truncate">
|
||||||
|
<GitBranch className="size-3 shrink-0 text-text-muted" />
|
||||||
|
<span className="truncate text-[10px] text-text-secondary">{card.primaryBranch}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mb-auto" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{card.taskCounts &&
|
||||||
|
(card.taskCounts.pending > 0 ||
|
||||||
|
card.taskCounts.inProgress > 0 ||
|
||||||
|
card.taskCounts.completed > 0) && (
|
||||||
|
<>
|
||||||
|
{card.taskCounts.inProgress > 0 && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||||
|
{card.taskCounts.inProgress} active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{card.taskCounts.pending > 0 && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">
|
||||||
|
{card.taskCounts.pending} pending
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{card.taskCounts.completed > 0 && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-green-500/15 px-1.5 py-0.5 text-[10px] font-medium text-green-400">
|
||||||
|
{card.taskCounts.completed} done
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-text-muted">·</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-text-muted">{card.lastActivityLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{card.tasksLoading ? (
|
||||||
|
<div className="mt-2 w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 flex-1 animate-pulse overflow-hidden rounded-full bg-[var(--color-surface-raised)]" />
|
||||||
|
<div className="h-2.5 w-6 animate-pulse rounded bg-[var(--color-surface-raised)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
card.taskCounts &&
|
||||||
|
(() => {
|
||||||
|
const pending = card.taskCounts.pending ?? 0;
|
||||||
|
const inProgress = card.taskCounts.inProgress ?? 0;
|
||||||
|
const completed = card.taskCounts.completed ?? 0;
|
||||||
|
const totalTasks = pending + inProgress + completed;
|
||||||
|
if (totalTasks === 0) return null;
|
||||||
|
const progressPercent = Math.round((completed / totalTasks) * 100);
|
||||||
|
return (
|
||||||
|
<div className="mt-2 w-full space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={completed}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={totalTasks}
|
||||||
|
aria-label={`Tasks ${completed}/${totalTasks} completed`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
|
||||||
|
{completed}/{totalTasks}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
|
||||||
|
{card.activeTeams && card.activeTeams.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-1.5 border-t border-border pt-2">
|
||||||
|
<Terminal className="size-3 shrink-0 text-emerald-400" />
|
||||||
|
{card.activeTeams.map((team) => (
|
||||||
|
<span
|
||||||
|
key={team.teamName}
|
||||||
|
className="inline-flex items-center rounded-full bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-medium text-emerald-400"
|
||||||
|
>
|
||||||
|
{team.displayName}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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 (
|
||||||
|
<button
|
||||||
|
className="hover:bg-surface/30 group relative flex min-h-[120px] flex-col items-center justify-center rounded-lg border border-dashed border-border bg-transparent p-4 transition-all duration-300 hover:border-border-emphasis"
|
||||||
|
onClick={onClick}
|
||||||
|
title="Select a project folder"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex size-8 items-center justify-center rounded-md border border-dashed border-border transition-colors duration-300 group-hover:border-border-emphasis">
|
||||||
|
<FolderOpen className="size-4 text-text-muted transition-colors group-hover:text-text-secondary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-text-muted transition-colors group-hover:text-text-secondary">
|
||||||
|
Select Folder
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RecentProjectsSection = ({
|
||||||
|
searchQuery,
|
||||||
|
}: Readonly<RecentProjectsSectionProps>): React.JSX.Element => {
|
||||||
|
const {
|
||||||
|
cards,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
canLoadMore,
|
||||||
|
isElectron,
|
||||||
|
loadMore,
|
||||||
|
reload,
|
||||||
|
openRecentProject,
|
||||||
|
openProjectPath,
|
||||||
|
selectProjectFolder,
|
||||||
|
} = useRecentProjectsSection(searchQuery);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="skeleton-card flex min-h-[120px] flex-col rounded-sm border border-border p-4"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${index * 80}ms`,
|
||||||
|
backgroundColor: 'var(--skeleton-base)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mb-3 size-8 rounded-sm"
|
||||||
|
style={{ backgroundColor: 'var(--skeleton-base-light)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="mb-2 h-3.5 rounded-sm"
|
||||||
|
style={{
|
||||||
|
width: `${titleWidths[index]}%`,
|
||||||
|
backgroundColor: 'var(--skeleton-base-light)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="mb-auto h-2.5 rounded-sm"
|
||||||
|
style={{
|
||||||
|
width: `${pathWidths[index]}%`,
|
||||||
|
backgroundColor: 'var(--skeleton-base-dim)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-16 rounded-sm"
|
||||||
|
style={{ backgroundColor: 'var(--skeleton-base-dim)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-12 rounded-sm"
|
||||||
|
style={{ backgroundColor: 'var(--skeleton-base-dim)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && cards.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
|
||||||
|
<div className="mb-1 flex size-12 items-center justify-center rounded-sm border border-border bg-surface-raised">
|
||||||
|
<FolderGit2 className="size-6 text-text-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="mb-1 text-sm text-text-secondary">Failed to load projects</p>
|
||||||
|
<p className="max-w-xl text-xs text-text-muted">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => void reload()}
|
||||||
|
className="rounded-sm border border-border bg-surface-raised px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-border-emphasis hover:text-text"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cards.length === 0 && searchQuery.trim()) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-sm border border-dashed border-border px-8 py-16">
|
||||||
|
<div className="mb-4 flex size-12 items-center justify-center rounded-sm border border-border bg-surface-raised">
|
||||||
|
<Search className="size-6 text-text-muted" />
|
||||||
|
</div>
|
||||||
|
<p className="mb-1 text-sm text-text-secondary">No projects found</p>
|
||||||
|
<p className="text-xs text-text-muted">No matches for "{searchQuery}"</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cards.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-sm border border-dashed border-border px-8 py-16">
|
||||||
|
<div className="mb-4 flex size-12 items-center justify-center rounded-sm border border-border bg-surface-raised">
|
||||||
|
<FolderGit2 className="size-6 text-text-muted" />
|
||||||
|
</div>
|
||||||
|
<p className="mb-1 text-sm text-text-secondary">No recent projects found</p>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Recent Claude and Codex activity will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{!searchQuery.trim() && isElectron && (
|
||||||
|
<SelectProjectFolderCard onClick={() => void selectProjectFolder()} />
|
||||||
|
)}
|
||||||
|
{cards.map((card) => (
|
||||||
|
<RecentProjectCard
|
||||||
|
key={card.id}
|
||||||
|
card={card}
|
||||||
|
onClick={() => void openRecentProject(card.project)}
|
||||||
|
onOpenPath={() => void openProjectPath(card.project.primaryPath)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canLoadMore && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button variant="outline" size="sm" onClick={loadMore}>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
51
src/features/recent-projects/renderer/utils/navigation.ts
Normal file
51
src/features/recent-projects/renderer/utils/navigation.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
* Each route file mirrors the corresponding IPC handler.
|
* Each route file mirrors the corresponding IPC handler.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type RecentProjectsFeatureFacade,
|
||||||
|
registerRecentProjectsHttp,
|
||||||
|
} from '@features/recent-projects/main';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import { registerConfigRoutes } from './config';
|
import { registerConfigRoutes } from './config';
|
||||||
|
|
@ -40,6 +44,7 @@ export interface HttpServices {
|
||||||
subagentResolver: SubagentResolver;
|
subagentResolver: SubagentResolver;
|
||||||
chunkBuilder: ChunkBuilder;
|
chunkBuilder: ChunkBuilder;
|
||||||
dataCache: DataCache;
|
dataCache: DataCache;
|
||||||
|
recentProjectsFeature?: RecentProjectsFeatureFacade;
|
||||||
updaterService: UpdaterService;
|
updaterService: UpdaterService;
|
||||||
sshConnectionManager: SshConnectionManager;
|
sshConnectionManager: SshConnectionManager;
|
||||||
teamProvisioningService?: TeamProvisioningService;
|
teamProvisioningService?: TeamProvisioningService;
|
||||||
|
|
@ -63,6 +68,9 @@ export function registerHttpRoutes(
|
||||||
registerUtilityRoutes(app);
|
registerUtilityRoutes(app);
|
||||||
registerSshRoutes(app, services.sshConnectionManager, sshModeSwitchCallback);
|
registerSshRoutes(app, services.sshConnectionManager, sshModeSwitchCallback);
|
||||||
registerUpdaterRoutes(app, services);
|
registerUpdaterRoutes(app, services);
|
||||||
|
if (services.recentProjectsFeature) {
|
||||||
|
registerRecentProjectsHttp(app, services.recentProjectsFeature);
|
||||||
|
}
|
||||||
registerEventRoutes(app);
|
registerEventRoutes(app);
|
||||||
|
|
||||||
logger.info('All HTTP routes registered');
|
logger.info('All HTTP routes registered');
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@ process.env.UV_THREADPOOL_SIZE ??= '16';
|
||||||
// Sentry must be the first import to capture early errors.
|
// Sentry must be the first import to capture early errors.
|
||||||
import './sentry';
|
import './sentry';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRecentProjectsFeature,
|
||||||
|
type RecentProjectsFeatureFacade,
|
||||||
|
registerRecentProjectsIpc,
|
||||||
|
removeRecentProjectsIpc,
|
||||||
|
} from '@features/recent-projects/main';
|
||||||
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
||||||
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
|
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
|
||||||
import { SchedulerService } from '@main/services/schedule/SchedulerService';
|
import { SchedulerService } from '@main/services/schedule/SchedulerService';
|
||||||
|
|
@ -54,7 +60,7 @@ import {
|
||||||
import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics';
|
import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics';
|
||||||
import { parseInboxJson } from '@shared/utils/inboxNoise';
|
import { parseInboxJson } from '@shared/utils/inboxNoise';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
|
|
@ -102,8 +108,8 @@ import {
|
||||||
} from './utils/safeWebContentsSend';
|
} from './utils/safeWebContentsSend';
|
||||||
import { syncTelemetryFlag } from './sentry';
|
import { syncTelemetryFlag } from './sentry';
|
||||||
import {
|
import {
|
||||||
BoardTaskActivityRecordSource,
|
|
||||||
BoardTaskActivityDetailService,
|
BoardTaskActivityDetailService,
|
||||||
|
BoardTaskActivityRecordSource,
|
||||||
BoardTaskActivityService,
|
BoardTaskActivityService,
|
||||||
BoardTaskExactLogDetailService,
|
BoardTaskExactLogDetailService,
|
||||||
BoardTaskExactLogsService,
|
BoardTaskExactLogsService,
|
||||||
|
|
@ -399,6 +405,7 @@ let contextRegistry: ServiceContextRegistry;
|
||||||
let notificationManager: NotificationManager;
|
let notificationManager: NotificationManager;
|
||||||
let updaterService: UpdaterService;
|
let updaterService: UpdaterService;
|
||||||
let sshConnectionManager: SshConnectionManager;
|
let sshConnectionManager: SshConnectionManager;
|
||||||
|
let recentProjectsFeature: RecentProjectsFeatureFacade;
|
||||||
let teamDataService: TeamDataService;
|
let teamDataService: TeamDataService;
|
||||||
let teamProvisioningService: TeamProvisioningService;
|
let teamProvisioningService: TeamProvisioningService;
|
||||||
let cliInstallerService: CliInstallerService;
|
let cliInstallerService: CliInstallerService;
|
||||||
|
|
@ -927,6 +934,11 @@ async function initializeServices(): Promise<void> {
|
||||||
});
|
});
|
||||||
|
|
||||||
teamProvisioningService.setMainWindow(mainWindow);
|
teamProvisioningService.setMainWindow(mainWindow);
|
||||||
|
recentProjectsFeature = createRecentProjectsFeature({
|
||||||
|
getActiveContext: () => contextRegistry.getActive(),
|
||||||
|
getLocalContext: () => contextRegistry.get('local'),
|
||||||
|
logger: createLogger('Feature:RecentProjects'),
|
||||||
|
});
|
||||||
|
|
||||||
// startProcessHealthPolling() is deferred to after window creation
|
// startProcessHealthPolling() is deferred to after window creation
|
||||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||||
|
|
@ -980,6 +992,7 @@ async function initializeServices(): Promise<void> {
|
||||||
crossTeamService,
|
crossTeamService,
|
||||||
teamBackupService ?? undefined
|
teamBackupService ?? undefined
|
||||||
);
|
);
|
||||||
|
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
|
||||||
|
|
||||||
// Forward SSH state changes to renderer and HTTP SSE clients
|
// Forward SSH state changes to renderer and HTTP SSE clients
|
||||||
sshConnectionManager.on('state-change', (status: unknown) => {
|
sshConnectionManager.on('state-change', (status: unknown) => {
|
||||||
|
|
@ -1028,6 +1041,7 @@ async function startHttpServer(
|
||||||
subagentResolver: activeContext.subagentResolver,
|
subagentResolver: activeContext.subagentResolver,
|
||||||
chunkBuilder: activeContext.chunkBuilder,
|
chunkBuilder: activeContext.chunkBuilder,
|
||||||
dataCache: activeContext.dataCache,
|
dataCache: activeContext.dataCache,
|
||||||
|
recentProjectsFeature,
|
||||||
updaterService,
|
updaterService,
|
||||||
sshConnectionManager,
|
sshConnectionManager,
|
||||||
teamProvisioningService,
|
teamProvisioningService,
|
||||||
|
|
@ -1119,6 +1133,7 @@ function shutdownServices(): void {
|
||||||
|
|
||||||
// Remove IPC handlers
|
// Remove IPC handlers
|
||||||
removeIpcHandlers();
|
removeIpcHandlers();
|
||||||
|
removeRecentProjectsIpc(ipcMain);
|
||||||
|
|
||||||
// Dispose backup service timers
|
// Dispose backup service timers
|
||||||
teamBackupService?.dispose();
|
teamBackupService?.dispose();
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
// runtime which is unavailable in standalone (pure Node.js) mode. Standalone
|
// runtime which is unavailable in standalone (pure Node.js) mode. Standalone
|
||||||
// error tracking can be added later with @sentry/node if needed.
|
// 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 { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider';
|
import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider';
|
||||||
|
|
@ -130,6 +131,11 @@ async function start(): Promise<void> {
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
httpServer = new HttpServer();
|
httpServer = new HttpServer();
|
||||||
|
const recentProjectsFeature = createRecentProjectsFeature({
|
||||||
|
getActiveContext: () => localContext,
|
||||||
|
getLocalContext: () => localContext,
|
||||||
|
logger: createLogger('Feature:RecentProjects'),
|
||||||
|
});
|
||||||
|
|
||||||
// Wire file watcher events to SSE broadcast
|
// Wire file watcher events to SSE broadcast
|
||||||
localContext.fileWatcher.on('file-change', (event: unknown) => {
|
localContext.fileWatcher.on('file-change', (event: unknown) => {
|
||||||
|
|
@ -157,6 +163,7 @@ async function start(): Promise<void> {
|
||||||
subagentResolver: localContext.subagentResolver,
|
subagentResolver: localContext.subagentResolver,
|
||||||
chunkBuilder: localContext.chunkBuilder,
|
chunkBuilder: localContext.chunkBuilder,
|
||||||
dataCache: localContext.dataCache,
|
dataCache: localContext.dataCache,
|
||||||
|
recentProjectsFeature,
|
||||||
updaterService: updaterServiceStub,
|
updaterService: updaterServiceStub,
|
||||||
sshConnectionManager: sshConnectionManagerStub,
|
sshConnectionManager: sshConnectionManagerStub,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { createRecentProjectsBridge } from '@features/recent-projects/preload';
|
||||||
import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
|
import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
|
||||||
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
||||||
|
|
||||||
|
|
@ -243,6 +244,7 @@ import type {
|
||||||
ClaudeRootInfo,
|
ClaudeRootInfo,
|
||||||
CliInstallationStatus,
|
CliInstallationStatus,
|
||||||
CliInstallerProgress,
|
CliInstallerProgress,
|
||||||
|
CliProviderId,
|
||||||
ConflictCheckResult,
|
ConflictCheckResult,
|
||||||
ContextInfo,
|
ContextInfo,
|
||||||
CreateScheduleInput,
|
CreateScheduleInput,
|
||||||
|
|
@ -448,6 +450,7 @@ ipcRenderer.on(
|
||||||
// Expose protected methods that allow the renderer process to use
|
// Expose protected methods that allow the renderer process to use
|
||||||
// the ipcRenderer without exposing the entire object
|
// the ipcRenderer without exposing the entire object
|
||||||
const electronAPI: ElectronAPI = {
|
const electronAPI: ElectronAPI = {
|
||||||
|
...createRecentProjectsBridge(),
|
||||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
getProjects: () => ipcRenderer.invoke('get-projects'),
|
getProjects: () => ipcRenderer.invoke('get-projects'),
|
||||||
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),
|
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),
|
||||||
|
|
@ -1408,7 +1411,7 @@ const electronAPI: ElectronAPI = {
|
||||||
getStatus: async (): Promise<CliInstallationStatus> => {
|
getStatus: async (): Promise<CliInstallationStatus> => {
|
||||||
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS);
|
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS);
|
||||||
},
|
},
|
||||||
getProviderStatus: async (providerId: import('@shared/types').CliProviderId) => {
|
getProviderStatus: async (providerId: CliProviderId) => {
|
||||||
return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId);
|
return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId);
|
||||||
},
|
},
|
||||||
install: async (): Promise<void> => {
|
install: async (): Promise<void> => {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
* to run in a regular browser connected to an HTTP server.
|
* to run in a regular browser connected to an HTTP server.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
AttachmentFileData,
|
AttachmentFileData,
|
||||||
|
|
@ -214,6 +215,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
|
|
||||||
getAppVersion = (): Promise<string> => this.get<string>('/api/version');
|
getAppVersion = (): Promise<string> => this.get<string>('/api/version');
|
||||||
|
|
||||||
|
getDashboardRecentProjects = (): Promise<DashboardRecentProject[]> =>
|
||||||
|
this.get<DashboardRecentProject[]>('/api/dashboard/recent-projects');
|
||||||
|
|
||||||
getProjects = (): Promise<Project[]> => this.get<Project[]>('/api/projects');
|
getProjects = (): Promise<Project[]> => this.get<Project[]>('/api/projects');
|
||||||
|
|
||||||
getSessions = (projectId: string): Promise<Session[]> =>
|
getSessions = (projectId: string): Promise<Session[]> =>
|
||||||
|
|
@ -1218,7 +1222,7 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
schedules = {
|
schedules: ElectronAPI['schedules'] = {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
console.warn('Schedules not available in browser mode');
|
console.warn('Schedules not available in browser mode');
|
||||||
return [] as Schedule[];
|
return [] as Schedule[];
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* DashboardView - Main dashboard with "Productivity Luxury" aesthetic.
|
* DashboardView - Main dashboard shell.
|
||||||
* Inspired by Linear, Vercel, and Raycast design patterns.
|
* Keeps only screen composition and delegates recent-projects logic to the feature slice.
|
||||||
* Features:
|
|
||||||
* - Subtle spotlight gradient
|
|
||||||
* - Centralized command search with inline project filtering
|
|
||||||
* - Border-first project cards with minimal backgrounds
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { RecentProjectsSection } from '@features/recent-projects/renderer';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
|
||||||
import { useStore } from '@renderer/store';
|
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 { formatShortcut } from '@renderer/utils/stringUtils';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { Command, Search, Users } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
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 { CliStatusBanner } from './CliStatusBanner';
|
||||||
import { DashboardUpdateBanner } from './DashboardUpdateBanner';
|
import { DashboardUpdateBanner } from './DashboardUpdateBanner';
|
||||||
import { TmuxStatusBanner } from './TmuxStatusBanner';
|
import { TmuxStatusBanner } from './TmuxStatusBanner';
|
||||||
|
import { WebPreviewBanner } from './WebPreviewBanner';
|
||||||
import type { RepositoryGroup } from '@renderer/types/data';
|
|
||||||
import type { TeamSummary } from '@shared/types';
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Command Search Input
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
interface CommandSearchProps {
|
interface CommandSearchProps {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -58,17 +25,16 @@ const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const { openCommandPalette, selectedProjectId } = useStore(
|
const { openCommandPalette, selectedProjectId } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((state) => ({
|
||||||
openCommandPalette: s.openCommandPalette,
|
openCommandPalette: state.openCommandPalette,
|
||||||
selectedProjectId: s.selectedProjectId,
|
selectedProjectId: state.selectedProjectId,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle Cmd+K to open full command palette
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.code === 'KeyK') {
|
if ((event.metaKey || event.ctrlKey) && event.code === 'KeyK') {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
openCommandPalette();
|
openCommandPalette();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -77,24 +43,24 @@ const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [openCommandPalette]);
|
}, [openCommandPalette]);
|
||||||
|
|
||||||
// Focus search when the dashboard mounts (packaged Electron can skip native autoFocus).
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = inputRef.current;
|
const input = inputRef.current;
|
||||||
if (!el) {
|
if (!input) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.focus({ preventScroll: true });
|
|
||||||
const t = window.setTimeout(() => {
|
input.focus({ preventScroll: true });
|
||||||
if (document.activeElement !== el) {
|
const timeoutId = window.setTimeout(() => {
|
||||||
el.focus({ preventScroll: true });
|
if (document.activeElement !== input) {
|
||||||
|
input.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
return () => window.clearTimeout(t);
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
{/* Search container with glow effect on focus */}
|
|
||||||
<div
|
<div
|
||||||
className={`relative flex items-center gap-3 rounded-sm border bg-surface-raised px-4 py-3 transition-all duration-200 ${
|
className={`relative flex items-center gap-3 rounded-sm border bg-surface-raised px-4 py-3 transition-all duration-200 ${
|
||||||
isFocused
|
isFocused
|
||||||
|
|
@ -107,13 +73,12 @@ const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
placeholder="Search projects..."
|
placeholder="Search projects..."
|
||||||
className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-muted"
|
className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-muted"
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
/>
|
/>
|
||||||
{/* Keyboard shortcut badge - opens full command palette */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => openCommandPalette()}
|
onClick={() => openCommandPalette()}
|
||||||
className="flex shrink-0 items-center gap-1 transition-opacity hover:opacity-80"
|
className="flex shrink-0 items-center gap-1 transition-opacity hover:opacity-80"
|
||||||
|
|
@ -135,712 +100,23 @@ const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Repository Card
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
interface RepositoryCardProps {
|
|
||||||
repo: RepositoryGroup;
|
|
||||||
onClick: () => void;
|
|
||||||
isHighlighted?: boolean;
|
|
||||||
taskCounts?: TaskStatusCounts;
|
|
||||||
tasksLoading?: boolean;
|
|
||||||
activeTeams?: TeamSummary[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const RepositoryCard = ({
|
|
||||||
repo,
|
|
||||||
onClick,
|
|
||||||
isHighlighted,
|
|
||||||
taskCounts,
|
|
||||||
tasksLoading,
|
|
||||||
activeTeams,
|
|
||||||
}: Readonly<RepositoryCardProps>): React.JSX.Element => {
|
|
||||||
const lastActivity = repo.mostRecentSession
|
|
||||||
? formatDistanceToNow(new Date(repo.mostRecentSession), { addSuffix: true })
|
|
||||||
: 'No recent activity';
|
|
||||||
|
|
||||||
const worktreeCount = repo.worktrees.length;
|
|
||||||
const hasMultipleWorktrees = worktreeCount > 1;
|
|
||||||
|
|
||||||
// Get the path from the first worktree
|
|
||||||
const projectPath = repo.worktrees[0]?.path || '';
|
|
||||||
const formattedPath = formatProjectPath(projectPath);
|
|
||||||
|
|
||||||
// Git branch info from worktrees
|
|
||||||
const mainWorktree = repo.worktrees.find((w) => w.isMainWorktree) ?? repo.worktrees[0];
|
|
||||||
const mainBranch = mainWorktree?.gitBranch;
|
|
||||||
|
|
||||||
// Detect if this is a worktree project:
|
|
||||||
// 1. No main worktree in the group (isMainWorktree flag)
|
|
||||||
// 2. OR the shown worktree has a tool-created source
|
|
||||||
// 3. OR path-based fallback for .claude/worktrees/ directories
|
|
||||||
const WORKTREE_PATH_MARKERS = [
|
|
||||||
'/.claude/worktrees/',
|
|
||||||
'/.claude-worktrees/',
|
|
||||||
'/.auto-claude/worktrees/',
|
|
||||||
'/.21st/worktrees/',
|
|
||||||
'/.ccswitch/worktrees/',
|
|
||||||
'/.cursor/worktrees/',
|
|
||||||
'/vibe-kanban/worktrees/',
|
|
||||||
'/conductor/workspaces/',
|
|
||||||
];
|
|
||||||
|
|
||||||
const shownWorktree = repo.worktrees[0];
|
|
||||||
const isWorktreeBySource =
|
|
||||||
shownWorktree?.source && !['git', 'unknown'].includes(shownWorktree.source);
|
|
||||||
const isWorktreeByPath =
|
|
||||||
shownWorktree && WORKTREE_PATH_MARKERS.some((m) => shownWorktree.path.includes(m));
|
|
||||||
const isWorktreeProject =
|
|
||||||
!repo.worktrees.some((w) => w.isMainWorktree) || isWorktreeBySource || isWorktreeByPath;
|
|
||||||
|
|
||||||
// Get the source label for worktree badge
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
|
||||||
'vibe-kanban': 'Vibe',
|
|
||||||
conductor: 'Conductor',
|
|
||||||
'auto-claude': 'Auto',
|
|
||||||
'21st': '21st',
|
|
||||||
'claude-desktop': 'Desktop',
|
|
||||||
'claude-code': 'Worktree',
|
|
||||||
ccswitch: 'ccswitch',
|
|
||||||
};
|
|
||||||
const worktreeSourceLabel = shownWorktree?.source && SOURCE_LABELS[shownWorktree.source];
|
|
||||||
|
|
||||||
const color = useMemo(() => projectColor(repo.name), [repo.name]);
|
|
||||||
const cardRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
|
|
||||||
const handleOpenPath = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (projectPath) {
|
|
||||||
void api.openPath(projectPath, projectPath);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[projectPath]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
ref={cardRef}
|
|
||||||
onClick={onClick}
|
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
|
||||||
className={`group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-l-[3px] p-4 text-left transition-all duration-300 ${
|
|
||||||
isHighlighted
|
|
||||||
? 'border-border-emphasis bg-surface-raised'
|
|
||||||
: 'bg-surface/50 border-border hover:border-border-emphasis hover:bg-surface-raised'
|
|
||||||
} `}
|
|
||||||
style={{
|
|
||||||
borderLeftColor: color.border,
|
|
||||||
boxShadow: isHovered ? `inset 3px 0 12px -4px ${color.glow}` : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Online indicator — top-right corner */}
|
|
||||||
{activeTeams && activeTeams.length > 0 && (
|
|
||||||
<span className="absolute right-3 top-3 inline-flex size-2.5">
|
|
||||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
|
||||||
<span className="relative inline-flex size-2.5 rounded-full bg-emerald-500" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Icon + Project name */}
|
|
||||||
<div className="mb-1 flex items-center gap-2.5">
|
|
||||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
|
|
||||||
{isWorktreeProject ? (
|
|
||||||
<GitFork
|
|
||||||
className="size-4 transition-colors group-hover:text-text"
|
|
||||||
style={{ color: color.icon }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FolderGit2
|
|
||||||
className="size-4 transition-colors group-hover:text-text"
|
|
||||||
style={{ color: color.icon }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
|
|
||||||
{repo.name}
|
|
||||||
</h3>
|
|
||||||
{isWorktreeProject && (
|
|
||||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-purple-500/15 px-1.5 py-0.5 text-[9px] font-medium text-purple-400">
|
|
||||||
{worktreeSourceLabel ?? 'Worktree'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Project path - monospace, muted; folder icon opens in file manager */}
|
|
||||||
<div className="flex w-full min-w-0 items-center gap-1 font-mono text-[10px] text-text-muted">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={handleOpenPath}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ')
|
|
||||||
handleOpenPath(e as unknown as React.MouseEvent);
|
|
||||||
}}
|
|
||||||
className="shrink-0 cursor-pointer rounded p-0.5 transition-colors hover:bg-white/5 hover:text-text-secondary"
|
|
||||||
>
|
|
||||||
<FolderOpen className="size-3" />
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">Open</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="truncate">{formattedPath}</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" align="start">
|
|
||||||
<p className="font-mono text-[11px]">{projectPath}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Git branch / worktree info */}
|
|
||||||
{mainBranch ? (
|
|
||||||
<div className="mb-auto mt-1 flex items-center gap-1.5 truncate">
|
|
||||||
<GitBranch className="size-3 shrink-0 text-text-muted" />
|
|
||||||
<span className="truncate text-[10px] text-text-secondary">{mainBranch}</span>
|
|
||||||
{hasMultipleWorktrees && (
|
|
||||||
<span className="shrink-0 rounded bg-surface-raised px-1 py-px text-[9px] text-text-muted">
|
|
||||||
+{worktreeCount - 1}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mb-auto" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Meta row: worktrees, sessions, time */}
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
||||||
{hasMultipleWorktrees && (
|
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] text-text-secondary">
|
|
||||||
<GitBranch className="size-3" />
|
|
||||||
{worktreeCount} worktrees
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[10px] text-text-secondary">{repo.totalSessions} sessions</span>
|
|
||||||
{taskCounts &&
|
|
||||||
(taskCounts.pending > 0 || taskCounts.inProgress > 0 || taskCounts.completed > 0) && (
|
|
||||||
<>
|
|
||||||
<span className="text-text-muted">·</span>
|
|
||||||
{taskCounts.inProgress > 0 && (
|
|
||||||
<span className="inline-flex items-center rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
|
||||||
{taskCounts.inProgress} active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{taskCounts.pending > 0 && (
|
|
||||||
<span className="inline-flex items-center rounded-full bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">
|
|
||||||
{taskCounts.pending} pending
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{taskCounts.completed > 0 && (
|
|
||||||
<span className="inline-flex items-center rounded-full bg-green-500/15 px-1.5 py-0.5 text-[10px] font-medium text-green-400">
|
|
||||||
{taskCounts.completed} done
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span className="text-text-muted">·</span>
|
|
||||||
<span className="text-[10px] text-text-muted">{lastActivity}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tasks progress bar */}
|
|
||||||
{tasksLoading ? (
|
|
||||||
<div className="mt-2 w-full">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-1.5 flex-1 animate-pulse overflow-hidden rounded-full bg-[var(--color-surface-raised)]" />
|
|
||||||
<div className="h-2.5 w-6 animate-pulse rounded bg-[var(--color-surface-raised)]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
taskCounts &&
|
|
||||||
(() => {
|
|
||||||
const pending = taskCounts.pending ?? 0;
|
|
||||||
const inProgress = taskCounts.inProgress ?? 0;
|
|
||||||
const completed = taskCounts.completed ?? 0;
|
|
||||||
const totalTasks = pending + inProgress + completed;
|
|
||||||
if (totalTasks === 0) return null;
|
|
||||||
const completedRatio = completed / totalTasks;
|
|
||||||
const progressPercent = Math.round(completedRatio * 100);
|
|
||||||
return (
|
|
||||||
<div className="mt-2 w-full space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
|
|
||||||
role="progressbar"
|
|
||||||
aria-valuenow={completed}
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={totalTasks}
|
|
||||||
aria-label={`Tasks ${completed}/${totalTasks} completed`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
|
|
||||||
style={{ width: `${progressPercent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
|
|
||||||
{completed}/{totalTasks}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Active teams running in this project */}
|
|
||||||
{activeTeams && activeTeams.length > 0 && (
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-1.5 border-t border-border pt-2">
|
|
||||||
<Terminal className="size-3 shrink-0 text-emerald-400" />
|
|
||||||
{activeTeams.map((t) => (
|
|
||||||
<span
|
|
||||||
key={t.teamName}
|
|
||||||
className="inline-flex items-center rounded-full bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-medium text-emerald-400"
|
|
||||||
>
|
|
||||||
{t.displayName}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// 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<void> => {
|
|
||||||
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 (
|
|
||||||
<button
|
|
||||||
className="hover:bg-surface/30 group relative flex min-h-[120px] flex-col items-center justify-center rounded-lg border border-dashed border-border bg-transparent p-4 transition-all duration-300 hover:border-border-emphasis"
|
|
||||||
onClick={handleClick}
|
|
||||||
title="Select a project folder"
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex size-8 items-center justify-center rounded-md border border-dashed border-border transition-colors duration-300 group-hover:border-border-emphasis">
|
|
||||||
<FolderOpen className="size-4 text-text-muted transition-colors group-hover:text-text-secondary" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-text-muted transition-colors group-hover:text-text-secondary">
|
|
||||||
Select Folder
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// 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<ProjectsGridProps>): 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<string[]>([]);
|
|
||||||
|
|
||||||
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<string, TeamSummary[]>();
|
|
||||||
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 (
|
|
||||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
|
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="skeleton-card flex min-h-[120px] flex-col rounded-sm border border-border p-4"
|
|
||||||
style={{
|
|
||||||
animationDelay: `${i * 80}ms`,
|
|
||||||
backgroundColor: 'var(--skeleton-base)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Icon placeholder */}
|
|
||||||
<div
|
|
||||||
className="mb-3 size-8 rounded-sm"
|
|
||||||
style={{ backgroundColor: 'var(--skeleton-base-light)' }}
|
|
||||||
/>
|
|
||||||
{/* Title placeholder */}
|
|
||||||
<div
|
|
||||||
className="mb-2 h-3.5 rounded-sm"
|
|
||||||
style={{
|
|
||||||
width: `${titleWidths[i]}%`,
|
|
||||||
backgroundColor: 'var(--skeleton-base-light)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Path placeholder */}
|
|
||||||
<div
|
|
||||||
className="mb-auto h-2.5 rounded-sm"
|
|
||||||
style={{
|
|
||||||
width: `${pathWidths[i]}%`,
|
|
||||||
backgroundColor: 'var(--skeleton-base-dim)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Meta row placeholder */}
|
|
||||||
<div className="mt-3 flex gap-2">
|
|
||||||
<div
|
|
||||||
className="h-2.5 w-16 rounded-sm"
|
|
||||||
style={{ backgroundColor: 'var(--skeleton-base-dim)' }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="h-2.5 w-12 rounded-sm"
|
|
||||||
style={{ backgroundColor: 'var(--skeleton-base-dim)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repositoryGroupsError && repositoryGroups.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
|
|
||||||
<div className="mb-1 flex size-12 items-center justify-center rounded-sm border border-border bg-surface-raised">
|
|
||||||
<FolderGit2 className="size-6 text-text-muted" />
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="mb-1 text-sm text-text-secondary">Failed to load projects</p>
|
|
||||||
<p className="max-w-xl text-xs text-text-muted">{repositoryGroupsError}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => void fetchRepositoryGroups()}
|
|
||||||
className="rounded-sm border border-border bg-surface-raised px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-border-emphasis hover:text-text"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filteredRepos.length === 0 && searchQuery.trim()) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-sm border border-dashed border-border px-8 py-16">
|
|
||||||
<div className="mb-4 flex size-12 items-center justify-center rounded-sm border border-border bg-surface-raised">
|
|
||||||
<Search className="size-6 text-text-muted" />
|
|
||||||
</div>
|
|
||||||
<p className="mb-1 text-sm text-text-secondary">No projects found</p>
|
|
||||||
<p className="text-xs text-text-muted">No matches for "{searchQuery}"</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repositoryGroups.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-sm border border-dashed border-border px-8 py-16">
|
|
||||||
<div className="mb-4 flex size-12 items-center justify-center rounded-sm border border-border bg-surface-raised">
|
|
||||||
<FolderGit2 className="size-6 text-text-muted" />
|
|
||||||
</div>
|
|
||||||
<p className="mb-1 text-sm text-text-secondary">No projects found</p>
|
|
||||||
<p className="font-mono text-xs text-text-muted">~/.claude/projects/</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
|
|
||||||
{!searchQuery.trim() && <NewProjectCard />}
|
|
||||||
{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<string>();
|
|
||||||
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 (
|
|
||||||
<RepositoryCard
|
|
||||||
key={repo.id}
|
|
||||||
repo={repo}
|
|
||||||
onClick={() => {
|
|
||||||
selectRepository(repo.id);
|
|
||||||
openTeamsTab();
|
|
||||||
}}
|
|
||||||
isHighlighted={!!searchQuery.trim()}
|
|
||||||
taskCounts={globalTasksLoading ? undefined : counts}
|
|
||||||
tasksLoading={globalTasksLoading}
|
|
||||||
activeTeams={repoActiveTeams.length > 0 ? repoActiveTeams : undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canLoadMore && (
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setVisibleProjects((prev) => prev + LOAD_MORE_STEP)}
|
|
||||||
>
|
|
||||||
Load more
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Dashboard View
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export const DashboardView = (): React.JSX.Element => {
|
export const DashboardView = (): React.JSX.Element => {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const openTeamsTab = useStore((s) => s.openTeamsTab);
|
const openTeamsTab = useStore((state) => state.openTeamsTab);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-1 overflow-auto bg-surface">
|
<div className="relative flex-1 overflow-auto bg-surface">
|
||||||
{/* Spotlight gradient background */}
|
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-x-0 top-0 h-[600px] bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(99,102,241,0.08),transparent)]"
|
className="pointer-events-none absolute inset-x-0 top-0 h-[600px] bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(99,102,241,0.08),transparent)]"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="relative mx-auto max-w-5xl px-8 py-12">
|
<div className="relative mx-auto max-w-5xl px-8 py-12">
|
||||||
{/* App update banner */}
|
<WebPreviewBanner />
|
||||||
<DashboardUpdateBanner />
|
<DashboardUpdateBanner />
|
||||||
|
|
||||||
{/* CLI Status Banner */}
|
|
||||||
<CliStatusBanner />
|
<CliStatusBanner />
|
||||||
<TmuxStatusBanner />
|
<TmuxStatusBanner />
|
||||||
|
|
||||||
{/* Team select + Search */}
|
|
||||||
<div className="mb-12 flex items-center justify-center gap-3">
|
<div className="mb-12 flex items-center justify-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={openTeamsTab}
|
onClick={openTeamsTab}
|
||||||
|
|
@ -855,7 +131,6 @@ export const DashboardView = (): React.JSX.Element => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section header */}
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-xs font-medium uppercase tracking-wider text-text-muted">
|
<h2 className="text-xs font-medium uppercase tracking-wider text-text-muted">
|
||||||
{searchQuery.trim() ? 'Search Results' : 'Recent Projects'}
|
{searchQuery.trim() ? 'Search Results' : 'Recent Projects'}
|
||||||
|
|
@ -870,8 +145,7 @@ export const DashboardView = (): React.JSX.Element => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Projects Grid */}
|
<RecentProjectsSection searchQuery={searchQuery} />
|
||||||
<ProjectsGrid searchQuery={searchQuery} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
27
src/renderer/components/dashboard/WebPreviewBanner.tsx
Normal file
27
src/renderer/components/dashboard/WebPreviewBanner.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className="mb-6 flex items-start gap-3 rounded-lg border px-4 py-3"
|
||||||
|
style={{
|
||||||
|
borderColor: 'rgba(217, 119, 6, 0.28)',
|
||||||
|
backgroundColor: 'rgba(245, 158, 11, 0.14)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FlaskConical className="mt-0.5 size-4 shrink-0 text-amber-600" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-amber-900">Web version is still in development</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-amber-800">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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/<feature-name>/`
|
||||||
|
|
||||||
```bash
|
Reference implementation:
|
||||||
mkdir -p src/renderer/features/<feature-name>/{ports,adapters,domain,ui,hooks,__tests__}
|
- `src/features/recent-projects`
|
||||||
```
|
|
||||||
|
|
||||||
---
|
Keep `src/renderer/features/*` for:
|
||||||
|
- existing legacy slices
|
||||||
## Directory Structure
|
- renderer-only thin integrations
|
||||||
|
- work that does not introduce a new use case, transport boundary, or cross-process architecture
|
||||||
### Full Feature
|
|
||||||
|
|
||||||
```
|
|
||||||
src/renderer/features/<feature-name>/
|
|
||||||
├── ports/ # Interfaces (contracts) — NO implementations
|
|
||||||
│ ├── <Feature>DataPort.ts # What data the feature needs (input)
|
|
||||||
│ ├── <Feature>EventPort.ts # Callbacks the feature fires (output)
|
|
||||||
│ ├── <Feature>ConfigPort.ts# Configuration / theme overrides
|
|
||||||
│ └── types.ts # Domain value types for this feature
|
|
||||||
│
|
|
||||||
├── adapters/ # Bridge between project infrastructure and feature
|
|
||||||
│ └── <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
|
|
||||||
│ ├── <Feature>View.tsx # Main component (orchestrator, entry point)
|
|
||||||
│ ├── <Feature>Overlay.tsx # Full-screen overlay variant (if applicable)
|
|
||||||
│ └── <Feature>Tab.tsx # Tab wrapper variant (if applicable)
|
|
||||||
│
|
|
||||||
├── hooks/ # React hooks — thin bridges to domain classes
|
|
||||||
│ └── use<Feature>.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/<feature-name>/
|
|
||||||
├── <Feature>Adapter.ts # Zustand → feature data
|
|
||||||
├── <Feature>View.tsx # Main component
|
|
||||||
└── index.ts # Public API
|
|
||||||
```
|
|
||||||
|
|
||||||
### When to Extract a Workspace Package
|
|
||||||
|
|
||||||
Some features benefit from a separate `packages/<name>/` 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 = <GraphView data={adapter} />;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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<string, NodeRenderer>();
|
|
||||||
|
|
||||||
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/<name>`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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: '...' | '<feature>';
|
|
||||||
|
|
||||||
// 2. src/renderer/components/layout/PaneContent.tsx — add route
|
|
||||||
{tab.type === '<feature>' && (
|
|
||||||
<TabUIProvider tabId={tab.id}>
|
|
||||||
<FeatureView ... />
|
|
||||||
</TabUIProvider>
|
|
||||||
)}
|
|
||||||
|
|
||||||
// 3. src/renderer/components/layout/SortableTab.tsx — add icon
|
|
||||||
<feature>: SomeIcon,
|
|
||||||
```
|
|
||||||
|
|
||||||
### Overlay Registration (1 file)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In host component (e.g., TeamDetailView.tsx):
|
|
||||||
const FeatureOverlay = lazy(() =>
|
|
||||||
import('@renderer/features/<feature>/ui/FeatureOverlay')
|
|
||||||
.then(m => ({ default: m.FeatureOverlay }))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Keyboard Shortcut (1 file)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In useKeyboardShortcuts.ts:
|
|
||||||
if (key === '<x>' && event.shiftKey && !event.altKey) {
|
|
||||||
window.dispatchEvent(new CustomEvent('toggle-<feature>', { 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 | `<module>.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/<name>/`
|
|
||||||
- [ ] 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`
|
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,8 @@ import type {
|
||||||
import type {
|
import type {
|
||||||
AddMemberRequest,
|
AddMemberRequest,
|
||||||
AddTaskCommentRequest,
|
AddTaskCommentRequest,
|
||||||
BoardTaskActivityDetailResult,
|
|
||||||
AttachmentFileData,
|
AttachmentFileData,
|
||||||
|
BoardTaskActivityDetailResult,
|
||||||
BoardTaskActivityEntry,
|
BoardTaskActivityEntry,
|
||||||
BoardTaskExactLogDetailResult,
|
BoardTaskExactLogDetailResult,
|
||||||
BoardTaskExactLogSummariesResponse,
|
BoardTaskExactLogSummariesResponse,
|
||||||
|
|
@ -89,6 +89,7 @@ import type {
|
||||||
import type { TerminalAPI } from './terminal';
|
import type { TerminalAPI } from './terminal';
|
||||||
import type { TmuxAPI } from './tmux';
|
import type { TmuxAPI } from './tmux';
|
||||||
import type { WaterfallData } from './visualization';
|
import type { WaterfallData } from './visualization';
|
||||||
|
import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts';
|
||||||
import type {
|
import type {
|
||||||
ConversationGroup,
|
ConversationGroup,
|
||||||
FileChangeEvent,
|
FileChangeEvent,
|
||||||
|
|
@ -721,7 +722,7 @@ export interface ReviewAPI {
|
||||||
/**
|
/**
|
||||||
* Complete Electron API exposed to the renderer process via preload script.
|
* Complete Electron API exposed to the renderer process via preload script.
|
||||||
*/
|
*/
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI extends RecentProjectsElectronApi {
|
||||||
getAppVersion: () => Promise<string>;
|
getAppVersion: () => Promise<string>;
|
||||||
getProjects: () => Promise<Project[]>;
|
getProjects: () => Promise<Project[]>;
|
||||||
getSessions: (projectId: string) => Promise<Session[]>;
|
getSessions: (projectId: string) => Promise<Session[]>;
|
||||||
|
|
|
||||||
|
|
@ -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> = {}): 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<typeof vi.fn>;
|
||||||
|
warn: ReturnType<typeof vi.fn>;
|
||||||
|
error: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
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<TestViewModel> = {
|
||||||
|
get: vi.fn().mockResolvedValue(cached),
|
||||||
|
set: vi.fn(),
|
||||||
|
};
|
||||||
|
const output: ListDashboardRecentProjectsOutputPort<TestViewModel> = {
|
||||||
|
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<TestViewModel> = {
|
||||||
|
get: vi.fn().mockResolvedValue(null),
|
||||||
|
set: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const output: ListDashboardRecentProjectsOutputPort<TestViewModel> = {
|
||||||
|
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<TestViewModel> = {
|
||||||
|
get: vi.fn().mockResolvedValue(null),
|
||||||
|
set: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const output: ListDashboardRecentProjectsOutputPort<TestViewModel> = {
|
||||||
|
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<RecentProjectCandidate[]>((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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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> = {}): 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"@features/*": ["./src/features/*"],
|
||||||
"@main/*": ["./src/main/*"],
|
"@main/*": ["./src/main/*"],
|
||||||
"@renderer/*": ["./src/renderer/*"],
|
"@renderer/*": ["./src/renderer/*"],
|
||||||
"@preload/*": ["./src/preload/*"],
|
"@preload/*": ["./src/preload/*"],
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,19 @@
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"@features/*": ["./src/features/*"],
|
||||||
"@main/*": ["./src/main/*"],
|
"@main/*": ["./src/main/*"],
|
||||||
"@preload/*": ["./src/preload/*"],
|
"@preload/*": ["./src/preload/*"],
|
||||||
"@shared/*": ["./src/shared/*"]
|
"@shared/*": ["./src/shared/*"]
|
||||||
},
|
},
|
||||||
"types": ["node"]
|
"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/**/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
'@features': resolve(__dirname, 'src/features'),
|
||||||
'@shared': resolve(__dirname, 'src/shared'),
|
'@shared': resolve(__dirname, 'src/shared'),
|
||||||
'@main': resolve(__dirname, 'src/main'),
|
'@main': resolve(__dirname, 'src/main'),
|
||||||
'@renderer': resolve(__dirname, 'src/renderer'),
|
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue