Merge branch 'feat/codex-recent-projects' into dev

This commit is contained in:
777genius 2026-04-14 16:07:18 +03:00
commit e99691ad3d
63 changed files with 4411 additions and 1251 deletions

16
AGENTS.md Normal file
View 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).

View file

@ -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

View file

@ -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

View 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

File diff suppressed because it is too large Load diff

View file

@ -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'),

View file

@ -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
View 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).

View file

@ -0,0 +1,5 @@
import type { DashboardRecentProject } from './dto';
export interface RecentProjectsElectronApi {
getDashboardRecentProjects(): Promise<DashboardRecentProject[]>;
}

View 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';

View 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;
}

View file

@ -0,0 +1,3 @@
export type * from './api';
export * from './channels';
export type * from './dto';

View file

@ -0,0 +1,5 @@
import type { RecentProjectAggregate } from '../../domain/models/RecentProjectAggregate';
export interface ListDashboardRecentProjectsResponse {
projects: RecentProjectAggregate[];
}

View file

@ -0,0 +1,3 @@
export interface ClockPort {
now(): number;
}

View file

@ -0,0 +1,5 @@
import type { ListDashboardRecentProjectsResponse } from '../models/ListDashboardRecentProjectsResponse';
export interface ListDashboardRecentProjectsOutputPort<TViewModel> {
present(response: ListDashboardRecentProjectsResponse): TViewModel;
}

View file

@ -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;
}

View file

@ -0,0 +1,4 @@
export interface RecentProjectsCachePort<T> {
get(key: string): Promise<T | null>;
set(key: string, value: T, ttlMs: number): Promise<void>;
}

View file

@ -0,0 +1,7 @@
import type { RecentProjectCandidate } from '../../domain/models/RecentProjectCandidate';
export interface RecentProjectsSourcePort {
readonly sourceId?: string;
readonly timeoutMs?: number;
list(): Promise<RecentProjectCandidate[]>;
}

View file

@ -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 };
}
}
}

View file

@ -0,0 +1 @@
export type ProviderId = 'anthropic' | 'codex' | 'gemini';

View file

@ -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;
}

View file

@ -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;
}

View file

@ -0,0 +1,3 @@
export type RecentProjectOpenTarget =
| { type: 'existing-worktree'; repositoryId: string; worktreeId: string }
| { type: 'synthetic-path'; path: string };

View file

@ -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);
}

View file

@ -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 [];
}
});
}

View file

@ -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);
}

View file

@ -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,
}));
}
}

View file

@ -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();
}
}
}

View file

@ -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,
};
}
}

View file

@ -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}`);
},
};
}

View 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';

View 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,
});
}
}

View file

@ -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 ?? [];
}
);
}
}

View file

@ -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;
}
}

View file

@ -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');
}
}
}
}

View file

@ -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,
};
}
}

View file

@ -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;
}

View file

@ -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),
};
}

View file

@ -0,0 +1 @@
export { createRecentProjectsBridge } from './createRecentProjectsBridge';

View file

@ -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),
}));
}

View file

@ -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 };
}

View file

@ -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,
};
}

View file

@ -0,0 +1 @@
export { RecentProjectsSection } from './ui/RecentProjectsSection';

View 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>
);
};

View file

@ -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 &quot;{searchQuery}&quot;</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>
);
};

View 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,
};
}

View file

@ -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)
);
}

View file

@ -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');

View file

@ -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();

View file

@ -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,
};

View file

@ -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> => {

View file

@ -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[];

View file

@ -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 &quot;{searchQuery}&quot;</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>
);

View 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>
);
};

View file

@ -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

View file

@ -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[]>;

View file

@ -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();
}
});
});

View file

@ -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();
});
});

View file

@ -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',
},
],
},
});
});
});

View file

@ -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();
});
});

View file

@ -13,6 +13,7 @@
"noEmit": true,
"baseUrl": ".",
"paths": {
"@features/*": ["./src/features/*"],
"@main/*": ["./src/main/*"],
"@renderer/*": ["./src/renderer/*"],
"@preload/*": ["./src/preload/*"],

View file

@ -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/**/*"
]
}

View file

@ -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'),