+
{sidebarVisible ? (
+ {isActive && isPaneFocused && !fullscreen ? (
+
+ ) : null}
+ {graphMessagesPanel}
{createTaskDialog}
{fullscreen && (
@@ -273,6 +291,7 @@ export const TeamGraphTab = ({
onSendMessage={dispatchSendMessage}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
+ messagesPanelEnabled={isActive && isPaneFocused}
/>
)}
diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts
index 49439b5c..24d5b21b 100644
--- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts
+++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts
@@ -7,16 +7,24 @@ import {
type MemberWorkSyncTargetedRecoveryReason,
} from './MemberWorkSyncTargetedRecoveryPolicy';
-import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../contracts';
+import type {
+ MemberWorkSyncMetricEvent,
+ MemberWorkSyncStatus,
+ MemberWorkSyncTeamMetrics,
+} from '../../contracts';
export type MemberWorkSyncNudgeActivationReason =
| 'shadow_ready'
| MemberWorkSyncTargetedRecoveryReason
| 'review_pickup_required'
+ | 'native_stale_in_progress'
| 'status_not_nudgeable'
| 'blocking_metrics'
| 'phase2_not_ready';
+const NATIVE_STALE_IN_PROGRESS_MIN_AGE_MS = 6 * 60_000;
+const NATIVE_STALE_IN_PROGRESS_PROVIDERS = new Set(['anthropic', 'codex', 'gemini']);
+
export interface MemberWorkSyncNudgeActivationDecision {
active: boolean;
reason: MemberWorkSyncNudgeActivationReason;
@@ -32,6 +40,129 @@ function hasBlockingMetrics(metrics: MemberWorkSyncTeamMetrics): boolean {
return metrics.phase2Readiness.reasons.some((reason) => BLOCKING_PHASE2_REASONS.has(reason));
}
+function normalizeMemberName(value: string): string {
+ return value.trim().toLowerCase();
+}
+
+function isLeadLikeMemberName(memberName: string): boolean {
+ const normalized = normalizeMemberName(memberName).replace(/[\s_]+/g, '-');
+ return (
+ normalized === 'lead' ||
+ normalized === 'team-lead' ||
+ normalized === 'teamlead' ||
+ normalized === 'team-leader'
+ );
+}
+
+function parseTime(value: string | undefined): number | null {
+ if (!value) {
+ return null;
+ }
+ const time = Date.parse(value);
+ return Number.isFinite(time) ? time : null;
+}
+
+function eventsForMember(
+ status: MemberWorkSyncStatus,
+ metrics: MemberWorkSyncTeamMetrics
+): MemberWorkSyncMetricEvent[] {
+ const memberName = normalizeMemberName(status.memberName);
+ return metrics.recentEvents
+ .filter((event) => normalizeMemberName(event.memberName) === memberName)
+ .sort((left, right) => left.recordedAt.localeCompare(right.recordedAt));
+}
+
+function hasAcceptedReportForCurrentFingerprint(
+ status: MemberWorkSyncStatus,
+ metrics: MemberWorkSyncTeamMetrics
+): boolean {
+ return eventsForMember(status, metrics).some(
+ (event) =>
+ event.kind === 'report_accepted' && event.agendaFingerprint === status.agenda.fingerprint
+ );
+}
+
+function isDifferentFingerprintBoundary(
+ event: MemberWorkSyncMetricEvent,
+ currentFingerprint: string
+): boolean {
+ if (event.agendaFingerprint !== currentFingerprint) {
+ return true;
+ }
+ return (
+ event.kind === 'fingerprint_changed' &&
+ event.previousFingerprint !== undefined &&
+ event.previousFingerprint !== currentFingerprint
+ );
+}
+
+function getCurrentFingerprintStableSinceMs(
+ status: MemberWorkSyncStatus,
+ metrics: MemberWorkSyncTeamMetrics,
+ nowMs: number
+): number | null {
+ const currentFingerprint = status.agenda.fingerprint;
+ const memberEvents = eventsForMember(status, metrics).filter((event) => {
+ const recordedAt = parseTime(event.recordedAt);
+ return recordedAt != null && recordedAt <= nowMs;
+ });
+ let latestDifferentFingerprintMs = Number.NEGATIVE_INFINITY;
+ for (const event of memberEvents) {
+ const recordedAt = parseTime(event.recordedAt);
+ if (recordedAt != null && isDifferentFingerprintBoundary(event, currentFingerprint)) {
+ latestDifferentFingerprintMs = Math.max(latestDifferentFingerprintMs, recordedAt);
+ }
+ }
+
+ const currentNeedsSyncEventTimes = memberEvents.flatMap((event) => {
+ const recordedAt = parseTime(event.recordedAt);
+ return event.kind === 'status_evaluated' &&
+ event.state === 'needs_sync' &&
+ event.agendaFingerprint === currentFingerprint &&
+ recordedAt != null &&
+ recordedAt >= latestDifferentFingerprintMs
+ ? [recordedAt]
+ : [];
+ });
+
+ return currentNeedsSyncEventTimes.length > 0 ? Math.min(...currentNeedsSyncEventTimes) : null;
+}
+
+function isNativeStaleInProgressCandidate(input: {
+ status: MemberWorkSyncStatus;
+ metrics: MemberWorkSyncTeamMetrics;
+}): boolean {
+ const { status, metrics } = input;
+ if (
+ status.state !== 'needs_sync' ||
+ status.shadow?.wouldNudge !== true ||
+ !status.diagnostics.includes('no_current_report') ||
+ !status.providerId ||
+ !NATIVE_STALE_IN_PROGRESS_PROVIDERS.has(status.providerId) ||
+ isLeadLikeMemberName(status.memberName) ||
+ status.agenda.items.length !== 1 ||
+ hasAcceptedReportForCurrentFingerprint(status, metrics)
+ ) {
+ return false;
+ }
+
+ const [item] = status.agenda.items;
+ if (
+ item.kind !== 'work' ||
+ item.reason !== 'owned_in_progress_task' ||
+ item.evidence.status !== 'in_progress'
+ ) {
+ return false;
+ }
+
+ const nowMs = parseTime(metrics.generatedAt) ?? parseTime(status.evaluatedAt);
+ if (nowMs == null) {
+ return false;
+ }
+ const stableSinceMs = getCurrentFingerprintStableSinceMs(status, metrics, nowMs);
+ return stableSinceMs != null && nowMs - stableSinceMs >= NATIVE_STALE_IN_PROGRESS_MIN_AGE_MS;
+}
+
function isReviewPickupRequiredCandidate(status: MemberWorkSyncStatus): boolean {
return (
status.state === 'needs_sync' &&
@@ -61,6 +192,10 @@ export function decideMemberWorkSyncNudgeActivation(input: {
return { active: true, reason: targetedRecovery.reason };
}
+ if (isNativeStaleInProgressCandidate(input)) {
+ return { active: true, reason: 'native_stale_in_progress' };
+ }
+
if (hasBlockingMetrics(input.metrics)) {
return { active: false, reason: 'blocking_metrics' };
}
diff --git a/src/features/recent-projects/contracts/dto.ts b/src/features/recent-projects/contracts/dto.ts
index bdb4eda0..1fa6e5c6 100644
--- a/src/features/recent-projects/contracts/dto.ts
+++ b/src/features/recent-projects/contracts/dto.ts
@@ -2,6 +2,8 @@ export type DashboardProviderId = 'anthropic' | 'codex' | 'gemini';
export type DashboardRecentProjectSource = 'claude' | 'codex' | 'mixed';
+export type DashboardRecentProjectFilesystemState = 'available' | 'deleted';
+
export type DashboardRecentProjectOpenTarget =
| { type: 'existing-worktree'; repositoryId: string; worktreeId: string }
| { type: 'synthetic-path'; path: string };
@@ -16,6 +18,7 @@ export interface DashboardRecentProject {
source: DashboardRecentProjectSource;
openTarget: DashboardRecentProjectOpenTarget;
primaryBranch?: string;
+ filesystemState?: DashboardRecentProjectFilesystemState;
}
export interface DashboardRecentProjectsPayload {
diff --git a/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts b/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts
index 9c098a65..26402f5d 100644
--- a/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts
+++ b/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts
@@ -1,4 +1,5 @@
import type { ProviderId } from './ProviderId';
+import type { RecentProjectFilesystemState } from './RecentProjectFilesystemState';
import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget';
export interface RecentProjectAggregate {
@@ -11,4 +12,5 @@ export interface RecentProjectAggregate {
source: 'claude' | 'codex' | 'mixed';
openTarget: RecentProjectOpenTarget;
branchName?: string;
+ filesystemState: RecentProjectFilesystemState;
}
diff --git a/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts b/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts
index 2abc5315..17c389d2 100644
--- a/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts
+++ b/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts
@@ -1,4 +1,5 @@
import type { ProviderId } from './ProviderId';
+import type { RecentProjectFilesystemState } from './RecentProjectFilesystemState';
import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget';
export interface RecentProjectCandidate {
@@ -11,4 +12,5 @@ export interface RecentProjectCandidate {
sourceKind: 'claude' | 'codex';
openTarget: RecentProjectOpenTarget;
branchName?: string;
+ filesystemState?: RecentProjectFilesystemState;
}
diff --git a/src/features/recent-projects/core/domain/models/RecentProjectFilesystemState.ts b/src/features/recent-projects/core/domain/models/RecentProjectFilesystemState.ts
new file mode 100644
index 00000000..22a3029a
--- /dev/null
+++ b/src/features/recent-projects/core/domain/models/RecentProjectFilesystemState.ts
@@ -0,0 +1 @@
+export type RecentProjectFilesystemState = 'available' | 'deleted';
diff --git a/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts b/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts
index ce3be598..41efa95b 100644
--- a/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts
+++ b/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts
@@ -25,10 +25,14 @@ function uniqueProviders(providerIds: readonly ProviderId[]): ProviderId[] {
function selectPreferredCandidate(
candidates: readonly RecentProjectCandidate[]
): RecentProjectCandidate {
- const existingWorktreeCandidates = candidates.filter(
+ const availableCandidates = candidates.filter(
+ (candidate) => candidate.filesystemState !== 'deleted'
+ );
+ const candidatePool = availableCandidates.length > 0 ? availableCandidates : candidates;
+ const existingWorktreeCandidates = candidatePool.filter(
(candidate) => candidate.openTarget.type === 'existing-worktree'
);
- const pool = existingWorktreeCandidates.length > 0 ? existingWorktreeCandidates : candidates;
+ const pool = existingWorktreeCandidates.length > 0 ? existingWorktreeCandidates : candidatePool;
return [...pool].sort((left, right) => {
if (right.lastActivityAt !== left.lastActivityAt) {
@@ -81,6 +85,7 @@ export function mergeRecentProjectCandidates(
source: sourceKinds.size > 1 ? 'mixed' : sourceKinds.has('codex') ? 'codex' : 'claude',
openTarget: preferred.openTarget,
branchName: mergeBranchName(group),
+ filesystemState: preferred.filesystemState ?? 'available',
};
});
diff --git a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts
index c9a3e5fb..887ae7c8 100644
--- a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts
+++ b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts
@@ -20,6 +20,7 @@ export class DashboardRecentProjectsPresenter implements ListDashboardRecentProj
source: aggregate.source,
openTarget: aggregate.openTarget,
primaryBranch: aggregate.branchName,
+ filesystemState: aggregate.filesystemState,
})
),
};
diff --git a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts
index d1d55a94..faefffb6 100644
--- a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts
+++ b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts
@@ -44,6 +44,7 @@ function toCandidate(repo: RepositoryGroup): RecentProjectCandidate | null {
worktreeId: preferredWorktree.id,
},
branchName: preferredWorktree.gitBranch,
+ filesystemState: preferredWorktree.filesystemState ?? 'available',
};
}
diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts
index 75558354..d8311372 100644
--- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts
+++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts
@@ -1,3 +1,4 @@
+import { resolveProjectFilesystemState } from '@features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState';
import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
import path from 'path';
@@ -129,7 +130,9 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
);
const candidates = (
- await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread)))
+ await Promise.all(
+ interactiveThreads.map((thread) => this.#toCandidate(thread, activeContext.fsProvider))
+ )
).filter((candidate): candidate is RecentProjectCandidate => candidate !== null);
if (!degraded) {
@@ -299,7 +302,10 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
}
}
- async #toCandidate(thread: CodexThreadSummary): Promise
{
+ async #toCandidate(
+ thread: CodexThreadSummary,
+ fsProvider?: ServiceContext['fsProvider']
+ ): Promise {
const cwd = thread.cwd?.trim();
if (!cwd || isEphemeralProjectPath(cwd)) {
return null;
@@ -321,6 +327,7 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
path: cwd,
},
branchName: thread.gitInfo?.branch ?? undefined,
+ filesystemState: await resolveProjectFilesystemState(cwd, fsProvider),
};
}
}
diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts
index cec73762..1ee20721 100644
--- a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts
+++ b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
+import { resolveProjectFilesystemState } from '@features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState';
import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
@@ -225,7 +226,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
try {
const snapshots = await this.#listRecentSessionSnapshots();
const candidates = await Promise.all(
- snapshots.map((snapshot) => this.#toCandidate(snapshot))
+ snapshots.map((snapshot) => this.#toCandidate(snapshot, activeContext.fsProvider))
);
const validCandidates = candidates.filter(
@@ -303,7 +304,8 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
}
async #toCandidate(
- snapshot: CodexSessionProjectSnapshot
+ snapshot: CodexSessionProjectSnapshot,
+ fsProvider?: ServiceContext['fsProvider']
): Promise {
const identity = await this.deps.identityResolver.resolve(snapshot.cwd);
const displayName = identity?.name ?? path.basename(snapshot.cwd) ?? snapshot.cwd;
@@ -321,6 +323,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
path: snapshot.cwd,
},
branchName: snapshot.branchName,
+ filesystemState: await resolveProjectFilesystemState(snapshot.cwd, fsProvider),
};
}
}
diff --git a/src/features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState.ts b/src/features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState.ts
new file mode 100644
index 00000000..5f1a1d7f
--- /dev/null
+++ b/src/features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState.ts
@@ -0,0 +1,21 @@
+import type { RecentProjectFilesystemState } from '../../../core/domain/models/RecentProjectFilesystemState';
+import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider';
+
+export async function resolveProjectFilesystemState(
+ projectPath: string,
+ fsProvider?: Pick
+): Promise {
+ if (!projectPath.trim()) {
+ return 'deleted';
+ }
+
+ if (!fsProvider) {
+ return 'available';
+ }
+
+ try {
+ return (await fsProvider.exists(projectPath)) ? 'available' : 'deleted';
+ } catch {
+ return 'deleted';
+ }
+}
diff --git a/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts b/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts
index 58b0cd79..7568b4aa 100644
--- a/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts
+++ b/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts
@@ -15,6 +15,7 @@ export interface RecentProjectCardModel {
lastActivityLabel: string;
providerIds: DashboardRecentProject['providerIds'];
primaryBranch?: string;
+ filesystemState?: DashboardRecentProject['filesystemState'];
taskCounts?: TaskStatusCounts;
tasksLoading: boolean;
activeTeams?: TeamSummary[];
@@ -121,6 +122,7 @@ export function adaptRecentProjectsSection({
}),
providerIds: sortDashboardProviderIds(project.providerIds),
primaryBranch: project.primaryBranch,
+ filesystemState: project.filesystemState,
taskCounts: sumTaskCounts(project, taskCountsByProject),
tasksLoading,
activeTeams: collectActiveTeams(project, activeTeamsByProject),
diff --git a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts
index fb7fd848..f01387f8 100644
--- a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts
+++ b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts
@@ -106,6 +106,11 @@ export function useOpenRecentProject(): {
const openRecentProject = useCallback(
async (project: DashboardRecentProject): Promise => {
+ if (project.filesystemState === 'deleted') {
+ logger.warn('Skipped deleted recent project path', { path: project.primaryPath });
+ return;
+ }
+
try {
await openTarget(project.openTarget, project.associatedPaths);
recordRecentProjectOpenPaths([project.primaryPath, ...project.associatedPaths]);
diff --git a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx
index ad187f70..cdc03227 100644
--- a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx
+++ b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx
@@ -3,8 +3,9 @@ import { useMemo } from 'react';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
+import { cn } from '@renderer/lib/utils';
import { projectColor } from '@renderer/utils/projectColor';
-import { FolderGit2, FolderOpen, GitBranch, Terminal } from 'lucide-react';
+import { FolderGit2, FolderOpen, FolderX, GitBranch, Terminal } from 'lucide-react';
import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter';
@@ -20,11 +21,17 @@ export const RecentProjectCard = ({
onOpenPath,
}: Readonly): React.JSX.Element => {
const color = useMemo(() => projectColor(card.name), [card.name]);
+ const isDeleted = card.filesystemState === 'deleted';
+ const FolderIcon = isDeleted ? FolderX : FolderGit2;
return (
{card.activeTeams && card.activeTeams.length > 0 && (
@@ -32,9 +39,9 @@ export const RecentProjectCard = ({
-
@@ -42,6 +49,16 @@ export const RecentProjectCard = ({
{card.name}
+ {isDeleted && (
+
+
+
+ Deleted
+
+
+ Project folder no longer exists
+
+ )}
{card.pathSummary && (
@@ -91,21 +108,34 @@ export const RecentProjectCard = ({
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
+ if (isDeleted) {
+ return;
+ }
onOpenPath();
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
+ if (isDeleted) {
+ return;
+ }
onOpenPath();
}
}}
- className="shrink-0 cursor-pointer rounded p-0.5 transition-colors hover:bg-white/5 hover:text-text-secondary"
+ className={cn(
+ 'shrink-0 rounded p-0.5 transition-colors',
+ isDeleted
+ ? 'cursor-not-allowed text-red-300/70'
+ : 'cursor-pointer hover:bg-white/5 hover:text-text-secondary'
+ )}
>
-
Open
+
+ {isDeleted ? 'Project folder no longer exists' : 'Open'}
+
diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts
index 784aa15e..793f57a5 100644
--- a/src/main/services/discovery/ProjectScanner.ts
+++ b/src/main/services/discovery/ProjectScanner.ts
@@ -30,6 +30,7 @@ import {
import {
type PaginatedSessionsResult,
type Project,
+ type ProjectFilesystemState,
type RepositoryGroup,
type SearchSessionsResult,
type Session,
@@ -82,6 +83,21 @@ const SEARCH_PROJECT_CACHE_TTL_MS = 30_000;
// for lookups and navigation; a small cap preserves that behavior without huge payloads.
const MAX_SESSION_IDS_EXPORTED = 200;
+async function resolveProjectFilesystemState(
+ fsProvider: FileSystemProvider,
+ projectPath: string
+): Promise {
+ if (!projectPath.trim()) {
+ return 'deleted';
+ }
+
+ try {
+ return (await fsProvider.exists(projectPath)) ? 'available' : 'deleted';
+ } catch {
+ return 'deleted';
+ }
+}
+
export interface ProjectScannerOptions {
/**
* Directory for the persisted session-list metadata index.
@@ -340,6 +356,7 @@ export class ProjectScanner {
totalSessions,
createdAt: project.createdAt,
mostRecentSession: project.mostRecentSession,
+ filesystemState: project.filesystemState,
},
],
name: project.name,
@@ -360,6 +377,7 @@ export class ProjectScanner {
const encodedId = customPath.replace(/[/\\]/g, '-');
const folderName = customPath.split(/[/\\]/).filter(Boolean).pop() ?? customPath;
const now = Date.now();
+ const filesystemState = await resolveProjectFilesystemState(this.fsProvider, customPath);
groups.push({
id: encodedId,
@@ -374,6 +392,7 @@ export class ProjectScanner {
sessions: [],
totalSessions: 0,
createdAt: now,
+ filesystemState,
},
],
name: folderName,
@@ -550,6 +569,7 @@ export class ProjectScanner {
cwdHint: firstCwd ?? undefined,
sessionPaths,
});
+ const filesystemState = await resolveProjectFilesystemState(this.fsProvider, actualPath);
// Derive name from resolved path — more reliable than decodePath for
// paths containing dashes (e.g. "test-project" encodes lossily).
@@ -564,6 +584,7 @@ export class ProjectScanner {
totalSessions: allSessionIds.length,
createdAt: Math.floor(createdAt),
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
+ filesystemState,
},
];
}
@@ -623,6 +644,10 @@ export class ProjectScanner {
totalSessions: sessionIds.length,
createdAt: Math.floor(createdAt),
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
+ filesystemState: await resolveProjectFilesystemState(
+ this.fsProvider,
+ actualCwd ?? decodedFallback
+ ),
});
}
diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts
index 0458fd1d..bf92c60e 100644
--- a/src/main/services/infrastructure/CliInstallerService.ts
+++ b/src/main/services/infrastructure/CliInstallerService.ts
@@ -501,7 +501,7 @@ export class CliInstallerService {
},
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
},
] as const
).map((provider) => ({
diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
index d79a7f4c..07443151 100644
--- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
+++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
@@ -326,7 +326,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
- return 'OpenCode (75+ LLM providers)';
+ return 'OpenCode (200+ models)';
}
}
diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts
index 1c2595ab..b0221f56 100644
--- a/src/main/types/domain.ts
+++ b/src/main/types/domain.ts
@@ -44,6 +44,8 @@ export type MessageCategory = 'user' | 'system' | 'hardNoise' | 'ai' | 'compact'
/**
* Project information derived from ~/.claude/projects/ directory.
*/
+export type ProjectFilesystemState = 'available' | 'deleted';
+
export interface Project {
/** Encoded directory name (e.g., "-Users-username-projectname") */
id: string;
@@ -62,6 +64,8 @@ export interface Project {
createdAt: number;
/** Unix timestamp of most recent session activity */
mostRecentSession?: number;
+ /** Filesystem state for the decoded working directory. */
+ filesystemState?: ProjectFilesystemState;
}
/**
@@ -202,6 +206,8 @@ export interface Worktree {
createdAt: number;
/** Unix timestamp of most recent session activity */
mostRecentSession?: number;
+ /** Filesystem state for this worktree path. */
+ filesystemState?: ProjectFilesystemState;
}
/**
diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx
index 2145b21f..d1f60117 100644
--- a/src/renderer/components/dashboard/CliStatusBanner.tsx
+++ b/src/renderer/components/dashboard/CliStatusBanner.tsx
@@ -385,7 +385,7 @@ function getProviderLabel(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
- return 'OpenCode (75+ LLM providers)';
+ return 'OpenCode (200+ models)';
}
}
diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx
index 2ba585ff..4cd48f64 100644
--- a/src/renderer/components/layout/TeamTabSectionNav.tsx
+++ b/src/renderer/components/layout/TeamTabSectionNav.tsx
@@ -33,7 +33,7 @@ export const TeamTabSectionNav = ({
if (messagesPanelMode === 'sidebar') {
return section.id !== 'messages' && section.id !== 'claude-logs';
}
- if (messagesPanelMode === 'bottom-sheet') {
+ if (messagesPanelMode === 'bottom-sheet' || messagesPanelMode === 'floating-composer') {
return section.id !== 'messages';
}
return true;
diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx
index 85a49f2d..1416f917 100644
--- a/src/renderer/components/settings/sections/CliStatusSection.tsx
+++ b/src/renderer/components/settings/sections/CliStatusSection.tsx
@@ -123,7 +123,7 @@ function getProviderLabel(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
- return 'OpenCode (75+ LLM providers)';
+ return 'OpenCode (200+ models)';
}
}
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index b8c900a4..36d545b5 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -3623,9 +3623,18 @@ export const TeamDetailView = memo(function TeamDetailView({
ref={setMessagesPanelMountPoint}
className="pointer-events-none absolute inset-0 z-30"
/>
- {messagesPanelMode === 'bottom-sheet' && (
+ {messagesPanelMode === 'bottom-sheet' && !graphOpen && (
)}
+ {messagesPanelMode === 'floating-composer' &&
+ isThisTabActive &&
+ isPaneFocused &&
+ !graphOpen && (
+
+ )}
@@ -3650,6 +3659,12 @@ export const TeamDetailView = memo(function TeamDetailView({
.getState()
.openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName });
}}
+ messagesPanelEnabled={
+ (messagesPanelMode === 'floating-composer' ||
+ messagesPanelMode === 'bottom-sheet') &&
+ isThisTabActive &&
+ isPaneFocused
+ }
onSendMessage={(memberName) => {
setSendDialogRecipient(memberName);
setSendDialogDefaultText(undefined);
diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
index 3a22a60d..cfab2cd9 100644
--- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
@@ -95,6 +95,10 @@ import {
resolveProviderScopedMemberModel,
} from './memberModelScope';
import { OptionalSettingsSection } from './OptionalSettingsSection';
+import {
+ isDeletedProjectPathSelection,
+ isSelectableProjectPathProject,
+} from './projectPathOptions';
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
import { ProjectPathSelector } from './ProjectPathSelector';
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
@@ -574,9 +578,17 @@ export const CreateTeamDialog = ({
[members]
);
- const selectedProjectCwd = isEphemeralProjectPath(selectedProjectPath)
- ? ''
- : selectedProjectPath.trim();
+ const selectedProjectPathDeleted = useMemo(
+ () =>
+ cwdMode === 'project' &&
+ selectedProjectPath.length > 0 &&
+ isDeletedProjectPathSelection(projects, selectedProjectPath),
+ [cwdMode, projects, selectedProjectPath]
+ );
+ const selectedProjectCwd =
+ isEphemeralProjectPath(selectedProjectPath) || selectedProjectPathDeleted
+ ? ''
+ : selectedProjectPath.trim();
const effectiveCwd = cwdMode === 'project' ? selectedProjectCwd : customCwd.trim();
const dialogTeamNameKey = sanitizeTeamName(teamName.trim());
/** All taken names: existing teams + teams currently being provisioned. */
@@ -1214,7 +1226,7 @@ export const CreateTeamDialog = ({
if (cwdMode !== 'project') {
return;
}
- const selectableProjects = projects.filter((project) => !isEphemeralProjectPath(project.path));
+ const selectableProjects = projects.filter(isSelectableProjectPathProject);
if (selectableProjects.length === 0) {
return;
}
@@ -1247,17 +1259,20 @@ export const CreateTeamDialog = ({
}
}
setSelectedProjectPath(selectableProjects[0].path);
- }, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
+ }, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath, setSelectedProjectPath]);
useEffect(() => {
if (!open || cwdMode !== 'project' || !selectedProjectPath) {
return;
}
- if (!isEphemeralProjectPath(selectedProjectPath)) {
+ if (
+ !isEphemeralProjectPath(selectedProjectPath) &&
+ !isDeletedProjectPathSelection(projects, selectedProjectPath)
+ ) {
return;
}
setSelectedProjectPath('');
- }, [open, cwdMode, selectedProjectPath, setSelectedProjectPath]);
+ }, [open, cwdMode, projects, selectedProjectPath, setSelectedProjectPath]);
useFileListCacheWarmer(effectiveCwd || null);
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
index 838a497e..7dde457b 100644
--- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
@@ -79,6 +79,7 @@ import {
CheckCircle2,
ChevronDown,
ChevronRight,
+ ExternalLink,
Info,
Loader2,
X,
@@ -98,6 +99,10 @@ import {
resolveProviderScopedMemberModel,
} from './memberModelScope';
import { OptionalSettingsSection } from './OptionalSettingsSection';
+import {
+ isDeletedProjectPathSelection,
+ isSelectableProjectPathProject,
+} from './projectPathOptions';
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
import { ProjectPathSelector } from './ProjectPathSelector';
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
@@ -230,6 +235,8 @@ export type LaunchTeamDialogProps =
| LaunchDialogScheduleMode;
const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate';
+const ANTHROPIC_AGENT_SDK_CREDIT_ARTICLE_URL =
+ 'https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan';
// =============================================================================
// Helpers
@@ -1363,9 +1370,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// Launch-only effects
// ---------------------------------------------------------------------------
- const selectedProjectCwd = isEphemeralProjectPath(selectedProjectPath)
- ? ''
- : selectedProjectPath.trim();
+ const selectedProjectPathDeleted = useMemo(
+ () =>
+ cwdMode === 'project' &&
+ selectedProjectPath.length > 0 &&
+ isDeletedProjectPathSelection(projects, selectedProjectPath),
+ [cwdMode, projects, selectedProjectPath]
+ );
+ const selectedProjectCwd =
+ isEphemeralProjectPath(selectedProjectPath) || selectedProjectPathDeleted
+ ? ''
+ : selectedProjectPath.trim();
const effectiveCwd = cwdMode === 'project' ? selectedProjectCwd : customCwd.trim();
const hasSelectedWorktreeIsolation =
isLaunchMode &&
@@ -1697,7 +1712,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return;
}
if (cwdMode !== 'project') return;
- const selectableProjects = projects.filter((project) => !isEphemeralProjectPath(project.path));
+ const selectableProjects = projects.filter(isSelectableProjectPathProject);
if (selectableProjects.length === 0) return;
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
const normalizedDefaultProjectPath = normalizePath(defaultProjectPath);
@@ -1726,17 +1741,20 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}
}
setSelectedProjectPath(selectableProjects[0].path);
- }, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
+ }, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath, setSelectedProjectPath]);
useEffect(() => {
if (!open || cwdMode !== 'project' || !selectedProjectPath) {
return;
}
- if (!isEphemeralProjectPath(selectedProjectPath)) {
+ if (
+ !isEphemeralProjectPath(selectedProjectPath) &&
+ !isDeletedProjectPathSelection(projects, selectedProjectPath)
+ ) {
return;
}
setSelectedProjectPath('');
- }, [open, cwdMode, selectedProjectPath, setSelectedProjectPath]);
+ }, [open, cwdMode, projects, selectedProjectPath, setSelectedProjectPath]);
// Pre-warm file list cache so @-mention file search is instant
useFileListCacheWarmer(effectiveCwd || null);
@@ -1874,7 +1892,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const validationErrors = useMemo(() => {
const errors: string[] = [];
- if (!effectiveCwd) errors.push('Working directory is required');
+ if (selectedProjectPathDeleted) {
+ errors.push('Project folder no longer exists');
+ } else if (!effectiveCwd) {
+ errors.push('Working directory is required');
+ }
if (worktreeGitBlockingMessage) errors.push(worktreeGitBlockingMessage);
if (isSchedule) {
if (!effectiveTeamName) errors.push('Team is required');
@@ -1884,6 +1906,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return errors;
}, [
effectiveCwd,
+ selectedProjectPathDeleted,
worktreeGitBlockingMessage,
isSchedule,
effectiveTeamName,
@@ -2730,6 +2753,27 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
This prompt will be passed to
diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx
index bb49c9ca..49d8af7c 100644
--- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx
+++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx
@@ -7,7 +7,7 @@ import { Combobox } from '@renderer/components/ui/combobox';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { cn } from '@renderer/lib/utils';
-import { Check, FolderOpen } from 'lucide-react';
+import { Check, FolderOpen, FolderX } from 'lucide-react';
import {
buildProjectPathOptions,
@@ -58,6 +58,10 @@ function getOptionSource(option: ComboboxOption): DashboardRecentProjectSource |
return (option.meta as ProjectPathOptionMeta | undefined)?.discoverySource;
}
+function isDeletedOption(option: ComboboxOption): boolean {
+ return (option.meta as ProjectPathOptionMeta | undefined)?.filesystemState === 'deleted';
+}
+
function getSourceLabel(source: DashboardRecentProjectSource): string {
switch (source) {
case 'claude':
@@ -97,6 +101,16 @@ const ProjectSourceBadge = ({
);
};
+const ProjectDeletedBadge = (): React.JSX.Element => (
+
+
+ Deleted
+
+);
+
export type CwdMode = 'project' | 'custom';
interface ProjectPathSelectorProps {
@@ -178,28 +192,38 @@ export const ProjectPathSelector = ({
renderTriggerLabel={(option) => (
+ {isDeletedOption(option) ? : null}
{option.label}
)}
- renderOption={(option, isSelected, query) => (
- <>
-
-
-
-
- {renderHighlightedText(option.label, query)}
-
-
- {renderHighlightedText(option.description ?? '', query)}
-
+ renderOption={(option, isSelected, query) => {
+ const isDeleted = isDeletedOption(option);
+ return (
+
+
+
+ {isDeleted ?
: null}
+
+
+ {renderHighlightedText(option.label, query)}
+
+
+ {renderHighlightedText(option.description ?? '', query)}
+
+
- >
- )}
+ );
+ }}
/>
diff --git a/src/renderer/components/team/dialogs/projectPathOptions.ts b/src/renderer/components/team/dialogs/projectPathOptions.ts
index 8dd5af03..e4a56562 100644
--- a/src/renderer/components/team/dialogs/projectPathOptions.ts
+++ b/src/renderer/components/team/dialogs/projectPathOptions.ts
@@ -1,16 +1,21 @@
import { normalizePath } from '@renderer/utils/pathNormalize';
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
-import type { DashboardRecentProjectSource } from '@features/recent-projects/contracts';
+import type {
+ DashboardRecentProjectFilesystemState,
+ DashboardRecentProjectSource,
+} from '@features/recent-projects/contracts';
import type { ComboboxOption } from '@renderer/components/ui/combobox';
import type { Project } from '@shared/types';
export interface ProjectPathProject extends Project {
discoverySource?: DashboardRecentProjectSource;
+ filesystemState?: DashboardRecentProjectFilesystemState;
}
-export interface ProjectPathOptionMeta {
+export interface ProjectPathOptionMeta extends Record