+
{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/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..8e13c8f2 100644
--- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts
+++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts
@@ -1,4 +1,5 @@
import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
+import { resolveProjectFilesystemState } from '@features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState';
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..a6140760 100644
--- a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts
+++ b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts
@@ -3,6 +3,7 @@ import os from 'node:os';
import path from 'node:path';
import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
+import { resolveProjectFilesystemState } from '@features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState';
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort';
@@ -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/index.ts b/src/main/index.ts
index a8474c66..4e8069e0 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -353,6 +353,11 @@ async function createOpenCodeRuntimeAdapterRegistry(
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
+ const useHttpMcpBridge = bridgeEnv.CLAUDE_TEAM_OPENCODE_MCP_HTTP === '1';
+ if (!useHttpMcpBridge) {
+ // The OpenCode bridge direct tools/list proof currently requires a local MCP command.
+ delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL;
+ }
const applyMcpLaunchSpecEnv = async (
targetEnv: NodeJS.ProcessEnv,
options: { emitProgress?: boolean } = {}
@@ -408,17 +413,19 @@ async function createOpenCodeRuntimeAdapterRegistry(
}`
);
}
- try {
- reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
- const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
- bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
- reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
- } catch (error) {
- logger.warn(
- `[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${
- error instanceof Error ? error.message : String(error)
- }`
- );
+ if (useHttpMcpBridge) {
+ try {
+ reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
+ const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
+ bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
+ reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
+ } catch (error) {
+ logger.warn(
+ `[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ }
}
if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
await applyMcpLaunchSpecEnv(bridgeEnv, { emitProgress: true });
@@ -427,7 +434,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
const resolveBridgeCommandEnv = async (): Promise => {
const nextEnv = { ...bridgeEnv };
- if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
+ if (!useHttpMcpBridge || !bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
return nextEnv;
}
try {
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..917c1ed9 100644
--- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
@@ -671,6 +671,23 @@ export const CreateTeamDialog = ({
);
const lastPrepareRequestSignatureRef = useRef(null);
+ useEffect(() => {
+ const generation = ++prepareUnmountGenerationRef.current;
+ return () => {
+ // React StrictMode replays effect cleanup/setup in development; defer
+ // invalidation so the replay does not cancel the live prepare request.
+ queueMicrotask(() => {
+ if (!isCurrentPrepareGeneration(prepareUnmountGenerationRef, generation)) {
+ return;
+ }
+ cancelScheduledIdle(prepareIdleHandleRef.current);
+ prepareIdleHandleRef.current = null;
+ prepareRequestSeqRef.current += 1;
+ lastPrepareRequestSignatureRef.current = null;
+ });
+ };
+ }, []);
+
useEffect(() => {
runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider;
}, [runtimeBackendSummaryByProvider]);
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
index 838a497e..d16f3d91 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,
@@ -230,6 +231,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
@@ -2730,6 +2733,27 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
This prompt will be passed to claude -p for
one-shot execution
+ {selectedProviderId === 'anthropic' ? (
+
+
+
+ Starting June 15, 2026, Anthropic bills claude -p and Agent SDK
+ usage from the monthly Agent SDK credit, separate from interactive Claude Code
+ limits. The credit resets each billing cycle and unused credit does not roll
+ over.{' '}
+
+ Read Anthropic article
+
+
+ .
+
+
+ ) : null}
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..6cdae4b0 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 {
discoverySource?: DashboardRecentProjectSource;
+ filesystemState?: DashboardRecentProjectFilesystemState;
}
function toProjectOption(project: ProjectPathProject): ComboboxOption {
@@ -20,10 +25,19 @@ function toProjectOption(project: ProjectPathProject): ComboboxOption {
description: project.path,
};
- if (project.discoverySource !== undefined) {
- option.meta = {
- discoverySource: project.discoverySource,
- } satisfies ProjectPathOptionMeta;
+ if (project.filesystemState === 'deleted') {
+ option.disabled = true;
+ }
+
+ if (project.discoverySource !== undefined || project.filesystemState !== undefined) {
+ const meta: ProjectPathOptionMeta = {};
+ if (project.discoverySource !== undefined) {
+ meta.discoverySource = project.discoverySource;
+ }
+ if (project.filesystemState !== undefined) {
+ meta.filesystemState = project.filesystemState;
+ }
+ option.meta = meta;
}
return option;
diff --git a/src/renderer/components/team/dialogs/projectPathProjects.ts b/src/renderer/components/team/dialogs/projectPathProjects.ts
index 60dcbbd6..b180a5bc 100644
--- a/src/renderer/components/team/dialogs/projectPathProjects.ts
+++ b/src/renderer/components/team/dialogs/projectPathProjects.ts
@@ -22,6 +22,14 @@ function mergeDiscoverySource(
return 'mixed';
}
+function mergeFilesystemState(
+ current: ProjectPathProject['filesystemState'],
+ next: ProjectPathProject['filesystemState']
+): ProjectPathProject['filesystemState'] {
+ if (current === 'available' || next === 'available') return 'available';
+ return current ?? next;
+}
+
function getPathName(projectPath: string): string {
return projectPath.split(/[/\\]/).filter(Boolean).pop() ?? projectPath;
}
@@ -47,6 +55,10 @@ function upsertProject(
existing.discoverySource,
project.discoverySource
);
+ existing.filesystemState = mergeFilesystemState(
+ existing.filesystemState,
+ project.filesystemState
+ );
if (!existing.mostRecentSession && project.mostRecentSession) {
existing.mostRecentSession = project.mostRecentSession;
}
@@ -58,6 +70,7 @@ function recentProjectToProject(project: {
primaryPath: string;
mostRecentActivity: number;
source: DashboardRecentProjectSource;
+ filesystemState?: ProjectPathProject['filesystemState'];
}): ProjectPathProject {
return {
id: `recent:${project.id}`,
@@ -68,10 +81,13 @@ function recentProjectToProject(project: {
createdAt: project.mostRecentActivity,
mostRecentSession: project.mostRecentActivity,
discoverySource: project.source,
+ filesystemState: project.filesystemState,
};
}
-function repositoryWorktreeToProject(worktree: RepositoryGroup['worktrees'][number]): Project {
+function repositoryWorktreeToProject(
+ worktree: RepositoryGroup['worktrees'][number]
+): ProjectPathProject {
return {
id: worktree.id,
path: worktree.path,
@@ -79,6 +95,7 @@ function repositoryWorktreeToProject(worktree: RepositoryGroup['worktrees'][numb
sessions: [],
totalSessions: 0,
createdAt: worktree.createdAt ?? Date.now(),
+ filesystemState: worktree.filesystemState,
};
}
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx
index 58c647f8..c3f66dc6 100644
--- a/src/renderer/components/team/messages/MessageComposer.tsx
+++ b/src/renderer/components/team/messages/MessageComposer.tsx
@@ -65,6 +65,7 @@ interface MessageComposerProps {
sendWarning?: string | null;
sendDebugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null;
lastResult?: SendMessageResult | null;
+ cornerActionPrefix?: React.ReactNode;
/** Ref to the underlying textarea element for external focus management. */
textareaRef?: React.Ref;
onSend: (
@@ -112,6 +113,7 @@ export const MessageComposer = ({
sendWarning,
sendDebugDetails,
lastResult,
+ cornerActionPrefix,
textareaRef: externalTextareaRef,
onSend,
onCrossTeamSend,
@@ -143,6 +145,7 @@ export const MessageComposer = ({
const [recipientOpen, setRecipientOpen] = useState(false);
const [recipientSearch, setRecipientSearch] = useState('');
const recipientSearchRef = useRef(null);
+ const [isTextareaFocused, setIsTextareaFocused] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
const fileInputRef = useRef(null);
@@ -642,6 +645,8 @@ export const MessageComposer = ({
},
[canAttach, draftHandlePaste, showFileRestrictionError, validateSelectedAttachmentFiles]
);
+ const handleTextareaFocus = useCallback(() => setIsTextareaFocused(true), []);
+ const handleTextareaBlur = useCallback(() => setIsTextareaFocused(false), []);
const remaining = MAX_TEXT_LENGTH - trimmed.length;
const hasAttachmentPreviewContent =
@@ -666,6 +671,29 @@ export const MessageComposer = ({
Reused recent cross-team request
) : null;
+ const shouldShowFooterCharCount = remaining < 200;
+ const shouldShowSavedIndicator = isTextareaFocused && draft.isSaved;
+ const nonCompactFooterRight =
+ compactFooterNotice || shouldShowFooterCharCount || shouldShowSavedIndicator ? (
+
+ {compactFooterNotice}
+ {shouldShowFooterCharCount || shouldShowSavedIndicator ? (
+
+ {shouldShowFooterCharCount ? (
+
+ {remaining} chars left
+
+ ) : null}
+ {shouldShowSavedIndicator ? (
+ Saved
+ ) : null}
+
+ ) : null}
+
+ ) : null;
+ const composerFooterRight = isCompactLayout ? compactFooterNotice : nonCompactFooterRight;
return (
+ {cornerActionPrefix}
{/* NOTE: ContextRing disabled — usage formula is inaccurate */}
@@ -1108,27 +1139,7 @@ export const MessageComposer = ({
}
- footerRight={
- isCompactLayout ? (
- compactFooterNotice
- ) : (
-
- {compactFooterNotice}
-
- {remaining < 200 ? (
-
- {remaining} chars left
-
- ) : null}
- {draft.isSaved ? (
- Saved
- ) : null}
-
-
- )
- }
+ footerRight={composerFooterRight}
/>
diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx
index 54e856b0..ac725343 100644
--- a/src/renderer/components/team/messages/MessagesPanel.tsx
+++ b/src/renderer/components/team/messages/MessagesPanel.tsx
@@ -13,6 +13,12 @@ import { Sheet, type SheetRef } from 'react-modal-sheet';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@renderer/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
@@ -33,7 +39,9 @@ import {
CheckCheck,
ChevronsDownUp,
ChevronsUpDown,
+ Dock,
MessageSquare,
+ MoreHorizontal,
PanelBottom,
PanelBottomClose,
PanelBottomOpen,
@@ -807,6 +815,10 @@ export const MessagesPanel = memo(function MessagesPanel({
onPositionChange('bottom-sheet');
}, [onPositionChange]);
+ const moveToFloatingComposer = useCallback(() => {
+ onPositionChange('floating-composer');
+ }, [onPositionChange]);
+
const snapBottomSheetTo = useCallback((snapIndex: number) => {
setBottomSheetSnapIndex(snapIndex);
bottomSheetRef.current?.snapTo(snapIndex);
@@ -864,6 +876,53 @@ export const MessagesPanel = memo(function MessagesPanel({
/>
);
+ const floatingComposerModeControls = (
+
+
+
+
+
+
+
+ Move to inline
+
+
+
+
+
+
+
+ Move to bottom sheet
+
+
+
+
+
+
+
+ Move to sidebar
+
+
+ );
+
const compactComposerSection = (
);
+ const floatingComposerSection = (
+
+ );
+
const inlineStatusSection = (
)}
-
-
- setMessagesCollapsed((v) => !v)}
- aria-label={messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
- >
- {messagesCollapsed ? : }
-
-
-
- {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
-
-
-
-
- setMessagesSearchBarVisible((v) => !v)}
- aria-label={
- messagesSearchBarVisible ? 'Hide message search' : 'Show message search'
- }
- >
- {messagesSearchBarVisible ? : }
-
-
-
- {messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
-
-
-
-
-
-
-
-
- Move to inline
-
+
+
+
+
+
+
+
+
+
+ Message actions
+
+
+ setMessagesCollapsed((v) => !v)}>
+ {messagesCollapsed ? (
+
+ ) : (
+
+ )}
+ {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
+
+ setMessagesSearchBarVisible((v) => !v)}>
+ {messagesSearchBarVisible ? (
+
+ ) : (
+
+ )}
+ {messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
+
+
+
+ Move to inline
+
+
+
+ Move to bottom sheet
+
+
+
+ Float composer
+
+
+
{/* Search & filter bar (toggleable) */}
@@ -1126,6 +1202,16 @@ export const MessagesPanel = memo(function MessagesPanel({
);
}
+ if (position === 'floating-composer') {
+ return (
+
+
+
{floatingComposerSection}
+
+
+ );
+ }
+
if (position === 'bottom-sheet') {
if (!mountPoint) {
return
;
@@ -1196,114 +1282,74 @@ export const MessagesPanel = memo(function MessagesPanel({
className="ml-auto flex items-center gap-1"
onPointerDown={(e) => e.stopPropagation()}
>
- {messagesUnreadCount > 0 && (
+
-
-
-
+
+
+
+
+
- Mark all as read
+ Message actions
- )}
-
-
- setMessagesCollapsed((value) => !value)}
- aria-label={
- messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'
- }
- >
+
+ {messagesUnreadCount > 0 && (
+
+
+ Mark all as read
+
+ )}
+ setMessagesCollapsed((value) => !value)}>
{messagesCollapsed ? (
-
+
) : (
-
+
)}
-
-
-
- {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
-
-
-
-
- setMessagesSearchBarVisible((value) => !value)}
- aria-label={
- messagesSearchBarVisible ? 'Hide message search' : 'Show message search'
- }
- >
- {messagesSearchBarVisible ? : }
-
-
-
- {messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
-
-
-
-
-
+ {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
+
+
+ setMessagesSearchBarVisible((value) => !value)}
>
+ {messagesSearchBarVisible ? (
+
+ ) : (
+
+ )}
+ {messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
+
+
{isBottomSheetCollapsed ? (
-
+
) : (
-
+
)}
-
-
-
- {isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}
-
-
-
-
-
-
-
-
- Move to inline
-
-
-
-
-
-
-
- Move to sidebar
-
+ {isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}
+
+
+
+ Move to inline
+
+
+
+ Move to sidebar
+
+
+
+ Float composer
+
+
+
@@ -1386,6 +1432,23 @@ export const MessagesPanel = memo(function MessagesPanel({
Move to bottom sheet
+
+
+ {
+ e.stopPropagation();
+ moveToFloatingComposer();
+ }}
+ aria-label="Float messages composer"
+ >
+
+
+
+ Float composer
+
;
}
@@ -133,12 +134,20 @@ export const Combobox = ({
{
+ if (option.disabled) {
+ return;
+ }
onValueChange(option.value);
setOpen(false);
setSearch('');
}}
- className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
+ className={cn(
+ 'relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]',
+ option.disabled && 'cursor-not-allowed opacity-60'
+ )}
>
{renderOption ? (
renderOption(option, isSelected, search)
diff --git a/src/renderer/components/ui/dropdown-menu.tsx b/src/renderer/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000..d1b46d96
--- /dev/null
+++ b/src/renderer/components/ui/dropdown-menu.tsx
@@ -0,0 +1,65 @@
+/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
+import * as React from 'react';
+
+import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
+import { cn } from '@renderer/lib/utils';
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+export {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+};
+/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */
diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts
index d8213c10..40054525 100644
--- a/src/renderer/store/slices/cliInstallerSlice.ts
+++ b/src/renderer/store/slices/cliInstallerSlice.ts
@@ -38,7 +38,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
{ providerId: 'anthropic', displayName: 'Anthropic' },
{ providerId: 'codex', displayName: 'Codex' },
{ providerId: 'gemini', displayName: 'Gemini' },
- { providerId: 'opencode', displayName: 'OpenCode (75+ LLM providers)' },
+ { providerId: 'opencode', displayName: 'OpenCode (200+ models)' },
] as const
).map((provider) => ({
...provider,
@@ -500,7 +500,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/renderer/types/teamMessagesPanelMode.ts b/src/renderer/types/teamMessagesPanelMode.ts
index 87fbaaf1..85f90ec3 100644
--- a/src/renderer/types/teamMessagesPanelMode.ts
+++ b/src/renderer/types/teamMessagesPanelMode.ts
@@ -1 +1 @@
-export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet';
+export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet' | 'floating-composer';
diff --git a/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts b/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts
index fd6e655e..556949c6 100644
--- a/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts
+++ b/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts
@@ -96,4 +96,40 @@ describe('mergeRecentProjectCandidates', () => {
expect(result[0].identity).toBe('repo:beta');
expect(result[0].branchName).toBeUndefined();
});
+
+ it('prefers an available candidate over a newer deleted path', () => {
+ const result = mergeRecentProjectCandidates([
+ makeCandidate({
+ lastActivityAt: 1_000,
+ primaryPath: '/workspace/alpha',
+ associatedPaths: ['/workspace/alpha'],
+ filesystemState: 'available',
+ openTarget: {
+ type: 'synthetic-path',
+ path: '/workspace/alpha',
+ },
+ }),
+ makeCandidate({
+ lastActivityAt: 5_000,
+ primaryPath: '/workspace/alpha-deleted',
+ associatedPaths: ['/workspace/alpha-deleted'],
+ filesystemState: 'deleted',
+ openTarget: {
+ type: 'synthetic-path',
+ path: '/workspace/alpha-deleted',
+ },
+ }),
+ ]);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ primaryPath: '/workspace/alpha',
+ lastActivityAt: 5_000,
+ filesystemState: 'available',
+ openTarget: {
+ type: 'synthetic-path',
+ path: '/workspace/alpha',
+ },
+ });
+ });
});
diff --git a/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts b/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts
index afc4418b..0e429ea5 100644
--- a/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts
+++ b/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts
@@ -114,6 +114,42 @@ describe('CodexSessionFileRecentProjectsSourceAdapter', () => {
expect(identityResolver.resolve).toHaveBeenCalledWith('/Users/test/projects/alpha');
});
+ it('marks a Codex session project as deleted when its cwd is gone', async () => {
+ const codexHome = path.join(tempDir, '.codex');
+ const logger = createLogger();
+ const identityResolver = {
+ resolve: vi.fn().mockResolvedValue(null),
+ } as unknown as RecentProjectIdentityResolver;
+ const fsProvider = {
+ exists: vi.fn().mockResolvedValue(false),
+ };
+ await writeRollout(
+ path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-deleted.jsonl'),
+ {
+ cwd: '/Users/test/projects/deleted',
+ },
+ new Date('2026-04-14T12:00:00.000Z')
+ );
+
+ const adapter = new CodexSessionFileRecentProjectsSourceAdapter({
+ getActiveContext: () => ({ type: 'local', id: 'local-1', fsProvider }) as never,
+ getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
+ identityResolver,
+ logger,
+ codexHome,
+ });
+
+ const result = await adapter.list();
+
+ expect(result.candidates[0]).toEqual(
+ expect.objectContaining({
+ primaryPath: '/Users/test/projects/deleted',
+ filesystemState: 'deleted',
+ })
+ );
+ expect(fsProvider.exists).toHaveBeenCalledWith('/Users/test/projects/deleted');
+ });
+
it('loads Codex projects from large session metadata lines without parsing the full line', async () => {
const codexHome = path.join(tempDir, '.codex');
const logger = createLogger();
diff --git a/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts b/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts
index 90e65166..35ee6e31 100644
--- a/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts
+++ b/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts
@@ -21,6 +21,7 @@ describe('adaptRecentProjectsSection', () => {
worktreeId: 'wt-alpha',
},
primaryBranch: 'main',
+ filesystemState: 'deleted',
};
const activeTeam: TeamSummary = {
@@ -52,11 +53,11 @@ describe('adaptRecentProjectsSection', () => {
taskCounts: { pending: 5, inProgress: 7, completed: 9 },
additionalPathCount: 1,
primaryBranch: 'main',
+ filesystemState: 'deleted',
activeTeams: [activeTeam],
pathSummary: {
badgeLabel: '2 paths',
- description:
- 'This card merges recent activity from related worktrees and project paths.',
+ description: 'This card merges recent activity from related worktrees and project paths.',
paths: [
{
label: 'Primary path',
diff --git a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
index a26ddc14..1d5047d3 100644
--- a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
+++ b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
@@ -153,4 +153,26 @@ describe('ProjectScanner cwd split logic', () => {
expect(worktree?.isMainWorktree).toBe(false);
expect(worktree?.source).toBe('claude-desktop');
});
+
+ it('marks decoded project paths as deleted when the working directory no longer exists', async () => {
+ const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-'));
+ tempDirs.push(projectsDir);
+
+ const encodedName = '-Users-test-deleted-project';
+ const projectDir = path.join(projectsDir, encodedName);
+ fs.mkdirSync(projectDir);
+
+ fs.writeFileSync(
+ path.join(projectDir, 'session-deleted.jsonl'),
+ createSessionLine({ cwd: '/Users/test/deleted-project' }) + '\n'
+ );
+
+ const scanner = new ProjectScanner(projectsDir);
+ const projects = await scanner.scan();
+
+ expect(projects[0]).toMatchObject({
+ path: '/Users/test/deleted-project',
+ filesystemState: 'deleted',
+ });
+ });
});
diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts
index 39e08c66..140454b7 100644
--- a/test/main/services/infrastructure/CliInstallerService.test.ts
+++ b/test/main/services/infrastructure/CliInstallerService.test.ts
@@ -151,7 +151,7 @@ describe('CliInstallerService', () => {
'opencode',
]);
expect(openCodeStatus).toMatchObject({
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
statusMessage: 'Runtime not found.',
canLoginFromUi: false,
diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
index dd039aec..df94eae6 100644
--- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
+++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
@@ -221,7 +221,7 @@ describe('ClaudeMultimodelBridgeService', () => {
});
expect(providers[3]).toMatchObject({
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
authenticated: false,
models: [],
diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts
index ff2affe4..061853eb 100644
--- a/test/renderer/components/cli/CliStatusVisibility.test.ts
+++ b/test/renderer/components/cli/CliStatusVisibility.test.ts
@@ -450,7 +450,7 @@ describe('CLI status visibility during completed install state', () => {
providers: [
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
authenticated: false,
authMethod: null,
@@ -476,7 +476,7 @@ describe('CLI status visibility during completed install state', () => {
await Promise.resolve();
});
- expect(host.textContent).toContain('OpenCode (75+ LLM providers)');
+ expect(host.textContent).toContain('OpenCode (200+ models)');
expect(host.textContent).toContain('Install');
const installButton = Array.from(host.querySelectorAll('button')).find(
@@ -523,7 +523,7 @@ describe('CLI status visibility during completed install state', () => {
providers: [
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
authenticated: false,
authMethod: null,
@@ -575,7 +575,7 @@ describe('CLI status visibility during completed install state', () => {
providers: [
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
diff --git a/test/renderer/components/extensions/skills/SkillsPanel.test.ts b/test/renderer/components/extensions/skills/SkillsPanel.test.ts
index a19ce837..b71bae74 100644
--- a/test/renderer/components/extensions/skills/SkillsPanel.test.ts
+++ b/test/renderer/components/extensions/skills/SkillsPanel.test.ts
@@ -559,7 +559,7 @@ describe('SkillsPanel', () => {
});
expect(host.textContent).toContain(
- 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic, Codex, and OpenCode (75+ LLM providers).'
+ 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic, Codex, and OpenCode (200+ models).'
);
expect(host.textContent).toContain('Codex only');
diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts
index 4f794a3a..d8d33952 100644
--- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts
+++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts
@@ -1405,6 +1405,12 @@ describe('LaunchTeamDialog', () => {
expect(host.textContent).toContain('model:claude-opus-4-6');
expect(host.textContent).toContain('effort:max');
expect(host.textContent).toContain('fast:on');
+ expect(host.textContent).toContain('monthly Agent SDK credit');
+ expect(
+ host.querySelector(
+ 'a[href="https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan"]'
+ )
+ ).toBeTruthy();
const submitButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Save Changes'
diff --git a/test/renderer/components/team/dialogs/projectPathOptions.test.ts b/test/renderer/components/team/dialogs/projectPathOptions.test.ts
index 33685f88..2af0d127 100644
--- a/test/renderer/components/team/dialogs/projectPathOptions.test.ts
+++ b/test/renderer/components/team/dialogs/projectPathOptions.test.ts
@@ -88,4 +88,27 @@ describe('buildProjectPathOptions', () => {
},
]);
});
+
+ it('marks deleted project paths as disabled options', () => {
+ const options = buildProjectPathOptions([
+ createProject({
+ id: 'project-deleted',
+ name: 'my-tes',
+ path: '/Users/belief/dev/projects/my-tes',
+ filesystemState: 'deleted',
+ }),
+ ]);
+
+ expect(options).toEqual([
+ {
+ value: '/Users/belief/dev/projects/my-tes',
+ label: 'my-tes',
+ description: '/Users/belief/dev/projects/my-tes',
+ disabled: true,
+ meta: {
+ filesystemState: 'deleted',
+ },
+ },
+ ]);
+ });
});