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/*`
|
||||
|
||||
## Features Architecture
|
||||
**All new features MUST be created in `src/renderer/features/<feature-name>/`.**
|
||||
See `src/renderer/features/CLAUDE.md` for the full guide on creating features with Clean Architecture, SOLID, and class-based patterns.
|
||||
**All new medium and large features should follow the canonical slice standard in [`docs/FEATURE_ARCHITECTURE_STANDARD.md`](docs/FEATURE_ARCHITECTURE_STANDARD.md).**
|
||||
|
||||
Default location:
|
||||
- `src/features/<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
|
||||
~/.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)
|
||||
- [Quick start](#quick-start)
|
||||
- [FAQ](#faq)
|
||||
- [Developer architecture docs](#developer-architecture-docs)
|
||||
- [Development](#development)
|
||||
- [Tech stack](#tech-stack)
|
||||
- [Build for distribution](#build-for-distribution)
|
||||
|
|
@ -118,6 +119,15 @@ A local orchestration layer for AI agent teams across Claude and Codex.
|
|||
- **Task-specific logs and messages** — clearly see agent/runtime logs and messages in isolation for each individual task, making it easy to trace what happened for any assignment
|
||||
- **Live process section** — see which agents are running processes and open URLs directly in the browser
|
||||
- **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work
|
||||
|
||||
## Developer architecture docs
|
||||
|
||||
For feature architecture and implementation guidance:
|
||||
|
||||
- Canonical standard - [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md)
|
||||
- Repo working instructions - [CLAUDE.md](CLAUDE.md)
|
||||
- Feature root guidance - [src/features/README.md](src/features/README.md)
|
||||
- Reference implementation - `src/features/recent-projects`
|
||||
- **Flexible autonomy** — let agents run fully autonomous, or review and approve each action one by one (you'll get a notification) — configure the level of control that fits your security needs
|
||||
- **Solo mode** — one-member team: a single agent that creates its own tasks and shows live progress. Saves tokens; can expand to a full team anytime
|
||||
|
||||
|
|
|
|||
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: {
|
||||
alias: {
|
||||
'@features': resolve(__dirname, 'src/features'),
|
||||
'@main': resolve(__dirname, 'src/main'),
|
||||
'@shared': resolve(__dirname, 'src/shared'),
|
||||
'@preload': resolve(__dirname, 'src/preload')
|
||||
|
|
@ -111,6 +112,7 @@ export default defineConfig({
|
|||
preload: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@features': resolve(__dirname, 'src/features'),
|
||||
'@preload': resolve(__dirname, 'src/preload'),
|
||||
'@shared': resolve(__dirname, 'src/shared'),
|
||||
'@main': resolve(__dirname, 'src/main')
|
||||
|
|
@ -141,6 +143,7 @@ export default defineConfig({
|
|||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@features': resolve(__dirname, 'src/features'),
|
||||
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||
'@shared': resolve(__dirname, 'src/shared'),
|
||||
'@main': resolve(__dirname, 'src/main'),
|
||||
|
|
|
|||
135
eslint.config.js
135
eslint.config.js
|
|
@ -78,7 +78,7 @@ export default defineConfig([
|
|||
// Import plugin configuration - Renderer (uses tsconfig.json)
|
||||
{
|
||||
name: 'import-plugin-renderer',
|
||||
files: ['src/renderer/**/*.{ts,tsx}'],
|
||||
files: ['src/renderer/**/*.{ts,tsx}', 'src/features/**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
|
|
@ -97,6 +97,137 @@ export default defineConfig([
|
|||
},
|
||||
},
|
||||
|
||||
// Feature-specific architecture guard rails - recent-projects
|
||||
{
|
||||
name: 'feature-recent-projects-public-entrypoints',
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
ignores: ['src/features/recent-projects/**/*'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
'@features/recent-projects/contracts/**',
|
||||
'@features/recent-projects/core/**',
|
||||
'@features/recent-projects/main/**',
|
||||
'@features/recent-projects/preload/**',
|
||||
'@features/recent-projects/renderer/**',
|
||||
],
|
||||
message:
|
||||
'Import recent-projects only through its public entrypoints: @features/recent-projects/contracts, @features/recent-projects/main, @features/recent-projects/preload, or @features/recent-projects/renderer.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'feature-recent-projects-core-domain-guards',
|
||||
files: ['src/features/recent-projects/core/domain/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
'@features/recent-projects/core/application/**',
|
||||
'@features/recent-projects/main/**',
|
||||
'@features/recent-projects/preload/**',
|
||||
'@features/recent-projects/renderer/**',
|
||||
'@main/**',
|
||||
'@renderer/**',
|
||||
'@preload/**',
|
||||
'electron',
|
||||
'fastify',
|
||||
'child_process',
|
||||
'node:child_process',
|
||||
],
|
||||
message:
|
||||
'recent-projects core/domain must stay side-effect free and cannot depend on application, adapters, infrastructure, or platform code.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'feature-recent-projects-core-application-guards',
|
||||
files: ['src/features/recent-projects/core/application/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
'@features/recent-projects/main/**',
|
||||
'@features/recent-projects/preload/**',
|
||||
'@features/recent-projects/renderer/**',
|
||||
'@renderer/**',
|
||||
'electron',
|
||||
'fastify',
|
||||
'child_process',
|
||||
'node:child_process',
|
||||
],
|
||||
message:
|
||||
'recent-projects core/application may depend only on domain, contracts, and application ports - not on adapters or runtime frameworks.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'feature-recent-projects-preload-guards',
|
||||
files: ['src/features/recent-projects/preload/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
'@features/recent-projects/main/**',
|
||||
'@main/**',
|
||||
'@renderer/**',
|
||||
],
|
||||
message:
|
||||
'recent-projects preload may depend only on contracts and preload-local bridge helpers.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'feature-recent-projects-renderer-ui-guards',
|
||||
files: ['src/features/recent-projects/renderer/ui/**/*.tsx'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
'@renderer/api',
|
||||
'@renderer/api/**',
|
||||
'@renderer/store',
|
||||
'@renderer/store/**',
|
||||
'@main/**',
|
||||
'electron',
|
||||
],
|
||||
message:
|
||||
'recent-projects renderer/ui must stay presentational. Move transport, store access, and navigation logic into hooks or adapters.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Module boundaries - Enforce Electron three-process architecture
|
||||
{
|
||||
name: 'module-boundaries',
|
||||
|
|
@ -548,7 +679,7 @@ export default defineConfig([
|
|||
|
||||
// === Import Restrictions ===
|
||||
// Note: boundaries/element-types handles main/renderer separation
|
||||
'no-restricted-imports': 'warn',
|
||||
'no-restricted-imports': 'off',
|
||||
|
||||
// === Mutation Prevention ===
|
||||
'no-param-reassign': 'warn',
|
||||
|
|
|
|||
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.
|
||||
*/
|
||||
|
||||
import {
|
||||
type RecentProjectsFeatureFacade,
|
||||
registerRecentProjectsHttp,
|
||||
} from '@features/recent-projects/main';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { registerConfigRoutes } from './config';
|
||||
|
|
@ -40,6 +44,7 @@ export interface HttpServices {
|
|||
subagentResolver: SubagentResolver;
|
||||
chunkBuilder: ChunkBuilder;
|
||||
dataCache: DataCache;
|
||||
recentProjectsFeature?: RecentProjectsFeatureFacade;
|
||||
updaterService: UpdaterService;
|
||||
sshConnectionManager: SshConnectionManager;
|
||||
teamProvisioningService?: TeamProvisioningService;
|
||||
|
|
@ -63,6 +68,9 @@ export function registerHttpRoutes(
|
|||
registerUtilityRoutes(app);
|
||||
registerSshRoutes(app, services.sshConnectionManager, sshModeSwitchCallback);
|
||||
registerUpdaterRoutes(app, services);
|
||||
if (services.recentProjectsFeature) {
|
||||
registerRecentProjectsHttp(app, services.recentProjectsFeature);
|
||||
}
|
||||
registerEventRoutes(app);
|
||||
|
||||
logger.info('All HTTP routes registered');
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ process.env.UV_THREADPOOL_SIZE ??= '16';
|
|||
// Sentry must be the first import to capture early errors.
|
||||
import './sentry';
|
||||
|
||||
import {
|
||||
createRecentProjectsFeature,
|
||||
type RecentProjectsFeatureFacade,
|
||||
registerRecentProjectsIpc,
|
||||
removeRecentProjectsIpc,
|
||||
} from '@features/recent-projects/main';
|
||||
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
||||
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
|
||||
import { SchedulerService } from '@main/services/schedule/SchedulerService';
|
||||
|
|
@ -54,7 +60,7 @@ import {
|
|||
import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics';
|
||||
import { parseInboxJson } from '@shared/utils/inboxNoise';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
|
|
@ -102,8 +108,8 @@ import {
|
|||
} from './utils/safeWebContentsSend';
|
||||
import { syncTelemetryFlag } from './sentry';
|
||||
import {
|
||||
BoardTaskActivityRecordSource,
|
||||
BoardTaskActivityDetailService,
|
||||
BoardTaskActivityRecordSource,
|
||||
BoardTaskActivityService,
|
||||
BoardTaskExactLogDetailService,
|
||||
BoardTaskExactLogsService,
|
||||
|
|
@ -399,6 +405,7 @@ let contextRegistry: ServiceContextRegistry;
|
|||
let notificationManager: NotificationManager;
|
||||
let updaterService: UpdaterService;
|
||||
let sshConnectionManager: SshConnectionManager;
|
||||
let recentProjectsFeature: RecentProjectsFeatureFacade;
|
||||
let teamDataService: TeamDataService;
|
||||
let teamProvisioningService: TeamProvisioningService;
|
||||
let cliInstallerService: CliInstallerService;
|
||||
|
|
@ -927,6 +934,11 @@ async function initializeServices(): Promise<void> {
|
|||
});
|
||||
|
||||
teamProvisioningService.setMainWindow(mainWindow);
|
||||
recentProjectsFeature = createRecentProjectsFeature({
|
||||
getActiveContext: () => contextRegistry.getActive(),
|
||||
getLocalContext: () => contextRegistry.get('local'),
|
||||
logger: createLogger('Feature:RecentProjects'),
|
||||
});
|
||||
|
||||
// startProcessHealthPolling() is deferred to after window creation
|
||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||
|
|
@ -980,6 +992,7 @@ async function initializeServices(): Promise<void> {
|
|||
crossTeamService,
|
||||
teamBackupService ?? undefined
|
||||
);
|
||||
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
|
||||
|
||||
// Forward SSH state changes to renderer and HTTP SSE clients
|
||||
sshConnectionManager.on('state-change', (status: unknown) => {
|
||||
|
|
@ -1028,6 +1041,7 @@ async function startHttpServer(
|
|||
subagentResolver: activeContext.subagentResolver,
|
||||
chunkBuilder: activeContext.chunkBuilder,
|
||||
dataCache: activeContext.dataCache,
|
||||
recentProjectsFeature,
|
||||
updaterService,
|
||||
sshConnectionManager,
|
||||
teamProvisioningService,
|
||||
|
|
@ -1119,6 +1133,7 @@ function shutdownServices(): void {
|
|||
|
||||
// Remove IPC handlers
|
||||
removeIpcHandlers();
|
||||
removeRecentProjectsIpc(ipcMain);
|
||||
|
||||
// Dispose backup service timers
|
||||
teamBackupService?.dispose();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
// runtime which is unavailable in standalone (pure Node.js) mode. Standalone
|
||||
// error tracking can be added later with @sentry/node if needed.
|
||||
|
||||
import { createRecentProjectsFeature } from '@features/recent-projects/main';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider';
|
||||
|
|
@ -130,6 +131,11 @@ async function start(): Promise<void> {
|
|||
|
||||
// Create HTTP server
|
||||
httpServer = new HttpServer();
|
||||
const recentProjectsFeature = createRecentProjectsFeature({
|
||||
getActiveContext: () => localContext,
|
||||
getLocalContext: () => localContext,
|
||||
logger: createLogger('Feature:RecentProjects'),
|
||||
});
|
||||
|
||||
// Wire file watcher events to SSE broadcast
|
||||
localContext.fileWatcher.on('file-change', (event: unknown) => {
|
||||
|
|
@ -157,6 +163,7 @@ async function start(): Promise<void> {
|
|||
subagentResolver: localContext.subagentResolver,
|
||||
chunkBuilder: localContext.chunkBuilder,
|
||||
dataCache: localContext.dataCache,
|
||||
recentProjectsFeature,
|
||||
updaterService: updaterServiceStub,
|
||||
sshConnectionManager: sshConnectionManagerStub,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { createRecentProjectsBridge } from '@features/recent-projects/preload';
|
||||
import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
||||
|
||||
|
|
@ -243,6 +244,7 @@ import type {
|
|||
ClaudeRootInfo,
|
||||
CliInstallationStatus,
|
||||
CliInstallerProgress,
|
||||
CliProviderId,
|
||||
ConflictCheckResult,
|
||||
ContextInfo,
|
||||
CreateScheduleInput,
|
||||
|
|
@ -448,6 +450,7 @@ ipcRenderer.on(
|
|||
// Expose protected methods that allow the renderer process to use
|
||||
// the ipcRenderer without exposing the entire object
|
||||
const electronAPI: ElectronAPI = {
|
||||
...createRecentProjectsBridge(),
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
getProjects: () => ipcRenderer.invoke('get-projects'),
|
||||
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),
|
||||
|
|
@ -1408,7 +1411,7 @@ const electronAPI: ElectronAPI = {
|
|||
getStatus: async (): Promise<CliInstallationStatus> => {
|
||||
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);
|
||||
},
|
||||
install: async (): Promise<void> => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
* to run in a regular browser connected to an HTTP server.
|
||||
*/
|
||||
|
||||
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||
import type {
|
||||
AppConfig,
|
||||
AttachmentFileData,
|
||||
|
|
@ -214,6 +215,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
|
||||
getAppVersion = (): Promise<string> => this.get<string>('/api/version');
|
||||
|
||||
getDashboardRecentProjects = (): Promise<DashboardRecentProject[]> =>
|
||||
this.get<DashboardRecentProject[]>('/api/dashboard/recent-projects');
|
||||
|
||||
getProjects = (): Promise<Project[]> => this.get<Project[]>('/api/projects');
|
||||
|
||||
getSessions = (projectId: string): Promise<Session[]> =>
|
||||
|
|
@ -1218,7 +1222,7 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
},
|
||||
};
|
||||
|
||||
schedules = {
|
||||
schedules: ElectronAPI['schedules'] = {
|
||||
list: async () => {
|
||||
console.warn('Schedules not available in browser mode');
|
||||
return [] as Schedule[];
|
||||
|
|
|
|||
|
|
@ -1,53 +1,20 @@
|
|||
/**
|
||||
* DashboardView - Main dashboard with "Productivity Luxury" aesthetic.
|
||||
* Inspired by Linear, Vercel, and Raycast design patterns.
|
||||
* Features:
|
||||
* - Subtle spotlight gradient
|
||||
* - Centralized command search with inline project filtering
|
||||
* - Border-first project cards with minimal backgrounds
|
||||
* DashboardView - Main dashboard shell.
|
||||
* Keeps only screen composition and delegates recent-projects logic to the feature slice.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { RecentProjectsSection } from '@features/recent-projects/renderer';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers';
|
||||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||
import {
|
||||
buildTaskCountsByProject,
|
||||
normalizePath,
|
||||
type TaskStatusCounts,
|
||||
} from '@renderer/utils/pathNormalize';
|
||||
import { projectColor } from '@renderer/utils/projectColor';
|
||||
import { formatShortcut } from '@renderer/utils/stringUtils';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { Command, Search, Users } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
const logger = createLogger('Component:DashboardView');
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
Command,
|
||||
FolderGit2,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
GitFork,
|
||||
Search,
|
||||
Terminal,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { CliStatusBanner } from './CliStatusBanner';
|
||||
import { DashboardUpdateBanner } from './DashboardUpdateBanner';
|
||||
import { TmuxStatusBanner } from './TmuxStatusBanner';
|
||||
|
||||
import type { RepositoryGroup } from '@renderer/types/data';
|
||||
import type { TeamSummary } from '@shared/types';
|
||||
|
||||
// =============================================================================
|
||||
// Command Search Input
|
||||
// =============================================================================
|
||||
import { WebPreviewBanner } from './WebPreviewBanner';
|
||||
|
||||
interface CommandSearchProps {
|
||||
value: string;
|
||||
|
|
@ -58,17 +25,16 @@ const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React
|
|||
const [isFocused, setIsFocused] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { openCommandPalette, selectedProjectId } = useStore(
|
||||
useShallow((s) => ({
|
||||
openCommandPalette: s.openCommandPalette,
|
||||
selectedProjectId: s.selectedProjectId,
|
||||
useShallow((state) => ({
|
||||
openCommandPalette: state.openCommandPalette,
|
||||
selectedProjectId: state.selectedProjectId,
|
||||
}))
|
||||
);
|
||||
|
||||
// Handle Cmd+K to open full command palette
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.code === 'KeyK') {
|
||||
e.preventDefault();
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.code === 'KeyK') {
|
||||
event.preventDefault();
|
||||
openCommandPalette();
|
||||
}
|
||||
};
|
||||
|
|
@ -77,24 +43,24 @@ const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React
|
|||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [openCommandPalette]);
|
||||
|
||||
// Focus search when the dashboard mounts (packaged Electron can skip native autoFocus).
|
||||
useLayoutEffect(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) {
|
||||
const input = inputRef.current;
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
el.focus({ preventScroll: true });
|
||||
const t = window.setTimeout(() => {
|
||||
if (document.activeElement !== el) {
|
||||
el.focus({ preventScroll: true });
|
||||
|
||||
input.focus({ preventScroll: true });
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (document.activeElement !== input) {
|
||||
input.focus({ preventScroll: true });
|
||||
}
|
||||
}, 50);
|
||||
return () => window.clearTimeout(t);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{/* Search container with glow effect on focus */}
|
||||
<div
|
||||
className={`relative flex items-center gap-3 rounded-sm border bg-surface-raised px-4 py-3 transition-all duration-200 ${
|
||||
isFocused
|
||||
|
|
@ -107,13 +73,12 @@ const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React
|
|||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder="Search projects..."
|
||||
className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-muted"
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
{/* Keyboard shortcut badge - opens full command palette */}
|
||||
<button
|
||||
onClick={() => openCommandPalette()}
|
||||
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 => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const openTeamsTab = useStore((s) => s.openTeamsTab);
|
||||
const openTeamsTab = useStore((state) => state.openTeamsTab);
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 overflow-auto bg-surface">
|
||||
{/* Spotlight gradient background */}
|
||||
<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)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative mx-auto max-w-5xl px-8 py-12">
|
||||
{/* App update banner */}
|
||||
<WebPreviewBanner />
|
||||
<DashboardUpdateBanner />
|
||||
|
||||
{/* CLI Status Banner */}
|
||||
<CliStatusBanner />
|
||||
<TmuxStatusBanner />
|
||||
|
||||
{/* Team select + Search */}
|
||||
<div className="mb-12 flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={openTeamsTab}
|
||||
|
|
@ -855,7 +131,6 @@ export const DashboardView = (): React.JSX.Element => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wider text-text-muted">
|
||||
{searchQuery.trim() ? 'Search Results' : 'Recent Projects'}
|
||||
|
|
@ -870,8 +145,7 @@ export const DashboardView = (): React.JSX.Element => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Projects Grid */}
|
||||
<ProjectsGrid searchQuery={searchQuery} />
|
||||
<RecentProjectsSection searchQuery={searchQuery} />
|
||||
</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
|
||||
mkdir -p src/renderer/features/<feature-name>/{ports,adapters,domain,ui,hooks,__tests__}
|
||||
```
|
||||
Reference implementation:
|
||||
- `src/features/recent-projects`
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
### 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`
|
||||
Keep `src/renderer/features/*` for:
|
||||
- existing legacy slices
|
||||
- renderer-only thin integrations
|
||||
- work that does not introduce a new use case, transport boundary, or cross-process architecture
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ import type {
|
|||
import type {
|
||||
AddMemberRequest,
|
||||
AddTaskCommentRequest,
|
||||
BoardTaskActivityDetailResult,
|
||||
AttachmentFileData,
|
||||
BoardTaskActivityDetailResult,
|
||||
BoardTaskActivityEntry,
|
||||
BoardTaskExactLogDetailResult,
|
||||
BoardTaskExactLogSummariesResponse,
|
||||
|
|
@ -89,6 +89,7 @@ import type {
|
|||
import type { TerminalAPI } from './terminal';
|
||||
import type { TmuxAPI } from './tmux';
|
||||
import type { WaterfallData } from './visualization';
|
||||
import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts';
|
||||
import type {
|
||||
ConversationGroup,
|
||||
FileChangeEvent,
|
||||
|
|
@ -721,7 +722,7 @@ export interface ReviewAPI {
|
|||
/**
|
||||
* Complete Electron API exposed to the renderer process via preload script.
|
||||
*/
|
||||
export interface ElectronAPI {
|
||||
export interface ElectronAPI extends RecentProjectsElectronApi {
|
||||
getAppVersion: () => Promise<string>;
|
||||
getProjects: () => Promise<Project[]>;
|
||||
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,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@features/*": ["./src/features/*"],
|
||||
"@main/*": ["./src/main/*"],
|
||||
"@renderer/*": ["./src/renderer/*"],
|
||||
"@preload/*": ["./src/preload/*"],
|
||||
|
|
|
|||
|
|
@ -12,11 +12,19 @@
|
|||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@features/*": ["./src/features/*"],
|
||||
"@main/*": ["./src/main/*"],
|
||||
"@preload/*": ["./src/preload/*"],
|
||||
"@shared/*": ["./src/shared/*"]
|
||||
},
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["electron.vite.config.ts", "src/main/**/*", "src/preload/**/*"]
|
||||
"include": [
|
||||
"electron.vite.config.ts",
|
||||
"src/main/**/*",
|
||||
"src/preload/**/*",
|
||||
"src/features/*/contracts/**/*",
|
||||
"src/features/*/main/**/*",
|
||||
"src/features/*/preload/**/*"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export default defineConfig({
|
|||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@features': resolve(__dirname, 'src/features'),
|
||||
'@shared': resolve(__dirname, 'src/shared'),
|
||||
'@main': resolve(__dirname, 'src/main'),
|
||||
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue