-
+ {sidebarVisible ? (
+
+ ) : null}
setFullscreen(true)}
onOpenTeamPage={openTeamPage}
onCreateTask={openCreateTask}
- renderHud={({
- getLaunchAnchorScreenPlacement,
- getActivityAnchorScreenPlacement,
- getNodeScreenPosition,
- focusNodeIds,
- }) => (
- <>
-
-
- >
+ onToggleSidebar={toggleSidebarVisible}
+ isSidebarVisible={sidebarVisible}
+ renderTopToolbarContent={() => (
+
)}
+ onOwnerSlotDrop={commitOwnerSlotDrop}
+ renderHud={(hudProps) => {
+ const extraHudProps = hudProps as typeof hudProps & {
+ getViewportSize?: () => { width: number; height: number };
+ getActivityWorldRect?: (ownerNodeId: string) => {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+ width: number;
+ height: number;
+ } | null;
+ getCameraZoom?: () => number;
+ worldToScreen?: (x: number, y: number) => { x: number; y: number };
+ getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
+ };
+ const { getViewportSize, focusNodeIds } = extraHudProps;
+
+ return (
+ <>
+
+ >
+ );
+ }}
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => (
setFullscreen(false)}
+ sidebarVisible={sidebarVisible}
+ onToggleSidebar={toggleSidebarVisible}
onSendMessage={dispatchSendMessage}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
diff --git a/src/features/recent-projects/contracts/api.ts b/src/features/recent-projects/contracts/api.ts
new file mode 100644
index 00000000..c1a74622
--- /dev/null
+++ b/src/features/recent-projects/contracts/api.ts
@@ -0,0 +1,5 @@
+import type { DashboardRecentProjectsPayload } from './dto';
+
+export interface RecentProjectsElectronApi {
+ getDashboardRecentProjects(): Promise;
+}
diff --git a/src/features/recent-projects/contracts/channels.ts b/src/features/recent-projects/contracts/channels.ts
new file mode 100644
index 00000000..ce463578
--- /dev/null
+++ b/src/features/recent-projects/contracts/channels.ts
@@ -0,0 +1,2 @@
+export const GET_DASHBOARD_RECENT_PROJECTS = 'get-dashboard-recent-projects';
+export const DASHBOARD_RECENT_PROJECTS_ROUTE = '/api/dashboard/recent-projects';
diff --git a/src/features/recent-projects/contracts/dto.ts b/src/features/recent-projects/contracts/dto.ts
new file mode 100644
index 00000000..bdb4eda0
--- /dev/null
+++ b/src/features/recent-projects/contracts/dto.ts
@@ -0,0 +1,24 @@
+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;
+}
+
+export interface DashboardRecentProjectsPayload {
+ projects: DashboardRecentProject[];
+ degraded: boolean;
+}
diff --git a/src/features/recent-projects/contracts/index.ts b/src/features/recent-projects/contracts/index.ts
new file mode 100644
index 00000000..41e0bc74
--- /dev/null
+++ b/src/features/recent-projects/contracts/index.ts
@@ -0,0 +1,4 @@
+export type * from './api';
+export * from './channels';
+export type * from './dto';
+export * from './normalize';
diff --git a/src/features/recent-projects/contracts/normalize.ts b/src/features/recent-projects/contracts/normalize.ts
new file mode 100644
index 00000000..116912e6
--- /dev/null
+++ b/src/features/recent-projects/contracts/normalize.ts
@@ -0,0 +1,32 @@
+import type { DashboardRecentProject, DashboardRecentProjectsPayload } from './dto';
+
+export type DashboardRecentProjectsPayloadLike =
+ | DashboardRecentProjectsPayload
+ | DashboardRecentProject[]
+ | { degraded?: unknown; projects?: unknown }
+ | null
+ | undefined;
+
+export function normalizeDashboardRecentProjectsPayload(
+ value: DashboardRecentProjectsPayloadLike
+): DashboardRecentProjectsPayload | null {
+ if (!value) {
+ return null;
+ }
+
+ if (Array.isArray(value)) {
+ return {
+ projects: value,
+ degraded: false,
+ };
+ }
+
+ if (!Array.isArray(value.projects)) {
+ return null;
+ }
+
+ return {
+ projects: value.projects,
+ degraded: value.degraded === true,
+ };
+}
diff --git a/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts b/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts
new file mode 100644
index 00000000..0800ee06
--- /dev/null
+++ b/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts
@@ -0,0 +1,6 @@
+import type { RecentProjectAggregate } from '../../domain/models/RecentProjectAggregate';
+
+export interface ListDashboardRecentProjectsResponse {
+ projects: RecentProjectAggregate[];
+ degraded: boolean;
+}
diff --git a/src/features/recent-projects/core/application/ports/ClockPort.ts b/src/features/recent-projects/core/application/ports/ClockPort.ts
new file mode 100644
index 00000000..b7b6878b
--- /dev/null
+++ b/src/features/recent-projects/core/application/ports/ClockPort.ts
@@ -0,0 +1,3 @@
+export interface ClockPort {
+ now(): number;
+}
diff --git a/src/features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort.ts b/src/features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort.ts
new file mode 100644
index 00000000..b3fec9d4
--- /dev/null
+++ b/src/features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort.ts
@@ -0,0 +1,5 @@
+import type { ListDashboardRecentProjectsResponse } from '../models/ListDashboardRecentProjectsResponse';
+
+export interface ListDashboardRecentProjectsOutputPort {
+ present(response: ListDashboardRecentProjectsResponse): TViewModel;
+}
diff --git a/src/features/recent-projects/core/application/ports/LoggerPort.ts b/src/features/recent-projects/core/application/ports/LoggerPort.ts
new file mode 100644
index 00000000..d265d820
--- /dev/null
+++ b/src/features/recent-projects/core/application/ports/LoggerPort.ts
@@ -0,0 +1,5 @@
+export interface LoggerPort {
+ info(message: string, meta?: Record): void;
+ warn(message: string, meta?: Record): void;
+ error(message: string, meta?: Record): void;
+}
diff --git a/src/features/recent-projects/core/application/ports/RecentProjectsCachePort.ts b/src/features/recent-projects/core/application/ports/RecentProjectsCachePort.ts
new file mode 100644
index 00000000..92070e30
--- /dev/null
+++ b/src/features/recent-projects/core/application/ports/RecentProjectsCachePort.ts
@@ -0,0 +1,5 @@
+export interface RecentProjectsCachePort {
+ get(key: string): Promise;
+ getStale(key: string): Promise;
+ set(key: string, value: T, ttlMs: number): Promise;
+}
diff --git a/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts b/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts
new file mode 100644
index 00000000..cba03607
--- /dev/null
+++ b/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts
@@ -0,0 +1,14 @@
+import type { RecentProjectCandidate } from '../../domain/models/RecentProjectCandidate';
+
+export interface RecentProjectsSourceResult {
+ candidates: RecentProjectCandidate[];
+ degraded: boolean;
+}
+
+export type RecentProjectsSourcePayload = RecentProjectsSourceResult | RecentProjectCandidate[];
+
+export interface RecentProjectsSourcePort {
+ readonly sourceId?: string;
+ readonly timeoutMs?: number;
+ list(): Promise;
+}
diff --git a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts
new file mode 100644
index 00000000..348fc1b4
--- /dev/null
+++ b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts
@@ -0,0 +1,193 @@
+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 {
+ RecentProjectsSourcePayload,
+ RecentProjectsSourcePort,
+ RecentProjectsSourceResult,
+} from '../ports/RecentProjectsSourcePort';
+
+const DEFAULT_CACHE_TTL_MS = 10_000;
+const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500;
+
+interface SourceLoadResult {
+ candidates: RecentProjectCandidate[];
+ degraded: boolean;
+}
+
+function normalizeSourcePayload(payload: RecentProjectsSourcePayload): RecentProjectsSourceResult {
+ if (Array.isArray(payload)) {
+ return {
+ candidates: payload,
+ degraded: false,
+ };
+ }
+
+ return {
+ candidates: payload.candidates,
+ degraded: payload.degraded === true,
+ };
+}
+
+export interface ListDashboardRecentProjectsDeps {
+ sources: RecentProjectsSourcePort[];
+ cache: RecentProjectsCachePort;
+ output: ListDashboardRecentProjectsOutputPort;
+ clock: ClockPort;
+ logger: LoggerPort;
+ cacheTtlMs?: number;
+ degradedCacheTtlMs?: number;
+}
+
+export class ListDashboardRecentProjectsUseCase {
+ readonly #cacheTtlMs: number;
+ readonly #degradedCacheTtlMs: number;
+ readonly #inFlightByCacheKey = new Map>();
+
+ constructor(private readonly deps: ListDashboardRecentProjectsDeps) {
+ this.#cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
+ this.#degradedCacheTtlMs = deps.degradedCacheTtlMs ?? DEFAULT_DEGRADED_CACHE_TTL_MS;
+ }
+
+ async execute(cacheKey: string): Promise {
+ const cached = await this.deps.cache.get(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
+ const inFlight = this.#inFlightByCacheKey.get(cacheKey);
+ if (inFlight) {
+ return inFlight;
+ }
+
+ const execution = this.#executeUncached(cacheKey).finally(() => {
+ this.#inFlightByCacheKey.delete(cacheKey);
+ });
+
+ this.#inFlightByCacheKey.set(cacheKey, execution);
+ return execution;
+ }
+
+ async #executeUncached(cacheKey: string): Promise {
+ const startedAt = this.deps.clock.now();
+ const stale = await this.deps.cache.getStale(cacheKey);
+ 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),
+ degraded: hasDegradedSources,
+ };
+
+ if (hasDegradedSources && stale && response.projects.length === 0) {
+ await this.deps.cache.set(cacheKey, stale, this.#degradedCacheTtlMs);
+ this.deps.logger.info('recent-projects served stale cache', {
+ cacheKey,
+ degradedSources: results.filter((result) => result.degraded).length,
+ cacheTtlMs: this.#degradedCacheTtlMs,
+ durationMs: this.deps.clock.now() - startedAt,
+ });
+ return stale;
+ }
+
+ const viewModel = this.deps.output.present(response);
+ const cacheTtlMs = hasDegradedSources
+ ? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs)
+ : this.#cacheTtlMs;
+
+ await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs);
+ this.deps.logger.info('recent-projects loaded', {
+ cacheKey,
+ count: response.projects.length,
+ degradedSources: results.filter((result) => result.degraded).length,
+ cacheTtlMs,
+ durationMs: this.deps.clock.now() - startedAt,
+ });
+
+ return viewModel;
+ }
+
+ async #loadSource(
+ source: RecentProjectsSourcePort,
+ sourceIndex: number
+ ): Promise {
+ const sourceId = source.sourceId ?? `source-${sourceIndex}`;
+ if (!source.timeoutMs || source.timeoutMs <= 0) {
+ return this.#loadSourceWithoutTimeout(source, sourceId, sourceIndex);
+ }
+
+ let timer: ReturnType | null = null;
+ try {
+ const result = await Promise.race([
+ source
+ .list()
+ .then(
+ (payload) =>
+ ({
+ kind: 'success',
+ payload: normalizeSourcePayload(payload),
+ }) 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 result.payload;
+ }
+
+ if (result.kind === 'timeout') {
+ this.deps.logger.warn('recent-projects source timed out', {
+ sourceId,
+ sourceIndex,
+ timeoutMs: source.timeoutMs,
+ });
+ return { candidates: [], degraded: true };
+ }
+
+ this.deps.logger.warn('recent-projects source failed', {
+ sourceId,
+ sourceIndex,
+ error: result.error instanceof Error ? result.error.message : String(result.error),
+ });
+ return { candidates: [], degraded: true };
+ } finally {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ }
+ }
+
+ async #loadSourceWithoutTimeout(
+ source: RecentProjectsSourcePort,
+ sourceId: string,
+ sourceIndex: number
+ ): Promise {
+ try {
+ return normalizeSourcePayload(await source.list());
+ } catch (error) {
+ this.deps.logger.warn('recent-projects source failed', {
+ sourceId,
+ sourceIndex,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return { candidates: [], degraded: true };
+ }
+ }
+}
diff --git a/src/features/recent-projects/core/domain/models/ProviderId.ts b/src/features/recent-projects/core/domain/models/ProviderId.ts
new file mode 100644
index 00000000..d55bf3cd
--- /dev/null
+++ b/src/features/recent-projects/core/domain/models/ProviderId.ts
@@ -0,0 +1 @@
+export type ProviderId = 'anthropic' | 'codex' | 'gemini';
diff --git a/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts b/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts
new file mode 100644
index 00000000..9c098a65
--- /dev/null
+++ b/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts
@@ -0,0 +1,14 @@
+import type { ProviderId } from './ProviderId';
+import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget';
+
+export interface RecentProjectAggregate {
+ identity: string;
+ displayName: string;
+ primaryPath: string;
+ associatedPaths: string[];
+ lastActivityAt: number;
+ providerIds: ProviderId[];
+ source: 'claude' | 'codex' | 'mixed';
+ openTarget: RecentProjectOpenTarget;
+ branchName?: string;
+}
diff --git a/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts b/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts
new file mode 100644
index 00000000..2abc5315
--- /dev/null
+++ b/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts
@@ -0,0 +1,14 @@
+import type { ProviderId } from './ProviderId';
+import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget';
+
+export interface RecentProjectCandidate {
+ identity: string;
+ displayName: string;
+ primaryPath: string;
+ associatedPaths: string[];
+ lastActivityAt: number;
+ providerIds: ProviderId[];
+ sourceKind: 'claude' | 'codex';
+ openTarget: RecentProjectOpenTarget;
+ branchName?: string;
+}
diff --git a/src/features/recent-projects/core/domain/models/RecentProjectOpenTarget.ts b/src/features/recent-projects/core/domain/models/RecentProjectOpenTarget.ts
new file mode 100644
index 00000000..c4ae3214
--- /dev/null
+++ b/src/features/recent-projects/core/domain/models/RecentProjectOpenTarget.ts
@@ -0,0 +1,3 @@
+export type RecentProjectOpenTarget =
+ | { type: 'existing-worktree'; repositoryId: string; worktreeId: string }
+ | { type: 'synthetic-path'; path: string };
diff --git a/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts b/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts
new file mode 100644
index 00000000..ce3be598
--- /dev/null
+++ b/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts
@@ -0,0 +1,88 @@
+import type { ProviderId } from '../models/ProviderId';
+import type { RecentProjectAggregate } from '../models/RecentProjectAggregate';
+import type { RecentProjectCandidate } from '../models/RecentProjectCandidate';
+
+function uniquePaths(paths: readonly string[], primaryPath: string): string[] {
+ const ordered = [primaryPath, ...paths];
+ const seen = new Set();
+ const result: string[] = [];
+
+ for (const path of ordered) {
+ if (!path || seen.has(path)) {
+ continue;
+ }
+ seen.add(path);
+ result.push(path);
+ }
+
+ return result;
+}
+
+function uniqueProviders(providerIds: readonly ProviderId[]): ProviderId[] {
+ return Array.from(new Set(providerIds));
+}
+
+function selectPreferredCandidate(
+ candidates: readonly RecentProjectCandidate[]
+): RecentProjectCandidate {
+ const existingWorktreeCandidates = candidates.filter(
+ (candidate) => candidate.openTarget.type === 'existing-worktree'
+ );
+ const pool = existingWorktreeCandidates.length > 0 ? existingWorktreeCandidates : candidates;
+
+ return [...pool].sort((left, right) => {
+ if (right.lastActivityAt !== left.lastActivityAt) {
+ return right.lastActivityAt - left.lastActivityAt;
+ }
+ return left.displayName.localeCompare(right.displayName);
+ })[0];
+}
+
+function mergeBranchName(candidates: readonly RecentProjectCandidate[]): string | undefined {
+ const branchNames = Array.from(
+ new Set(candidates.map((candidate) => candidate.branchName?.trim()).filter(Boolean))
+ );
+
+ return branchNames.length === 1 ? branchNames[0] : undefined;
+}
+
+export function mergeRecentProjectCandidates(
+ candidates: readonly RecentProjectCandidate[]
+): RecentProjectAggregate[] {
+ const grouped = new Map();
+
+ for (const candidate of candidates) {
+ if (!candidate.identity || candidate.lastActivityAt <= 0) {
+ continue;
+ }
+ const bucket = grouped.get(candidate.identity);
+ if (bucket) {
+ bucket.push(candidate);
+ } else {
+ grouped.set(candidate.identity, [candidate]);
+ }
+ }
+
+ const aggregates = Array.from(grouped.values()).map((group): RecentProjectAggregate => {
+ const preferred = selectPreferredCandidate(group);
+ const providerIds = uniqueProviders(group.flatMap((candidate) => candidate.providerIds));
+ const sourceKinds = new Set(group.map((candidate) => candidate.sourceKind));
+
+ return {
+ identity: preferred.identity,
+ displayName: preferred.displayName,
+ primaryPath: preferred.primaryPath,
+ associatedPaths: uniquePaths(
+ group.flatMap((candidate) => candidate.associatedPaths),
+ preferred.primaryPath
+ ),
+ lastActivityAt: Math.max(...group.map((candidate) => candidate.lastActivityAt)),
+ providerIds,
+ source: sourceKinds.size > 1 ? 'mixed' : sourceKinds.has('codex') ? 'codex' : 'claude',
+ openTarget: preferred.openTarget,
+ branchName: mergeBranchName(group),
+ };
+ });
+
+ return aggregates.sort((left, right) => right.lastActivityAt - left.lastActivityAt);
+}
diff --git a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts
new file mode 100644
index 00000000..104ccb1a
--- /dev/null
+++ b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts
@@ -0,0 +1,30 @@
+import {
+ DASHBOARD_RECENT_PROJECTS_ROUTE,
+ type DashboardRecentProjectsPayload,
+ normalizeDashboardRecentProjectsPayload,
+} from '@features/recent-projects/contracts';
+import { createLogger } from '@shared/utils/logger';
+
+import type { RecentProjectsFeatureFacade } from '@features/recent-projects/main/composition/createRecentProjectsFeature';
+import type { FastifyInstance } from 'fastify';
+
+const logger = createLogger('Feature:RecentProjects:HTTP');
+
+export function registerRecentProjectsHttp(
+ app: FastifyInstance,
+ feature: RecentProjectsFeatureFacade
+): void {
+ app.get(DASHBOARD_RECENT_PROJECTS_ROUTE, async (): Promise => {
+ try {
+ return (
+ normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? {
+ projects: [],
+ degraded: true,
+ }
+ );
+ } catch (error) {
+ logger.error('Failed to load dashboard recent projects via HTTP', error);
+ return { projects: [], degraded: true };
+ }
+ });
+}
diff --git a/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts
new file mode 100644
index 00000000..a18ea436
--- /dev/null
+++ b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts
@@ -0,0 +1,33 @@
+import {
+ GET_DASHBOARD_RECENT_PROJECTS,
+ normalizeDashboardRecentProjectsPayload,
+} 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 (
+ normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? {
+ projects: [],
+ degraded: true,
+ }
+ );
+ } catch (error) {
+ logger.error('Failed to load dashboard recent projects via IPC', error);
+ return { projects: [], degraded: true };
+ }
+ });
+}
+
+export function removeRecentProjectsIpc(ipcMain: IpcMain): void {
+ ipcMain.removeHandler(GET_DASHBOARD_RECENT_PROJECTS);
+}
diff --git a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts
new file mode 100644
index 00000000..c9a3e5fb
--- /dev/null
+++ b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts
@@ -0,0 +1,27 @@
+import type {
+ DashboardRecentProject,
+ DashboardRecentProjectsPayload,
+} 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 {
+ present(response: ListDashboardRecentProjectsResponse): DashboardRecentProjectsPayload {
+ return {
+ degraded: response.degraded,
+ projects: response.projects.map(
+ (aggregate): DashboardRecentProject => ({
+ id: aggregate.identity,
+ name: aggregate.displayName,
+ primaryPath: aggregate.primaryPath,
+ associatedPaths: aggregate.associatedPaths,
+ mostRecentActivity: aggregate.lastActivityAt,
+ providerIds: aggregate.providerIds,
+ source: aggregate.source,
+ openTarget: aggregate.openTarget,
+ primaryBranch: aggregate.branchName,
+ })
+ ),
+ };
+ }
+}
diff --git a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts
new file mode 100644
index 00000000..700b9122
--- /dev/null
+++ b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts
@@ -0,0 +1,86 @@
+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,
+ RecentProjectsSourceResult,
+} from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort';
+import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate';
+import type { ServiceContext } from '@main/services';
+import type { RepositoryGroup, Worktree } from '@main/types';
+
+function selectPreferredWorktree(worktrees: readonly Worktree[]): Worktree | undefined {
+ return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0];
+}
+
+function toCandidate(repo: RepositoryGroup): RecentProjectCandidate | null {
+ if (!repo.worktrees.length || !repo.mostRecentSession) {
+ return null;
+ }
+
+ const preferredWorktree = selectPreferredWorktree(repo.worktrees);
+ if (!preferredWorktree) {
+ return null;
+ }
+
+ return {
+ identity: repo.identity?.id ?? `path:${normalizeIdentityPath(preferredWorktree.path)}`,
+ displayName: repo.name,
+ primaryPath: preferredWorktree.path,
+ associatedPaths: repo.worktrees.map((worktree) => worktree.path),
+ lastActivityAt: repo.mostRecentSession,
+ providerIds: ['anthropic'],
+ sourceKind: 'claude',
+ openTarget: {
+ type: 'existing-worktree',
+ repositoryId: repo.id,
+ worktreeId: preferredWorktree.id,
+ },
+ branchName: preferredWorktree.gitBranch,
+ };
+}
+
+export class ClaudeRecentProjectsSourceAdapter implements RecentProjectsSourcePort {
+ readonly #localWorktreeGrouper = new WorktreeGrouper(getProjectsBasePath());
+
+ constructor(
+ private readonly getActiveContext: () => ServiceContext,
+ private readonly logger: LoggerPort
+ ) {}
+
+ async list(): Promise {
+ const activeContext = this.getActiveContext();
+ const groups =
+ activeContext.type === 'local'
+ ? await this.#groupLocalProjects(activeContext)
+ : await activeContext.projectScanner.scanWithWorktreeGrouping();
+
+ const candidates = groups
+ .map((group) => toCandidate(group))
+ .filter((candidate): candidate is RecentProjectCandidate => candidate !== null);
+
+ this.logger.info('claude recent-projects source loaded', {
+ count: candidates.length,
+ contextId: activeContext.id,
+ });
+
+ return {
+ candidates,
+ degraded: false,
+ };
+ }
+
+ async #groupLocalProjects(activeContext: ServiceContext): Promise {
+ try {
+ const projects = await activeContext.projectScanner.scan();
+ return await this.#localWorktreeGrouper.groupByRepository(projects);
+ } catch (error) {
+ this.logger.warn('claude recent-projects fell back to simplified grouping', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return activeContext.projectScanner.scanWithWorktreeGrouping();
+ }
+ }
+}
diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts
new file mode 100644
index 00000000..f597fcfe
--- /dev/null
+++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts
@@ -0,0 +1,211 @@
+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,
+ RecentProjectsSourceResult,
+} from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort';
+import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate';
+import type {
+ CodexAppServerClient,
+ CodexRecentThreadsResult,
+ 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_INITIALIZE_TIMEOUT_MS = 6_000;
+const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
+const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
+const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
+const CODEX_TOTAL_FETCH_TIMEOUT_MS =
+ CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
+const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500;
+const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS =
+ CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
+
+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;
+}
+
+function isDegradedThreadResult(result: CodexRecentThreadsResult): boolean {
+ return Boolean(result.live.error || result.archived.error);
+}
+
+export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort {
+ readonly sourceId = 'codex';
+ readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS;
+
+ constructor(
+ private readonly deps: {
+ getActiveContext: () => ServiceContext;
+ getLocalContext: () => ServiceContext | undefined;
+ resolveBinary: () => Promise;
+ appServerClient: CodexAppServerClient;
+ identityResolver: RecentProjectIdentityResolver;
+ logger: LoggerPort;
+ }
+ ) {}
+
+ async list(): Promise {
+ const activeContext = this.deps.getActiveContext();
+ const localContext = this.deps.getLocalContext();
+
+ if (activeContext.type !== 'local' || activeContext.id !== localContext?.id) {
+ return {
+ candidates: [],
+ degraded: false,
+ };
+ }
+
+ const binaryPath = await this.deps.resolveBinary();
+ if (!binaryPath) {
+ this.deps.logger.info('codex recent-projects source skipped - binary unavailable');
+ return {
+ candidates: [],
+ degraded: false,
+ };
+ }
+
+ const threadSegments = await this.#listRecentThreadsSafe(binaryPath);
+ const degraded = isDegradedThreadResult(threadSegments);
+ this.#logSegmentFailure(threadSegments, 'live');
+ this.#logSegmentFailure(threadSegments, 'archived');
+ const liveThreads = threadSegments.live.threads;
+ const archivedThreads = threadSegments.archived.threads;
+
+ 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,
+ degraded,
+ });
+
+ return {
+ candidates,
+ degraded,
+ };
+ }
+
+ async #listRecentThreads(binaryPath: string): Promise {
+ const result = await this.deps.appServerClient.listRecentThreads(binaryPath, {
+ limit: CODEX_THREAD_LIMIT,
+ liveRequestTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
+ archivedRequestTimeoutMs: CODEX_ARCHIVED_FETCH_TIMEOUT_MS,
+ initializeTimeoutMs: CODEX_INITIALIZE_TIMEOUT_MS,
+ totalTimeoutMs: CODEX_TOTAL_FETCH_TIMEOUT_MS,
+ });
+
+ this.deps.logger.info('codex recent-projects thread lists loaded', {
+ liveCount: result.live.threads.length,
+ archivedCount: result.archived.threads.length,
+ });
+ return result;
+ }
+
+ #logSegmentFailure(result: CodexRecentThreadsResult, segment: 'live' | 'archived'): void {
+ const error = result[segment].error;
+ if (!error) {
+ return;
+ }
+
+ if (segment === 'archived' && !result.live.error) {
+ this.deps.logger.info('codex recent-projects archived thread list degraded', {
+ error,
+ });
+ return;
+ }
+
+ this.deps.logger.warn('codex recent-projects thread list failed', {
+ segment,
+ error,
+ });
+ }
+
+ async #listRecentThreadsSafe(binaryPath: string): Promise {
+ try {
+ return await this.#listRecentThreads(binaryPath);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ this.deps.logger.warn('codex recent-projects thread list session failed', {
+ error: message,
+ });
+
+ if (message.toLowerCase().includes('timed out')) {
+ return {
+ live: { threads: [], error: message },
+ archived: { threads: [], error: message },
+ };
+ }
+
+ try {
+ const liveFallback = await this.deps.appServerClient.listRecentLiveThreads(binaryPath, {
+ limit: CODEX_THREAD_LIMIT,
+ requestTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
+ initializeTimeoutMs: CODEX_INITIALIZE_TIMEOUT_MS,
+ totalTimeoutMs: CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS,
+ });
+
+ this.deps.logger.info('codex recent-projects recovered with live-only fallback', {
+ liveCount: liveFallback.threads.length,
+ });
+
+ return {
+ live: liveFallback,
+ archived: { threads: [], error: message },
+ };
+ } catch (fallbackError) {
+ const fallbackMessage =
+ fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
+ this.deps.logger.warn('codex recent-projects live-only fallback failed', {
+ error: fallbackMessage,
+ });
+ }
+
+ return {
+ live: { threads: [], error: message },
+ archived: { threads: [], error: message },
+ };
+ }
+ }
+
+ async #toCandidate(thread: CodexThreadSummary): Promise {
+ const cwd = thread.cwd?.trim();
+ if (!cwd) {
+ return null;
+ }
+
+ const identity = await this.deps.identityResolver.resolve(cwd);
+ const displayName = identity?.name ?? path.basename(cwd) ?? thread.name?.trim() ?? cwd;
+
+ return {
+ identity: identity?.id ?? `path:${normalizeIdentityPath(cwd)}`,
+ displayName,
+ primaryPath: cwd,
+ associatedPaths: [cwd],
+ lastActivityAt: normalizeTimestamp(thread.updatedAt ?? thread.createdAt),
+ providerIds: ['codex'],
+ sourceKind: 'codex',
+ openTarget: {
+ type: 'synthetic-path',
+ path: cwd,
+ },
+ branchName: thread.gitInfo?.branch ?? undefined,
+ };
+ }
+}
diff --git a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts
new file mode 100644
index 00000000..f7109381
--- /dev/null
+++ b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts
@@ -0,0 +1,61 @@
+import {
+ type DashboardRecentProjectsPayload,
+ normalizeDashboardRecentProjectsPayload,
+} from '@features/recent-projects/contracts';
+
+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 { ServiceContext } from '@main/services';
+
+export interface RecentProjectsFeatureFacade {
+ listDashboardRecentProjects(): Promise;
+}
+
+export function createRecentProjectsFeature(deps: {
+ getActiveContext: () => ServiceContext;
+ getLocalContext: () => ServiceContext | undefined;
+ logger: LoggerPort;
+}): RecentProjectsFeatureFacade {
+ const cache = new InMemoryRecentProjectsCache();
+ const presenter = new DashboardRecentProjectsPresenter();
+ const clock: ClockPort = { now: () => Date.now() };
+ const jsonRpcStdioClient = new JsonRpcStdioClient(deps.logger);
+ const codexAppServerClient = new CodexAppServerClient(jsonRpcStdioClient);
+ const identityResolver = new RecentProjectIdentityResolver();
+ const sources = [
+ new ClaudeRecentProjectsSourceAdapter(deps.getActiveContext, deps.logger),
+ new CodexRecentProjectsSourceAdapter({
+ getActiveContext: deps.getActiveContext,
+ getLocalContext: deps.getLocalContext,
+ resolveBinary: () => CodexBinaryResolver.resolve(),
+ appServerClient: codexAppServerClient,
+ identityResolver,
+ logger: deps.logger,
+ }),
+ ];
+ const useCase = new ListDashboardRecentProjectsUseCase({
+ sources,
+ cache,
+ output: presenter,
+ clock,
+ logger: deps.logger,
+ });
+
+ return {
+ listDashboardRecentProjects: async () => {
+ const activeContext = deps.getActiveContext();
+ const payload = await useCase.execute(`dashboard-recent-projects:${activeContext.id}`);
+ return normalizeDashboardRecentProjectsPayload(payload) ?? { projects: [], degraded: true };
+ },
+ };
+}
diff --git a/src/features/recent-projects/main/index.ts b/src/features/recent-projects/main/index.ts
new file mode 100644
index 00000000..bdf2a07b
--- /dev/null
+++ b/src/features/recent-projects/main/index.ts
@@ -0,0 +1,7 @@
+export { registerRecentProjectsHttp } from './adapters/input/http/registerRecentProjectsHttp';
+export {
+ registerRecentProjectsIpc,
+ removeRecentProjectsIpc,
+} from './adapters/input/ipc/registerRecentProjectsIpc';
+export type { RecentProjectsFeatureFacade } from './composition/createRecentProjectsFeature';
+export { createRecentProjectsFeature } from './composition/createRecentProjectsFeature';
diff --git a/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts b/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts
new file mode 100644
index 00000000..53f1c49b
--- /dev/null
+++ b/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts
@@ -0,0 +1,34 @@
+import type { RecentProjectsCachePort } from '../../../core/application/ports/RecentProjectsCachePort';
+
+interface CacheEntry {
+ value: T;
+ expiresAt: number;
+}
+
+export class InMemoryRecentProjectsCache implements RecentProjectsCachePort {
+ readonly #entries = new Map>();
+
+ async get(key: string): Promise {
+ const entry = this.#entries.get(key);
+ if (!entry) {
+ return null;
+ }
+
+ if (entry.expiresAt <= Date.now()) {
+ return null;
+ }
+
+ return entry.value;
+ }
+
+ async getStale(key: string): Promise {
+ return this.#entries.get(key)?.value ?? null;
+ }
+
+ async set(key: string, value: T, ttlMs: number): Promise {
+ this.#entries.set(key, {
+ value,
+ expiresAt: Date.now() + ttlMs,
+ });
+ }
+}
diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts
new file mode 100644
index 00000000..56985538
--- /dev/null
+++ b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts
@@ -0,0 +1,218 @@
+import type { JsonRpcSession, JsonRpcStdioClient } from './JsonRpcStdioClient';
+
+const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
+const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
+const DEFAULT_INITIALIZE_TIMEOUT_MS = 6_000;
+const MIN_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
+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 interface CodexThreadSegmentResult {
+ threads: CodexThreadSummary[];
+ error?: string;
+}
+
+export interface CodexRecentThreadsResult {
+ live: CodexThreadSegmentResult;
+ archived: CodexThreadSegmentResult;
+}
+
+interface ThreadListSessionOptions {
+ binaryPath: string;
+ requestTimeoutMs: number;
+ initializeTimeoutMs: number;
+ totalTimeoutMs: number;
+ label: string;
+}
+
+export class CodexAppServerClient {
+ constructor(private readonly rpcClient: JsonRpcStdioClient) {}
+
+ async listRecentLiveThreads(
+ binaryPath: string,
+ options: {
+ limit: number;
+ requestTimeoutMs?: number;
+ initializeTimeoutMs?: number;
+ totalTimeoutMs?: number;
+ }
+ ): Promise {
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
+ const initializeTimeoutMs = Math.max(
+ options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
+ requestTimeoutMs
+ );
+ const totalTimeoutMs = Math.max(
+ options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
+ initializeTimeoutMs + requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
+ );
+
+ return this.#withThreadListSession(
+ {
+ binaryPath,
+ requestTimeoutMs,
+ initializeTimeoutMs,
+ totalTimeoutMs,
+ label: 'codex app-server thread/list live',
+ },
+ async (session) => {
+ const live = await session.request(
+ 'thread/list',
+ {
+ archived: false,
+ limit: options.limit,
+ sortKey: 'updated_at',
+ },
+ requestTimeoutMs
+ );
+
+ return {
+ threads: live.data ?? [],
+ };
+ }
+ );
+ }
+
+ async listRecentThreads(
+ binaryPath: string,
+ options: {
+ limit: number;
+ liveRequestTimeoutMs?: number;
+ archivedRequestTimeoutMs?: number;
+ initializeTimeoutMs?: number;
+ totalTimeoutMs?: number;
+ }
+ ): Promise {
+ const liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
+ const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
+ const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
+ const initializeTimeoutMs = Math.max(
+ options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
+ sessionRequestTimeoutMs
+ );
+ const totalTimeoutMs = Math.max(
+ options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
+ initializeTimeoutMs + sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
+ );
+
+ return this.#withThreadListSession(
+ {
+ binaryPath,
+ requestTimeoutMs: sessionRequestTimeoutMs,
+ initializeTimeoutMs,
+ totalTimeoutMs,
+ label: 'codex app-server thread/list',
+ },
+ async (session) => {
+ const [live, archived] = await Promise.allSettled([
+ session.request(
+ 'thread/list',
+ {
+ archived: false,
+ limit: options.limit,
+ sortKey: 'updated_at',
+ },
+ liveRequestTimeoutMs
+ ),
+ session.request(
+ 'thread/list',
+ {
+ archived: true,
+ limit: options.limit,
+ sortKey: 'updated_at',
+ },
+ archivedRequestTimeoutMs
+ ),
+ ]);
+
+ return {
+ live:
+ live.status === 'fulfilled'
+ ? { threads: live.value.data ?? [] }
+ : {
+ threads: [],
+ error: live.reason instanceof Error ? live.reason.message : String(live.reason),
+ },
+ archived:
+ archived.status === 'fulfilled'
+ ? { threads: archived.value.data ?? [] }
+ : {
+ threads: [],
+ error:
+ archived.reason instanceof Error
+ ? archived.reason.message
+ : String(archived.reason),
+ },
+ };
+ }
+ );
+ }
+
+ async #withThreadListSession(
+ options: ThreadListSessionOptions,
+ handler: (session: JsonRpcSession) => Promise
+ ): Promise {
+ return this.rpcClient.withSession(
+ {
+ binaryPath: options.binaryPath,
+ args: ['app-server'],
+ requestTimeoutMs: options.requestTimeoutMs,
+ totalTimeoutMs: options.totalTimeoutMs,
+ label: options.label,
+ },
+ 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,
+ },
+ },
+ options.initializeTimeoutMs
+ );
+
+ await session.notify('initialized');
+ return handler(session);
+ }
+ );
+ }
+}
diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts b/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts
new file mode 100644
index 00000000..7b7184bf
--- /dev/null
+++ b/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts
@@ -0,0 +1,120 @@
+import { constants as fsConstants } from 'node:fs';
+import * as fsp from 'node:fs/promises';
+import path from 'node:path';
+
+const CACHE_VERIFY_TTL_MS = 30_000;
+
+let cachedBinaryPath: string | null | undefined;
+let cacheVerifiedAt = 0;
+let resolveInFlight: Promise | null = null;
+
+async function fileExists(filePath: string): Promise {
+ try {
+ await fsp.access(filePath, fsConstants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function expandWindowsExtensions(candidate: string): string[] {
+ if (process.platform !== 'win32') {
+ return [candidate];
+ }
+
+ const pathext = process.env.PATHEXT?.split(';').filter(Boolean) ?? [
+ '.EXE',
+ '.CMD',
+ '.BAT',
+ '.COM',
+ ];
+ const hasKnownExtension = pathext.some((ext) =>
+ candidate.toLowerCase().endsWith(ext.toLowerCase())
+ );
+
+ if (hasKnownExtension) {
+ return [candidate];
+ }
+
+ return [candidate, ...pathext.map((ext) => `${candidate}${ext.toLowerCase()}`)];
+}
+
+async function verifyBinary(candidate: string): Promise {
+ const expandedCandidates = expandWindowsExtensions(candidate);
+
+ if (path.isAbsolute(candidate) || candidate.includes(path.sep)) {
+ for (const expandedCandidate of expandedCandidates) {
+ if (await fileExists(expandedCandidate)) {
+ return expandedCandidate;
+ }
+ }
+ return null;
+ }
+
+ const pathEntries = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
+ for (const pathEntry of pathEntries) {
+ for (const expandedCandidate of expandedCandidates) {
+ const resolvedCandidate = path.join(pathEntry, expandedCandidate);
+ if (await fileExists(resolvedCandidate)) {
+ return resolvedCandidate;
+ }
+ }
+ }
+
+ return null;
+}
+
+export class CodexBinaryResolver {
+ static clearCache(): void {
+ cachedBinaryPath = undefined;
+ cacheVerifiedAt = 0;
+ resolveInFlight = null;
+ }
+
+ static async resolve(): Promise {
+ if (cachedBinaryPath !== undefined) {
+ if (cachedBinaryPath === null) {
+ return null;
+ }
+
+ if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) {
+ return cachedBinaryPath;
+ }
+
+ const verified = await verifyBinary(cachedBinaryPath);
+ if (verified) {
+ cacheVerifiedAt = Date.now();
+ return verified;
+ }
+
+ cachedBinaryPath = undefined;
+ cacheVerifiedAt = 0;
+ }
+
+ if (!resolveInFlight) {
+ resolveInFlight = CodexBinaryResolver.runResolve().finally(() => {
+ resolveInFlight = null;
+ });
+ }
+
+ return resolveInFlight;
+ }
+
+ private static async runResolve(): Promise {
+ const override = process.env.CODEX_CLI_PATH?.trim();
+ const candidates = override ? [override, 'codex'] : ['codex'];
+
+ for (const candidate of candidates) {
+ const resolved = await verifyBinary(candidate);
+ if (resolved) {
+ cachedBinaryPath = resolved;
+ cacheVerifiedAt = Date.now();
+ return resolved;
+ }
+ }
+
+ cachedBinaryPath = null;
+ cacheVerifiedAt = Date.now();
+ return null;
+ }
+}
diff --git a/src/features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient.ts b/src/features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient.ts
new file mode 100644
index 00000000..afd3dc6a
--- /dev/null
+++ b/src/features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient.ts
@@ -0,0 +1,211 @@
+import { once } from 'node:events';
+import readline from 'node:readline';
+
+import { killProcessTree, spawnCli } from '@main/utils/childProcess';
+
+import type { LoggerPort } from '../../../core/application/ports/LoggerPort';
+
+const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
+const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
+
+interface JsonRpcErrorPayload {
+ code?: number;
+ message?: string;
+}
+
+interface JsonRpcResponse {
+ id?: number;
+ result?: T;
+ error?: JsonRpcErrorPayload;
+}
+
+export interface JsonRpcSession {
+ request(method: string, params?: unknown, timeoutMs?: number): Promise;
+ notify(method: string, params?: unknown): Promise;
+}
+
+function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise {
+ let timeoutId: ReturnType | null = null;
+ const timeoutPromise = new Promise((_resolve, reject) => {
+ timeoutId = setTimeout(
+ () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
+ timeoutMs
+ );
+ });
+
+ return Promise.race([promise, timeoutPromise]).finally(() => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ }) as Promise;
+}
+
+export class JsonRpcStdioClient {
+ constructor(private readonly logger: LoggerPort) {}
+
+ async withSession(
+ options: {
+ binaryPath: string;
+ args: string[];
+ requestTimeoutMs?: number;
+ totalTimeoutMs?: number;
+ label: string;
+ },
+ handler: (session: JsonRpcSession) => Promise
+ ): Promise {
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
+ const totalTimeoutMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS;
+
+ return withTimeout(
+ this.#runSession(options.binaryPath, options.args, requestTimeoutMs, handler),
+ totalTimeoutMs,
+ options.label
+ );
+ }
+
+ async #runSession(
+ binaryPath: string,
+ args: string[],
+ requestTimeoutMs: number,
+ handler: (session: JsonRpcSession) => Promise
+ ): Promise {
+ const child = spawnCli(binaryPath, args, {
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
+ });
+ const lineReader = readline.createInterface({ input: child.stdout! });
+ child.stderr?.on('data', () => {
+ // Keep stderr drained so process warnings do not block the pipe.
+ });
+
+ const pending = new Map<
+ number,
+ {
+ resolve: (value: unknown) => void;
+ reject: (error: Error) => void;
+ timeoutId: ReturnType;
+ }
+ >();
+
+ let nextRequestId = 1;
+
+ const rejectAll = (error: Error): void => {
+ for (const [id, entry] of pending) {
+ clearTimeout(entry.timeoutId);
+ entry.reject(error);
+ pending.delete(id);
+ }
+ };
+
+ lineReader.on('line', (line) => {
+ let message: JsonRpcResponse;
+ try {
+ message = JSON.parse(line) as JsonRpcResponse;
+ } catch (error) {
+ this.logger.warn('json-rpc stdio emitted non-json line', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return;
+ }
+
+ if (typeof message.id !== 'number') {
+ return;
+ }
+
+ const entry = pending.get(message.id);
+ if (!entry) {
+ return;
+ }
+
+ clearTimeout(entry.timeoutId);
+ pending.delete(message.id);
+
+ if (message.error) {
+ entry.reject(new Error(message.error.message ?? 'Unknown JSON-RPC error'));
+ return;
+ }
+
+ entry.resolve(message.result);
+ });
+
+ child.once('error', (error) => {
+ rejectAll(error instanceof Error ? error : new Error(String(error)));
+ });
+
+ child.once('exit', (code, signal) => {
+ if (pending.size === 0) {
+ return;
+ }
+
+ rejectAll(
+ new Error(
+ `JSON-RPC process exited unexpectedly (code=${code ?? 'null'} signal=${signal ?? 'null'})`
+ )
+ );
+ });
+
+ const session: JsonRpcSession = {
+ request: (
+ method: string,
+ params?: unknown,
+ timeoutMs = requestTimeoutMs
+ ): Promise =>
+ new Promise((resolve, reject) => {
+ if (!child.stdin) {
+ reject(new Error('JSON-RPC stdin is not available'));
+ return;
+ }
+
+ const id = nextRequestId++;
+ const timeoutId = setTimeout(() => {
+ pending.delete(id);
+ reject(new Error(`JSON-RPC request timed out: ${method}`));
+ }, timeoutMs);
+
+ pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timeoutId });
+
+ child.stdin.write(`${JSON.stringify({ id, method, params })}\n`, (error) => {
+ if (!error) {
+ return;
+ }
+
+ clearTimeout(timeoutId);
+ pending.delete(id);
+ reject(error instanceof Error ? error : new Error(String(error)));
+ });
+ }),
+
+ notify: async (method: string, params?: unknown): Promise => {
+ if (!child.stdin) {
+ throw new Error('JSON-RPC stdin is not available');
+ }
+
+ await new Promise((resolve, reject) => {
+ child.stdin!.write(`${JSON.stringify({ method, params })}\n`, (error) => {
+ if (error) {
+ reject(error instanceof Error ? error : new Error(String(error)));
+ return;
+ }
+ resolve();
+ });
+ });
+ },
+ };
+
+ try {
+ return await handler(session);
+ } finally {
+ rejectAll(new Error('JSON-RPC session closed'));
+ lineReader.close();
+ if (child.stdin && !child.stdin.destroyed) {
+ child.stdin.end();
+ }
+ killProcessTree(child);
+ try {
+ await once(child, 'close');
+ } catch {
+ this.logger.warn('json-rpc close wait failed');
+ }
+ }
+ }
+}
diff --git a/src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts b/src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts
new file mode 100644
index 00000000..f7dc39d3
--- /dev/null
+++ b/src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts
@@ -0,0 +1,20 @@
+import { gitIdentityResolver } from '@main/services/parsing/GitIdentityResolver';
+
+export interface RecentProjectIdentity {
+ id: string;
+ name?: string;
+}
+
+export class RecentProjectIdentityResolver {
+ async resolve(projectPath: string): Promise {
+ const identity = await gitIdentityResolver.resolveIdentity(projectPath);
+ if (!identity) {
+ return null;
+ }
+
+ return {
+ id: identity.id,
+ name: identity.name,
+ };
+ }
+}
diff --git a/src/features/recent-projects/main/infrastructure/identity/normalizeIdentityPath.ts b/src/features/recent-projects/main/infrastructure/identity/normalizeIdentityPath.ts
new file mode 100644
index 00000000..5115440e
--- /dev/null
+++ b/src/features/recent-projects/main/infrastructure/identity/normalizeIdentityPath.ts
@@ -0,0 +1,10 @@
+import path from 'path';
+
+export function normalizeIdentityPath(projectPath: string): string {
+ let normalized = path.normalize(projectPath);
+ while (normalized.length > 1 && (normalized.endsWith('/') || normalized.endsWith('\\'))) {
+ normalized = normalized.slice(0, -1);
+ }
+
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
+}
diff --git a/src/features/recent-projects/preload/createRecentProjectsBridge.ts b/src/features/recent-projects/preload/createRecentProjectsBridge.ts
new file mode 100644
index 00000000..f5494bd9
--- /dev/null
+++ b/src/features/recent-projects/preload/createRecentProjectsBridge.ts
@@ -0,0 +1,11 @@
+import {
+ GET_DASHBOARD_RECENT_PROJECTS,
+ type RecentProjectsElectronApi,
+} from '@features/recent-projects/contracts';
+import { ipcRenderer } from 'electron';
+
+export function createRecentProjectsBridge(): RecentProjectsElectronApi {
+ return {
+ getDashboardRecentProjects: () => ipcRenderer.invoke(GET_DASHBOARD_RECENT_PROJECTS),
+ };
+}
diff --git a/src/features/recent-projects/preload/index.ts b/src/features/recent-projects/preload/index.ts
new file mode 100644
index 00000000..c68458b2
--- /dev/null
+++ b/src/features/recent-projects/preload/index.ts
@@ -0,0 +1 @@
+export { createRecentProjectsBridge } from './createRecentProjectsBridge';
diff --git a/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts b/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts
new file mode 100644
index 00000000..58b0cd79
--- /dev/null
+++ b/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts
@@ -0,0 +1,130 @@
+import { formatProjectPath } from '@renderer/utils/pathDisplay';
+import { normalizePath, type TaskStatusCounts } from '@renderer/utils/pathNormalize';
+import { formatDistanceToNow } from 'date-fns';
+
+import { sortDashboardProviderIds } from '../utils/projectDecorations';
+
+import type { DashboardRecentProject } from '@features/recent-projects/contracts';
+import type { TeamSummary } from '@shared/types';
+
+export interface RecentProjectCardModel {
+ id: string;
+ project: DashboardRecentProject;
+ name: string;
+ formattedPath: string;
+ lastActivityLabel: string;
+ providerIds: DashboardRecentProject['providerIds'];
+ primaryBranch?: string;
+ taskCounts?: TaskStatusCounts;
+ tasksLoading: boolean;
+ activeTeams?: TeamSummary[];
+ additionalPathCount: number;
+ pathSummary?: {
+ badgeLabel: string;
+ description: string;
+ paths: {
+ label: string;
+ fullPath: string;
+ }[];
+ };
+}
+
+interface RecentProjectsSectionAdapterInput {
+ projects: DashboardRecentProject[];
+ taskCountsByProject: Map;
+ activeTeamsByProject: Map;
+ tasksLoading: boolean;
+}
+
+function sumTaskCounts(
+ project: DashboardRecentProject,
+ taskCountsByProject: Map
+): TaskStatusCounts | undefined {
+ const total = project.associatedPaths.reduce(
+ (counts, currentPath) => {
+ const next = taskCountsByProject.get(normalizePath(currentPath));
+ if (!next) {
+ return counts;
+ }
+
+ return {
+ pending: counts.pending + next.pending,
+ inProgress: counts.inProgress + next.inProgress,
+ completed: counts.completed + next.completed,
+ };
+ },
+ { pending: 0, inProgress: 0, completed: 0 }
+ );
+
+ return total.pending > 0 || total.inProgress > 0 || total.completed > 0 ? total : undefined;
+}
+
+function collectActiveTeams(
+ project: DashboardRecentProject,
+ activeTeamsByProject: Map
+): TeamSummary[] | undefined {
+ const seen = new Set();
+ const activeTeams: TeamSummary[] = [];
+
+ for (const projectPath of project.associatedPaths) {
+ const teams = activeTeamsByProject.get(normalizePath(projectPath));
+ if (!teams) {
+ continue;
+ }
+
+ for (const team of teams) {
+ if (seen.has(team.teamName)) {
+ continue;
+ }
+
+ seen.add(team.teamName);
+ activeTeams.push(team);
+ }
+ }
+
+ return activeTeams.length > 0 ? activeTeams : undefined;
+}
+
+function buildPathSummary(
+ project: DashboardRecentProject
+): RecentProjectCardModel['pathSummary'] | undefined {
+ const orderedPaths = [project.primaryPath, ...project.associatedPaths].filter(Boolean);
+ const uniquePaths = Array.from(new Set(orderedPaths));
+
+ if (uniquePaths.length <= 1) {
+ return undefined;
+ }
+
+ return {
+ badgeLabel: `${uniquePaths.length} paths`,
+ description: 'This card merges recent activity from related worktrees and project paths.',
+ paths: uniquePaths.map((fullPath, index) => ({
+ label: index === 0 ? 'Primary path' : `Related path ${index}`,
+ fullPath,
+ })),
+ };
+}
+
+export function adaptRecentProjectsSection({
+ projects,
+ taskCountsByProject,
+ activeTeamsByProject,
+ tasksLoading,
+}: RecentProjectsSectionAdapterInput): RecentProjectCardModel[] {
+ return projects.map((project) => ({
+ id: project.id,
+ project,
+ name: project.name,
+ formattedPath: formatProjectPath(project.primaryPath),
+ lastActivityLabel: formatDistanceToNow(new Date(project.mostRecentActivity), {
+ addSuffix: true,
+ }),
+ providerIds: sortDashboardProviderIds(project.providerIds),
+ primaryBranch: project.primaryBranch,
+ taskCounts: sumTaskCounts(project, taskCountsByProject),
+ tasksLoading,
+ activeTeams: collectActiveTeams(project, activeTeamsByProject),
+ additionalPathCount: Math.max(0, project.associatedPaths.length - 1),
+ pathSummary: buildPathSummary(project),
+ }));
+}
diff --git a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts
new file mode 100644
index 00000000..8f755deb
--- /dev/null
+++ b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts
@@ -0,0 +1,128 @@
+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';
+import { recordRecentProjectOpenPaths } from '../utils/recentProjectOpenHistory';
+
+const logger = createLogger('Feature:RecentProjects:open');
+
+export function useOpenRecentProject(): {
+ openRecentProject: (project: DashboardRecentProject) => Promise;
+ openProjectPath: (projectPath: string) => Promise