fix: harden Windows frontend stability (#125)

This commit is contained in:
infiniti 2026-05-16 19:57:11 +03:00 committed by GitHub
parent a6ba6072c0
commit d29f3a23d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1787 additions and 307 deletions

View file

@ -23,7 +23,7 @@
</p>
<p align="center">
<sub>Free desktop app for AI agent teams. Auto-detects Claude/Codex/OpenCode (75+ LLM providers). Use the provider access you already have - subscriptions or API keys. Not just coding agents.</sub>
<sub>Free desktop app for AI agent teams. Auto-detects Claude/Codex/OpenCode (200+ models). Use the provider access you already have - subscriptions or API keys. Not just coding agents.</sub>
</p>
<img width="1304" height="820" alt="image" src="https://github.com/user-attachments/assets/dea53a01-68b3-4c36-bcf6-e4d1ad4cdb31" />

View file

@ -127,6 +127,7 @@
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",

View file

@ -126,6 +126,9 @@ importers:
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dropdown-menu':
specifier: 2.1.16
version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-hover-card':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -3191,6 +3194,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-dropdown-menu@2.1.16':
resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-focus-guards@1.1.3':
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
peerDependencies:
@ -13999,6 +14015,21 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)':
dependencies:
react: 19.2.4

View file

@ -0,0 +1,86 @@
import { type ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
import { MessagesPanel } from '@renderer/components/team/messages/MessagesPanel';
import {
getTeamPendingRepliesState,
setTeamPendingRepliesState,
} from '@renderer/components/team/sidebar/teamSidebarUiState';
import { useStore } from '@renderer/store';
import {
selectResolvedMembersForTeamName,
selectTeamDataForName,
} from '@renderer/store/slices/teamSlice';
import { useShallow } from 'zustand/react/shallow';
interface UseGraphMessagesPanelInput {
teamName: string;
enabled?: boolean;
mountPoint?: Element | null;
onOpenMemberProfile: (memberName: string) => void;
onOpenTaskDetail: (taskId: string) => void;
}
export function useGraphMessagesPanel({
teamName,
enabled = true,
mountPoint,
onOpenMemberProfile,
onOpenTaskDetail,
}: UseGraphMessagesPanelInput): ReactElement {
const [pendingRepliesByMember, setPendingRepliesByMember] = useState(() =>
getTeamPendingRepliesState(teamName)
);
const { messagesPanelMode, setMessagesPanelMode, members, tasks, isTeamAlive } = useStore(
useShallow((state) => {
const teamData = selectTeamDataForName(state, teamName);
return {
messagesPanelMode: state.messagesPanelMode,
setMessagesPanelMode: state.setMessagesPanelMode,
members: selectResolvedMembersForTeamName(state, teamName),
tasks: teamData?.tasks ?? [],
isTeamAlive: teamData?.isAlive,
};
})
);
const activeMembers = useMemo(() => members.filter((member) => !member.removedAt), [members]);
useEffect(() => {
setPendingRepliesByMember(getTeamPendingRepliesState(teamName));
}, [teamName]);
useEffect(() => {
setTeamPendingRepliesState(teamName, pendingRepliesByMember);
}, [pendingRepliesByMember, teamName]);
const handlePendingReplyChange = useCallback(
(updater: (prev: Record<string, number>) => Record<string, number>) => {
setPendingRepliesByMember(updater);
},
[]
);
if (
!enabled ||
(messagesPanelMode !== 'floating-composer' && messagesPanelMode !== 'bottom-sheet')
) {
return <></>;
}
return (
<MessagesPanel
teamName={teamName}
position={messagesPanelMode}
onPositionChange={setMessagesPanelMode}
mountPoint={messagesPanelMode === 'bottom-sheet' ? mountPoint : undefined}
members={activeMembers}
tasks={tasks}
isTeamAlive={isTeamAlive}
timeWindow={null}
pendingRepliesByMember={pendingRepliesByMember}
onPendingReplyChange={handlePendingReplyChange}
onMemberClick={(member) => onOpenMemberProfile(member.name)}
onTaskClick={(task) => onOpenTaskDetail(task.id)}
onTaskIdClick={onOpenTaskDetail}
/>
);
}

View file

@ -3,12 +3,13 @@
* Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50).
*/
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel';
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
@ -36,6 +37,7 @@ export interface TeamGraphOverlayProps {
onPinAsTab?: () => void;
sidebarVisible?: boolean;
onToggleSidebar?: () => void;
messagesPanelEnabled?: boolean;
onSendMessage?: (memberName: string) => void;
onOpenTaskDetail?: (taskId: string) => void;
onOpenMemberProfile?: (
@ -53,6 +55,7 @@ export const TeamGraphOverlay = ({
onPinAsTab,
sidebarVisible,
onToggleSidebar,
messagesPanelEnabled = true,
onSendMessage,
onOpenTaskDetail,
onOpenMemberProfile,
@ -67,8 +70,18 @@ export const TeamGraphOverlay = ({
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
useGraphSidebarVisibility();
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState<HTMLDivElement | null>(
null
);
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
const graphMessagesPanel = useGraphMessagesPanel({
teamName,
enabled: messagesPanelEnabled,
mountPoint: messagesPanelMountPoint,
onOpenMemberProfile: (memberName) => onOpenMemberProfile?.(memberName),
onOpenTaskDetail: (taskId) => onOpenTaskDetail?.(taskId),
});
// Task action dispatchers (same pattern as TeamGraphTab)
const dispatchTaskAction = useCallback(
@ -242,6 +255,13 @@ export const TeamGraphOverlay = ({
/>
)}
/>
{messagesPanelEnabled ? (
<div
ref={setMessagesPanelMountPoint}
className="pointer-events-none absolute inset-0 z-30"
/>
) : null}
{graphMessagesPanel}
{createTaskDialog}
</div>
);

View file

@ -9,6 +9,7 @@ import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel';
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
@ -54,6 +55,9 @@ export const TeamGraphTab = ({
const { openTeamPage, commitOwnerSlotDrop, commitOwnerGridOrderDrop, setLayoutMode } =
useTeamGraphSurfaceActions(teamName);
const [fullscreen, setFullscreen] = useState(false);
const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState<HTMLDivElement | null>(
null
);
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
@ -129,9 +133,16 @@ export const TeamGraphTab = ({
[dispatchOpenProfile]
),
};
const graphMessagesPanel = useGraphMessagesPanel({
teamName,
enabled: isActive && isPaneFocused && !fullscreen,
mountPoint: messagesPanelMountPoint,
onOpenMemberProfile: dispatchOpenProfile,
onOpenTaskDetail: dispatchOpenTask,
});
return (
<div className="flex size-full overflow-hidden" style={{ background: '#050510' }}>
<div className="relative flex size-full overflow-hidden" style={{ background: '#050510' }}>
{sidebarVisible ? (
<TeamSidebarHost
teamName={teamName}
@ -262,6 +273,13 @@ export const TeamGraphTab = ({
)}
/>
</div>
{isActive && isPaneFocused && !fullscreen ? (
<div
ref={setMessagesPanelMountPoint}
className="pointer-events-none absolute inset-0 z-30"
/>
) : null}
{graphMessagesPanel}
{createTaskDialog}
{fullscreen && (
<Suspense fallback={null}>
@ -273,6 +291,7 @@ export const TeamGraphTab = ({
onSendMessage={dispatchSendMessage}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
messagesPanelEnabled={isActive && isPaneFocused}
/>
</Suspense>
)}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
export type RecentProjectFilesystemState = 'available' | 'deleted';

View file

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

View file

@ -20,6 +20,7 @@ export class DashboardRecentProjectsPresenter implements ListDashboardRecentProj
source: aggregate.source,
openTarget: aggregate.openTarget,
primaryBranch: aggregate.branchName,
filesystemState: aggregate.filesystemState,
})
),
};

View file

@ -44,6 +44,7 @@ function toCandidate(repo: RepositoryGroup): RecentProjectCandidate | null {
worktreeId: preferredWorktree.id,
},
branchName: preferredWorktree.gitBranch,
filesystemState: preferredWorktree.filesystemState ?? 'available',
};
}

View file

@ -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<RecentProjectCandidate | null> {
async #toCandidate(
thread: CodexThreadSummary,
fsProvider?: ServiceContext['fsProvider']
): Promise<RecentProjectCandidate | null> {
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),
};
}
}

View file

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

View file

@ -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<FileSystemProvider, 'exists'>
): Promise<RecentProjectFilesystemState> {
if (!projectPath.trim()) {
return 'deleted';
}
if (!fsProvider) {
return 'available';
}
try {
return (await fsProvider.exists(projectPath)) ? 'available' : 'deleted';
} catch {
return 'deleted';
}
}

View file

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

View file

@ -106,6 +106,11 @@ export function useOpenRecentProject(): {
const openRecentProject = useCallback(
async (project: DashboardRecentProject): Promise<void> => {
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]);

View file

@ -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<RecentProjectCardProps>): React.JSX.Element => {
const color = useMemo(() => projectColor(card.name), [card.name]);
const isDeleted = card.filesystemState === 'deleted';
const FolderIcon = isDeleted ? FolderX : FolderGit2;
return (
<button
onClick={onClick}
className="project-row-zebra-card group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis"
onClick={isDeleted ? undefined : onClick}
aria-disabled={isDeleted}
className={cn(
'project-row-zebra-card group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis',
isDeleted && 'cursor-default border-red-500/25 bg-red-500/[0.03] hover:border-red-500/35'
)}
>
{card.activeTeams && card.activeTeams.length > 0 && (
<ActivePulseIndicator className="absolute right-3 top-3" />
@ -32,9 +39,9 @@ export const RecentProjectCard = ({
<div className="mb-1 flex items-center gap-2.5">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
<FolderGit2
<FolderIcon
className="size-4 transition-colors group-hover:text-text"
style={{ color: color.icon }}
style={{ color: isDeleted ? 'var(--field-error-text)' : color.icon }}
/>
</div>
<div className="min-w-0">
@ -42,6 +49,16 @@ export const RecentProjectCard = ({
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
{card.name}
</h3>
{isDeleted && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 items-center rounded-full border border-red-500/30 bg-red-500/10 px-1.5 py-0.5 text-[9px] font-medium text-red-300">
Deleted
</span>
</TooltipTrigger>
<TooltipContent side="bottom">Project folder no longer exists</TooltipContent>
</Tooltip>
)}
{card.pathSummary && (
<Tooltip>
<TooltipTrigger asChild>
@ -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'
)}
>
<FolderOpen className="size-3" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom">Open</TooltipContent>
<TooltipContent side="bottom">
{isDeleted ? 'Project folder no longer exists' : 'Open'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>

View file

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

View file

@ -501,7 +501,7 @@ export class CliInstallerService {
},
{
providerId: 'opencode',
displayName: 'OpenCode (75+ LLM providers)',
displayName: 'OpenCode (200+ models)',
},
] as const
).map((provider) => ({

View file

@ -326,7 +326,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
return 'OpenCode (75+ LLM providers)';
return 'OpenCode (200+ models)';
}
}

View file

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

View file

@ -385,7 +385,7 @@ function getProviderLabel(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
return 'OpenCode (75+ LLM providers)';
return 'OpenCode (200+ models)';
}
}

View file

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

View file

@ -123,7 +123,7 @@ function getProviderLabel(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
return 'OpenCode (75+ LLM providers)';
return 'OpenCode (200+ models)';
}
}

View file

@ -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 && (
<TeamMessagesPanelBridge position="bottom-sheet" {...sharedMessagesPanelProps} />
)}
{messagesPanelMode === 'floating-composer' &&
isThisTabActive &&
isPaneFocused &&
!graphOpen && (
<TeamMessagesPanelBridge
position="floating-composer"
{...sharedMessagesPanelProps}
/>
)}
</div>
</div>
@ -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);

View file

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

View file

@ -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 <code className="font-mono">claude -p</code> for
one-shot execution
</p>
{selectedProviderId === 'anthropic' ? (
<div className="flex gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 p-2 text-[11px] leading-relaxed text-amber-100">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<p>
Starting June 15, 2026, Anthropic bills <code>claude -p</code> 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.{' '}
<a
href={ANTHROPIC_AGENT_SDK_CREDIT_ARTICLE_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 font-medium underline underline-offset-2 hover:text-white"
>
Read Anthropic article
<ExternalLink className="size-3" />
</a>
.
</p>
</div>
) : null}
</div>
<div>

View file

@ -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 => (
<span
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-red-500/30 bg-red-500/10 px-1.5 py-0.5 text-[10px] font-medium text-red-300"
title="Project folder no longer exists"
>
<FolderX className="size-3" />
Deleted
</span>
);
export type CwdMode = 'project' | 'custom';
interface ProjectPathSelectorProps {
@ -178,28 +192,38 @@ export const ProjectPathSelector = ({
renderTriggerLabel={(option) => (
<span className="flex min-w-0 items-center gap-1.5">
<ProjectSourceBadge source={getOptionSource(option)} />
{isDeletedOption(option) ? <ProjectDeletedBadge /> : null}
<span className="min-w-0 truncate">{option.label}</span>
</span>
)}
renderOption={(option, isSelected, query) => (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<ProjectSourceBadge source={getOptionSource(option)} />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{renderHighlightedText(option.label, query)}
</p>
<p className="truncate text-[var(--color-text-muted)]">
{renderHighlightedText(option.description ?? '', query)}
</p>
renderOption={(option, isSelected, query) => {
const isDeleted = isDeletedOption(option);
return (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<Check
className={cn(
'size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<ProjectSourceBadge source={getOptionSource(option)} />
{isDeleted ? <ProjectDeletedBadge /> : null}
<div className="min-w-0 flex-1">
<p
className={cn(
'truncate font-medium text-[var(--color-text)]',
isDeleted && 'text-red-200'
)}
>
{renderHighlightedText(option.label, query)}
</p>
<p className="truncate text-[var(--color-text-muted)]">
{renderHighlightedText(option.description ?? '', query)}
</p>
</div>
</div>
</>
)}
);
}}
/>
</div>
</div>

View file

@ -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<string, unknown> {
discoverySource?: DashboardRecentProjectSource;
filesystemState?: DashboardRecentProjectFilesystemState;
}
function toProjectOption(project: ProjectPathProject): ComboboxOption {
@ -20,15 +25,45 @@ 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;
}
export function isSelectableProjectPathProject(
project: Pick<ProjectPathProject, 'path' | 'filesystemState'>
): boolean {
return !isEphemeralProjectPath(project.path) && project.filesystemState !== 'deleted';
}
export function findProjectPathProjectByPath(
projects: readonly ProjectPathProject[],
projectPath: string
): ProjectPathProject | undefined {
const normalizedPath = normalizePath(projectPath);
return projects.find((project) => normalizePath(project.path) === normalizedPath);
}
export function isDeletedProjectPathSelection(
projects: readonly ProjectPathProject[],
projectPath: string
): boolean {
return findProjectPathProjectByPath(projects, projectPath)?.filesystemState === 'deleted';
}
/**
* Collapse duplicate project entries that resolve to the same filesystem path.
* This keeps combobox item values unique even when scanner sources overlap.

View file

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

View file

@ -32,6 +32,7 @@ import {
isPathPrefix,
joinPath,
lastSeparatorIndex,
normalizePathForComparison,
splitPath,
} from '@shared/utils/platformPath';
import { useVirtualizer } from '@tanstack/react-virtual';
@ -81,6 +82,17 @@ const INDENT_PX = 12;
const MAX_DEPTH = 12;
const AUTO_EXPAND_DELAY_MS = 500;
function treePathsEqual(
left: string | null | undefined,
right: string | null | undefined
): boolean {
return (
typeof left === 'string' &&
typeof right === 'string' &&
normalizePathForComparison(left) === normalizePathForComparison(right)
);
}
// =============================================================================
// Component
// =============================================================================
@ -204,7 +216,7 @@ export const EditorFileTree = ({
// Scroll to file when selectedFilePath changes (e.g. from revealFileInEditor)
useEffect(() => {
if (!selectedFilePath) return;
const idx = flatItems.findIndex((fi) => fi.node.fullPath === selectedFilePath);
const idx = flatItems.findIndex((fi) => treePathsEqual(fi.node.fullPath, selectedFilePath));
if (idx >= 0) {
virtualizer.scrollToIndex(idx, { align: 'center' });
}
@ -633,7 +645,7 @@ const DraggableTreeItem = React.memo(
onRenameCancel,
}: DraggableTreeItemProps): React.ReactElement => {
const { node, depth, isExpanded } = item;
const isSelected = activeNodePath === node.fullPath;
const isSelected = treePathsEqual(activeNodePath, node.fullPath);
const visualDepth = Math.min(depth, MAX_DEPTH);
const isSensitive = node.data?.isSensitive;

View file

@ -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<HTMLTextAreaElement>;
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<HTMLInputElement>(null);
const [isTextareaFocused, setIsTextareaFocused] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(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
</span>
) : null;
const shouldShowFooterCharCount = remaining < 200;
const shouldShowSavedIndicator = isTextareaFocused && draft.isSaved;
const nonCompactFooterRight =
compactFooterNotice || shouldShowFooterCharCount || shouldShowSavedIndicator ? (
<div className="flex flex-col items-end gap-1">
{compactFooterNotice}
{shouldShowFooterCharCount || shouldShowSavedIndicator ? (
<div className="flex items-center gap-2">
{shouldShowFooterCharCount ? (
<span
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
>
{remaining} chars left
</span>
) : null}
{shouldShowSavedIndicator ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null}
</div>
) : null}
</div>
) : null;
const composerFooterRight = isCompactLayout ? compactFooterNotice : nonCompactFooterRight;
return (
<div
@ -1045,6 +1073,8 @@ export const MessageComposer = ({
commandSuggestions={slashCommandSuggestions}
chips={draft.chips}
onChipRemove={draft.removeChip}
onFocus={handleTextareaFocus}
onBlur={handleTextareaBlur}
projectPath={projectPath}
onFileChipInsert={draft.addChip}
onModEnter={handleSend}
@ -1059,7 +1089,7 @@ export const MessageComposer = ({
maxRows={6}
maxLength={MAX_TEXT_LENGTH}
hintText={crossTeamHintText}
showHint={!isCompactLayout}
showHint={!isCompactLayout && isTextareaFocused}
cornerActionInset={isCompactLayout ? 'compact' : 'default'}
cornerActionLeft={
<ActionModeSelector
@ -1071,6 +1101,7 @@ export const MessageComposer = ({
}
cornerAction={
<div className="flex items-center gap-2">
{cornerActionPrefix}
{/* NOTE: ContextRing disabled — usage formula is inaccurate */}
<Tooltip>
<TooltipTrigger asChild>
@ -1108,27 +1139,7 @@ export const MessageComposer = ({
</Tooltip>
</div>
}
footerRight={
isCompactLayout ? (
compactFooterNotice
) : (
<div className="flex flex-col items-end gap-1">
{compactFooterNotice}
<div className="flex items-center gap-2">
{remaining < 200 ? (
<span
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
>
{remaining} chars left
</span>
) : null}
{draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null}
</div>
</div>
)
}
footerRight={composerFooterRight}
/>
</div>
</div>

View file

@ -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 = (
<div className="inline-flex items-center gap-0.5 pr-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={moveToInline}
aria-label="Move messages to inline panel"
>
<PanelBottom size={13} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Move to inline</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={moveToBottomSheet}
aria-label="Move messages to bottom sheet"
>
<PanelBottomOpen size={13} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Move to bottom sheet</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={moveToSidebar}
aria-label="Move messages to sidebar"
>
<PanelLeft size={13} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Move to sidebar</TooltipContent>
</Tooltip>
</div>
);
const compactComposerSection = (
<MessagesComposerSection
teamName={teamName}
@ -881,6 +940,24 @@ export const MessagesPanel = memo(function MessagesPanel({
/>
);
const floatingComposerSection = (
<MessagesComposerSection
teamName={teamName}
layout="compact"
members={members}
isTeamAlive={isTeamAlive}
sending={sendingMessage}
sendError={sendMessageError}
sendWarning={effectiveSendMessageWarning}
sendDebugDetails={effectiveSendMessageDebugDetails}
lastResult={lastSendMessageResult}
cornerActionPrefix={floatingComposerModeControls}
textareaRef={composerTextareaRef}
onSend={handleSend}
onCrossTeamSend={handleCrossTeamSend}
/>
);
const inlineStatusSection = (
<MessagesStatusSection
members={members}
@ -1054,54 +1131,53 @@ export const MessagesPanel = memo(function MessagesPanel({
</Tooltip>
)}
<div className="ml-auto flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={() => setMessagesCollapsed((v) => !v)}
aria-label={messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
>
{messagesCollapsed ? <ChevronsUpDown size={14} /> : <ChevronsDownUp size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={() => setMessagesSearchBarVisible((v) => !v)}
aria-label={
messagesSearchBarVisible ? 'Hide message search' : 'Show message search'
}
>
{messagesSearchBarVisible ? <X size={14} /> : <Search size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={moveToInline}
aria-label="Move messages to inline panel"
>
<PanelLeftClose size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Move to inline</TooltipContent>
</Tooltip>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] data-[state=open]:bg-[var(--color-surface-raised)] data-[state=open]:text-[var(--color-text-secondary)]"
aria-label="Message panel actions"
>
<MoreHorizontal size={15} />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Message actions</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" side="bottom" className="w-48">
<DropdownMenuItem onSelect={() => setMessagesCollapsed((v) => !v)}>
{messagesCollapsed ? (
<ChevronsUpDown size={14} className="shrink-0" />
) : (
<ChevronsDownUp size={14} className="shrink-0" />
)}
<span>{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setMessagesSearchBarVisible((v) => !v)}>
{messagesSearchBarVisible ? (
<X size={14} className="shrink-0" />
) : (
<Search size={14} className="shrink-0" />
)}
<span>{messagesSearchBarVisible ? 'Hide search' : 'Search messages'}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={moveToInline}>
<PanelLeftClose size={14} className="shrink-0" />
<span>Move to inline</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={moveToBottomSheet}>
<PanelBottomOpen size={14} className="shrink-0" />
<span>Move to bottom sheet</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={moveToFloatingComposer}>
<Dock size={14} className="shrink-0" />
<span>Float composer</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Search & filter bar (toggleable) */}
@ -1126,6 +1202,16 @@ export const MessagesPanel = memo(function MessagesPanel({
);
}
if (position === 'floating-composer') {
return (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-40 px-4 pb-5 sm:px-6 sm:pb-6">
<div className="mx-auto w-full max-w-[500px]">
<div className="pointer-events-auto">{floatingComposerSection}</div>
</div>
</div>
);
}
if (position === 'bottom-sheet') {
if (!mountPoint) {
return <div className="hidden" aria-hidden="true" />;
@ -1196,114 +1282,74 @@ export const MessagesPanel = memo(function MessagesPanel({
className="ml-auto flex items-center gap-1"
onPointerDown={(e) => e.stopPropagation()}
>
{messagesUnreadCount > 0 && (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-[22px] p-0 text-blue-400 hover:bg-blue-500/10 hover:text-blue-300"
onClick={handleMarkAllRead}
aria-label="Mark all messages as read"
>
<CheckCheck size={13} />
</Button>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] data-[state=open]:bg-[var(--color-surface-raised)] data-[state=open]:text-[var(--color-text-secondary)]"
aria-label="Message bottom sheet actions"
>
<MoreHorizontal size={14} />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">Mark all as read</TooltipContent>
<TooltipContent side="top">Message actions</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={() => setMessagesCollapsed((value) => !value)}
aria-label={
messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'
}
>
<DropdownMenuContent align="end" side="top" className="w-48">
{messagesUnreadCount > 0 && (
<DropdownMenuItem
className="text-blue-400 focus:text-blue-300"
onSelect={handleMarkAllRead}
>
<CheckCheck size={14} className="shrink-0" />
<span>Mark all as read</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => setMessagesCollapsed((value) => !value)}>
{messagesCollapsed ? (
<ChevronsUpDown size={14} />
<ChevronsUpDown size={14} className="shrink-0" />
) : (
<ChevronsDownUp size={14} />
<ChevronsDownUp size={14} className="shrink-0" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={() => setMessagesSearchBarVisible((value) => !value)}
aria-label={
messagesSearchBarVisible ? 'Hide message search' : 'Show message search'
}
>
{messagesSearchBarVisible ? <X size={14} /> : <Search size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={toggleBottomSheetExpansion}
aria-label={
isBottomSheetCollapsed
? 'Expand messages bottom sheet'
: 'Collapse messages bottom sheet'
}
<span>
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setMessagesSearchBarVisible((value) => !value)}
>
{messagesSearchBarVisible ? (
<X size={14} className="shrink-0" />
) : (
<Search size={14} className="shrink-0" />
)}
<span>{messagesSearchBarVisible ? 'Hide search' : 'Search messages'}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={toggleBottomSheetExpansion}>
{isBottomSheetCollapsed ? (
<PanelBottomOpen size={14} />
<PanelBottomOpen size={14} className="shrink-0" />
) : (
<PanelBottomClose size={14} />
<PanelBottomClose size={14} className="shrink-0" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={moveToInline}
aria-label="Move messages to inline panel"
>
<PanelBottom size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Move to inline</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={moveToSidebar}
aria-label="Move messages to sidebar"
>
<PanelLeft size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Move to sidebar</TooltipContent>
</Tooltip>
<span>{isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={moveToInline}>
<PanelBottom size={14} className="shrink-0" />
<span>Move to inline</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={moveToSidebar}>
<PanelLeft size={14} className="shrink-0" />
<span>Move to sidebar</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={moveToFloatingComposer}>
<Dock size={14} className="shrink-0" />
<span>Float composer</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
@ -1386,6 +1432,23 @@ export const MessagesPanel = memo(function MessagesPanel({
</TooltipTrigger>
<TooltipContent side="top">Move to bottom sheet</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
moveToFloatingComposer();
}}
aria-label="Float messages composer"
>
<Dock size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Float composer</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button

View file

@ -10,6 +10,7 @@ export interface ComboboxOption {
value: string;
label: string;
description?: string;
disabled?: boolean;
/** Extra data for renderOption (e.g. sessionCount, path). */
meta?: Record<string, unknown>;
}
@ -133,12 +134,20 @@ export const Combobox = ({
<CommandPrimitive.Item
key={option.value}
value={option.value}
disabled={option.disabled}
aria-disabled={option.disabled === true}
onSelect={() => {
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)

View file

@ -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<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[160px] rounded-md border border-[var(--color-border-emphasis)] bg-[var(--color-surface-overlay)] p-1 text-[12px] text-[var(--color-text)] shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded px-2 py-1.5 text-[12px] text-[var(--color-text-secondary)] outline-none transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-[var(--color-text)] focus:bg-[rgba(255,255,255,0.06)] focus:text-[var(--color-text)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('my-1 h-px bg-[var(--color-border)]', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
};
/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */

View file

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

View file

@ -19,6 +19,7 @@ import {
isWindowsishPath,
joinPath,
lastSeparatorIndex,
normalizePathForComparison,
splitPath,
stripTrailingSeparators,
} from '@shared/utils/platformPath';
@ -42,6 +43,29 @@ function omitKey<V>(record: Record<string, V>, key: string): Record<string, V> {
return result;
}
function editorPathsEqual(left: string, right: string): boolean {
return normalizePathForComparison(left) === normalizePathForComparison(right);
}
function findMatchingPathKey<V>(record: Record<string, V>, filePath: string): string | null {
if (filePath in record) return filePath;
return Object.keys(record).find((key) => editorPathsEqual(key, filePath)) ?? null;
}
function getMatchingPathMapValue<V>(map: Map<string, V>, filePath: string): V | undefined {
const exact = map.get(filePath);
if (exact !== undefined) return exact;
for (const [key, value] of map) {
if (editorPathsEqual(key, filePath)) return value;
}
return undefined;
}
function omitMatchingPathKey<V>(record: Record<string, V>, filePath: string): Record<string, V> {
const key = findMatchingPathKey(record, filePath);
return key ? omitKey(record, key) : record;
}
/**
* Cooldown map: filePath timestamp of last successful save.
*
@ -617,7 +641,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const { editorOpenTabs } = get();
// Dedup: if file already open, just activate it
const existing = editorOpenTabs.find((t) => t.filePath === filePath);
const existing = editorOpenTabs.find((t) => editorPathsEqual(t.filePath, filePath));
if (existing) {
set({ editorActiveTabId: existing.id });
return;
@ -1216,26 +1240,27 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
}, 2000);
}
const { editorOpenTabs, editorProjectPath, editorSaving } = get();
const openTab = editorOpenTabs.find((tab) => editorPathsEqual(tab.filePath, event.path));
const canonicalEventPath = openTab?.filePath ?? event.path;
// Ignore watcher events for files we are currently saving (our own write)
if (editorSaving[event.path]) return;
if (findMatchingPathKey(editorSaving, event.path)) return;
// Ignore watcher events within cooldown after save
// (covers race: save completes → editorSaving cleared → watcher fires late)
const lastSaveTime = recentSaveTimestamps.get(event.path);
const lastSaveTime = getMatchingPathMapValue(recentSaveTimestamps, event.path);
if (lastSaveTime && Date.now() - lastSaveTime < SAVE_COOLDOWN_MS) return;
// Ignore watcher events within cooldown after move
const lastMoveTime = recentMoveTimestamps.get(event.path);
const lastMoveTime = getMatchingPathMapValue(recentMoveTimestamps, event.path);
if (lastMoveTime && Date.now() - lastMoveTime < MOVE_COOLDOWN_MS) return;
// Track changes for open files
const isOpenFile = editorOpenTabs.some((t) => t.filePath === event.path);
if (isOpenFile || event.type === 'delete') {
if (openTab || event.type === 'delete') {
set((s) => ({
editorExternalChanges: {
...s.editorExternalChanges,
[event.path]: event.type,
[canonicalEventPath]: event.type,
},
}));
}
@ -1267,7 +1292,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
clearExternalChange: (filePath: string) => {
set((s) => ({
editorExternalChanges: omitKey(s.editorExternalChanges, filePath),
editorExternalChanges: omitMatchingPathKey(s.editorExternalChanges, filePath),
}));
},

View file

@ -2,7 +2,7 @@
* Path resolution utilities for the store.
*/
import { stripTrailingSeparators } from '@shared/utils/platformPath';
import { isAbsoluteOrHomePath, stripTrailingSeparators } from '@shared/utils/platformPath';
/**
* Resolves a relative path against a base path, handling various path formats.
@ -15,7 +15,7 @@ import { stripTrailingSeparators } from '@shared/utils/platformPath';
*/
export function resolveFilePath(base: string, relativePath: string): string {
// If already absolute, return as-is
if (isAbsolutePath(relativePath)) {
if (isAbsoluteOrHomePath(relativePath)) {
return relativePath;
}
@ -27,13 +27,7 @@ export function resolveFilePath(base: string, relativePath: string): string {
cleanRelative = cleanRelative.slice(1);
}
if (isAbsolutePath(cleanRelative)) {
return cleanRelative;
}
// Tilde paths (~/) are home-relative absolute paths - pass through as-is
// The main process will expand ~ to the actual home directory
if (cleanRelative.startsWith('~/') || cleanRelative.startsWith('~\\') || cleanRelative === '~') {
if (isAbsoluteOrHomePath(cleanRelative)) {
return cleanRelative;
}
@ -69,10 +63,6 @@ export function resolveFilePath(base: string, relativePath: string): string {
return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase;
}
function isAbsolutePath(input: string): boolean {
return input.startsWith('/') || input.startsWith('\\\\') || /^[a-zA-Z]:[\\/]/.test(input);
}
function normalizeSeparators(input: string, separator: '/' | '\\'): string {
let output = '';
let prevWasSeparator = false;

View file

@ -1 +1 @@
export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet';
export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet' | 'floating-composer';

View file

@ -8,6 +8,7 @@
*/
import {
isAbsoluteOrHomePath,
isPathPrefix,
lastSeparatorIndex,
normalizePathForComparison,
@ -63,20 +64,6 @@ export function getDisplayName(path: string, _source: ClaudeMdSource): string {
return path;
}
/**
* Check if a path is absolute (starts with /).
*/
function isAbsolutePath(path: string): boolean {
return (
path.startsWith('/') ||
path.startsWith('~/') ||
path.startsWith('~\\') ||
path === '~' ||
path.startsWith('\\\\') ||
/^[a-zA-Z]:[\\/]/.test(path)
);
}
/**
* Join paths, handling various path formats properly.
* Handles:
@ -87,7 +74,7 @@ function isAbsolutePath(path: string): boolean {
* - Paths with @ prefix: @apps/foo/bar.tsx (strips @ then joins)
*/
function joinPaths(base: string, relative: string): string {
if (isAbsolutePath(relative)) {
if (isAbsoluteOrHomePath(relative)) {
return relative;
}
@ -99,7 +86,7 @@ function joinPaths(base: string, relative: string): string {
if (cleanRelative.startsWith('@')) {
cleanRelative = cleanRelative.slice(1);
}
if (isAbsolutePath(cleanRelative)) {
if (isAbsoluteOrHomePath(cleanRelative)) {
return cleanRelative;
}
@ -239,7 +226,9 @@ export function extractUserMentionPaths(
for (const ref of fileReferences) {
if (ref.path) {
// Convert to absolute if relative
const absolutePath = isAbsolutePath(ref.path) ? ref.path : joinPaths(projectRoot, ref.path);
const absolutePath = isAbsoluteOrHomePath(ref.path)
? ref.path
: joinPaths(projectRoot, ref.path);
paths.push(absolutePath);
}
}
@ -525,7 +514,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
const responseRefs = extractFileRefsFromResponses(aiGroup.responses);
for (const ref of responseRefs) {
if (ref.path) {
const absPath = isAbsolutePath(ref.path) ? ref.path : joinPaths(projectRoot, ref.path);
const absPath = isAbsoluteOrHomePath(ref.path) ? ref.path : joinPaths(projectRoot, ref.path);
allFilePaths.push(absPath);
}
}

View file

@ -9,7 +9,11 @@
* This builds on claudeMdTracker.ts and extends it to track all context sources.
*/
import { normalizePathForComparison, stripTrailingSeparators } from '@shared/utils/platformPath';
import {
isAbsoluteOrHomePath,
normalizePathForComparison,
stripTrailingSeparators,
} from '@shared/utils/platformPath';
import { estimateTokens } from '@shared/utils/tokenFormatting';
import { MAX_MENTIONED_FILE_TOKENS } from '../types/contextInjection';
@ -447,20 +451,6 @@ interface ComputeContextStatsParams {
directoryTokenData?: Record<string, ClaudeMdFileInfo>;
}
/**
* Helper to check if a path is absolute.
*/
function isAbsolutePath(path: string): boolean {
return (
path.startsWith('/') ||
path.startsWith('~/') ||
path.startsWith('~\\') ||
path === '~' ||
path.startsWith('\\\\') ||
/^[a-zA-Z]:[\\/]/.test(path)
);
}
/**
* Helper to join paths, handling various path formats properly.
* Handles:
@ -471,7 +461,7 @@ function isAbsolutePath(path: string): boolean {
* - Paths with @ prefix: @apps/foo/bar.tsx (strips @ then joins)
*/
function joinPaths(base: string, relative: string): string {
if (isAbsolutePath(relative)) {
if (isAbsoluteOrHomePath(relative)) {
return relative;
}
@ -482,7 +472,7 @@ function joinPaths(base: string, relative: string): string {
if (cleanRelative.startsWith('@')) {
cleanRelative = cleanRelative.slice(1);
}
if (isAbsolutePath(cleanRelative)) {
if (isAbsoluteOrHomePath(cleanRelative)) {
return cleanRelative;
}
@ -679,7 +669,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
const responseRefs = extractFileRefsFromResponses(aiGroup.responses);
for (const ref of responseRefs) {
if (ref.path) {
const absPath = isAbsolutePath(ref.path) ? ref.path : joinPaths(projectRoot, ref.path);
const absPath = isAbsoluteOrHomePath(ref.path) ? ref.path : joinPaths(projectRoot, ref.path);
allFilePaths.push(absPath);
}
}
@ -735,7 +725,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
if (!fileRef.path) continue;
// Convert to absolute path if needed
const absolutePath = isAbsolutePath(fileRef.path)
const absolutePath = isAbsoluteOrHomePath(fileRef.path)
? fileRef.path
: joinPaths(projectRoot, fileRef.path);
@ -768,7 +758,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
for (const fileRef of responseRefs) {
if (!fileRef.path) continue;
const absolutePath = isAbsolutePath(fileRef.path)
const absolutePath = isAbsoluteOrHomePath(fileRef.path)
? fileRef.path
: joinPaths(projectRoot, fileRef.path);

View file

@ -22,6 +22,18 @@ export function isWindowsishPath(filePath: string): boolean {
return /^[A-Za-z]:\//.test(p) || p.startsWith('//');
}
/** True for filesystem-absolute paths and home-relative `~` paths. */
export function isAbsoluteOrHomePath(filePath: string): boolean {
return (
filePath.startsWith('/') ||
filePath.startsWith('~/') ||
filePath.startsWith('~\\') ||
filePath === '~' ||
filePath.startsWith('\\\\') ||
/^[A-Za-z]:[\\/]/.test(filePath)
);
}
/**
* Normalize for comparisons:
* - Convert `\``/`

View file

@ -86,6 +86,62 @@ function metrics(overrides: Partial<MemberWorkSyncTeamMetrics> = {}): MemberWork
};
}
function nativeStaleInProgressStatus(
overrides: Partial<MemberWorkSyncStatus> = {}
): MemberWorkSyncStatus {
const base = status({
providerId: 'codex',
diagnostics: ['no_current_report'],
agenda: {
...status().agenda,
fingerprint: 'agenda:v1:native-stale',
items: [
{
taskId: 'task-1',
displayId: '#1',
subject: 'Review landing',
kind: 'work',
assignee: 'alice',
priority: 'normal',
reason: 'owned_in_progress_task',
evidence: {
status: 'in_progress',
owner: 'alice',
},
},
],
},
});
return { ...base, ...overrides };
}
function staleMetrics(
overrides: Partial<MemberWorkSyncTeamMetrics> = {}
): MemberWorkSyncTeamMetrics {
return metrics({
generatedAt: '2026-05-06T00:06:00.000Z',
phase2Readiness: {
...metrics().phase2Readiness,
state: 'blocked',
reasons: ['would_nudge_rate_high', 'fingerprint_churn_high'],
},
recentEvents: [
{
id: 'status-stale',
teamName: 'team-a',
memberName: 'alice',
kind: 'status_evaluated',
state: 'needs_sync',
agendaFingerprint: 'agenda:v1:native-stale',
recordedAt: '2026-05-06T00:00:00.000Z',
actionableCount: 1,
providerId: 'codex',
},
],
...overrides,
});
}
describe('MemberWorkSyncNudgeActivationPolicy', () => {
it('activates OpenCode targeted nudges while shadow data is still collecting', () => {
expect(
@ -348,6 +404,313 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => {
).toEqual({ active: false, reason: 'blocking_metrics' });
});
it('activates stale native single in-progress recovery despite blocking metrics', () => {
expect(
decideMemberWorkSyncNudgeActivation({
status: nativeStaleInProgressStatus(),
metrics: staleMetrics(),
})
).toEqual({ active: true, reason: 'native_stale_in_progress' });
});
it('does not activate stale native in-progress recovery before the quiet window elapses', () => {
expect(
decideMemberWorkSyncNudgeActivation({
status: nativeStaleInProgressStatus(),
metrics: staleMetrics({
generatedAt: '2026-05-06T00:05:59.000Z',
}),
})
).toEqual({ active: false, reason: 'blocking_metrics' });
});
it('does not activate stale native in-progress recovery after an accepted report for the fingerprint', () => {
expect(
decideMemberWorkSyncNudgeActivation({
status: nativeStaleInProgressStatus(),
metrics: staleMetrics({
recentEvents: [
...staleMetrics().recentEvents,
{
id: 'report-accepted',
teamName: 'team-a',
memberName: 'alice',
kind: 'report_accepted',
state: 'still_working',
agendaFingerprint: 'agenda:v1:native-stale',
recordedAt: '2026-05-06T00:03:00.000Z',
actionableCount: 1,
providerId: 'codex',
},
],
}),
})
).toEqual({ active: false, reason: 'blocking_metrics' });
});
it('does not activate stale native in-progress recovery when the accepted report state is still current', () => {
expect(
decideMemberWorkSyncNudgeActivation({
status: nativeStaleInProgressStatus({
state: 'still_working',
report: {
state: 'still_working',
agendaFingerprint: 'agenda:v1:native-stale',
memberName: 'alice',
teamName: 'team-a',
reportedAt: '2026-05-06T00:03:00.000Z',
accepted: true,
},
}),
metrics: staleMetrics(),
})
).toEqual({ active: false, reason: 'status_not_nudgeable' });
});
it('resets the stale native in-progress quiet window after a fingerprint change', () => {
expect(
decideMemberWorkSyncNudgeActivation({
status: nativeStaleInProgressStatus(),
metrics: staleMetrics({
generatedAt: '2026-05-06T00:08:59.000Z',
recentEvents: [
{
id: 'old-same-fingerprint',
teamName: 'team-a',
memberName: 'alice',
kind: 'status_evaluated',
state: 'needs_sync',
agendaFingerprint: 'agenda:v1:native-stale',
recordedAt: '2026-05-06T00:00:00.000Z',
actionableCount: 1,
providerId: 'codex',
},
{
id: 'fingerprint-returned',
teamName: 'team-a',
memberName: 'alice',
kind: 'fingerprint_changed',
state: 'needs_sync',
agendaFingerprint: 'agenda:v1:native-stale',
previousFingerprint: 'agenda:v1:other',
recordedAt: '2026-05-06T00:03:00.000Z',
actionableCount: 1,
providerId: 'codex',
},
{
id: 'current-after-change',
teamName: 'team-a',
memberName: 'alice',
kind: 'status_evaluated',
state: 'needs_sync',
agendaFingerprint: 'agenda:v1:native-stale',
previousFingerprint: 'agenda:v1:other',
recordedAt: '2026-05-06T00:03:00.000Z',
actionableCount: 1,
providerId: 'codex',
},
],
}),
})
).toEqual({ active: false, reason: 'blocking_metrics' });
});
it('activates stale native in-progress recovery after a returned fingerprint is stable long enough', () => {
expect(
decideMemberWorkSyncNudgeActivation({
status: nativeStaleInProgressStatus(),
metrics: staleMetrics({
generatedAt: '2026-05-06T00:09:00.000Z',
recentEvents: [
{
id: 'old-same-fingerprint',
teamName: 'team-a',
memberName: 'alice',
kind: 'status_evaluated',
state: 'needs_sync',
agendaFingerprint: 'agenda:v1:native-stale',
recordedAt: '2026-05-06T00:00:00.000Z',
actionableCount: 1,
providerId: 'codex',
},
{
id: 'fingerprint-returned',
teamName: 'team-a',
memberName: 'alice',
kind: 'fingerprint_changed',
state: 'needs_sync',
agendaFingerprint: 'agenda:v1:native-stale',
previousFingerprint: 'agenda:v1:other',
recordedAt: '2026-05-06T00:03:00.000Z',
actionableCount: 1,
providerId: 'codex',
},
{
id: 'current-after-change',
teamName: 'team-a',
memberName: 'alice',
kind: 'status_evaluated',
state: 'needs_sync',
agendaFingerprint: 'agenda:v1:native-stale',
previousFingerprint: 'agenda:v1:other',
recordedAt: '2026-05-06T00:03:00.000Z',
actionableCount: 1,
providerId: 'codex',
},
],
}),
})
).toEqual({ active: true, reason: 'native_stale_in_progress' });
});
it('does not activate stale native in-progress recovery from another member stale event', () => {
expect(
decideMemberWorkSyncNudgeActivation({
status: nativeStaleInProgressStatus(),
metrics: staleMetrics({
recentEvents: [
{
id: 'other-member-status-stale',
teamName: 'team-a',
memberName: 'bob',
kind: 'status_evaluated',
state: 'needs_sync',
agendaFingerprint: 'agenda:v1:native-stale',
recordedAt: '2026-05-06T00:00:00.000Z',
actionableCount: 1,
providerId: 'codex',
},
],
}),
})
).toEqual({ active: false, reason: 'blocking_metrics' });
});
it('does not use native stale recovery for OpenCode or lead members', () => {
expect(
decideMemberWorkSyncNudgeActivation({
status: nativeStaleInProgressStatus({ providerId: 'opencode' }),
metrics: staleMetrics(),
})
).toEqual({ active: true, reason: 'opencode_targeted_shadow_collecting' });
expect(
decideMemberWorkSyncNudgeActivation({
status: nativeStaleInProgressStatus({ memberName: 'team-lead' }),
metrics: staleMetrics(),
})
).toEqual({ active: true, reason: 'lead_targeted_shadow_collecting' });
});
it('does not activate stale native in-progress recovery for multiple or non-in-progress work items', () => {
const baseItem = nativeStaleInProgressStatus().agenda.items[0]!;
expect(
decideMemberWorkSyncNudgeActivation({
status: nativeStaleInProgressStatus({
agenda: {
...nativeStaleInProgressStatus().agenda,
items: [
baseItem,
{
...baseItem,
taskId: 'task-2',
},
],
},
}),
metrics: staleMetrics(),
})
).toEqual({ active: false, reason: 'blocking_metrics' });
expect(
decideMemberWorkSyncNudgeActivation({
status: nativeStaleInProgressStatus({
agenda: {
...nativeStaleInProgressStatus().agenda,
items: [
{
...baseItem,
reason: 'owned_pending_task',
evidence: {
status: 'pending',
owner: 'alice',
},
},
],
},
}),
metrics: staleMetrics(),
})
).toEqual({ active: false, reason: 'blocking_metrics' });
});
it('does not activate stale native in-progress recovery for needsFix, review, blocked dependency, or clarification agenda items', () => {
const baseItem = nativeStaleInProgressStatus().agenda.items[0]!;
const cases = [
{
...baseItem,
evidence: {
status: 'needsFix',
owner: 'alice',
},
},
{
...baseItem,
kind: 'review' as const,
priority: 'review_requested' as const,
reason: 'current_cycle_review_assigned',
evidence: {
status: 'completed',
owner: 'bob',
reviewer: 'alice',
reviewState: 'review',
reviewCycleId: 'evt-review-request',
reviewRequestEventId: 'evt-review-request',
reviewObligation: 'review_pickup_required' as const,
canBypassPhase2: true,
historyEventIds: ['evt-review-request'],
},
},
{
...baseItem,
kind: 'blocked_dependency' as const,
priority: 'blocked' as const,
reason: 'blocked_by_incomplete_task',
evidence: {
status: 'in_progress',
owner: 'alice',
blockerTaskIds: ['task-blocker'],
},
},
{
...baseItem,
kind: 'clarification' as const,
priority: 'needs_clarification' as const,
reason: 'lead_clarification_required',
evidence: {
status: 'in_progress',
owner: 'alice',
needsClarification: 'lead' as const,
},
},
];
for (const item of cases) {
expect(
decideMemberWorkSyncNudgeActivation({
status: nativeStaleInProgressStatus({
agenda: {
...nativeStaleInProgressStatus().agenda,
items: [item],
},
}),
metrics: staleMetrics(),
})
).toEqual({ active: false, reason: 'blocking_metrics' });
}
});
it('keeps existing shadow_ready behavior for all providers', () => {
expect(
decideMemberWorkSyncNudgeActivation({

View file

@ -179,6 +179,69 @@ async function seedBlockingShadowCollectingMetrics(input: {
);
}
async function seedNativeStaleInProgressBlockingMetrics(input: {
teamsBasePath: string;
teamName: string;
memberName: string;
agendaFingerprint: string;
}): Promise<void> {
const nowMs = Date.now();
const staleObservedAt = new Date(nowMs - 6 * 60_000 - 1_000).toISOString();
const metricsPath = path.join(
input.teamsBasePath,
input.teamName,
'.member-work-sync',
'indexes',
'metrics.json'
);
await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true });
await fs.promises.writeFile(
metricsPath,
`${JSON.stringify(
{
schemaVersion: 2,
members: {
[input.memberName]: {
memberName: input.memberName,
state: 'needs_sync',
agendaFingerprint: input.agendaFingerprint,
actionableCount: 1,
evaluatedAt: staleObservedAt,
providerId: 'codex',
},
},
recentEvents: [
{
id: 'native-stale-status',
teamName: input.teamName,
memberName: input.memberName,
kind: 'status_evaluated',
state: 'needs_sync',
agendaFingerprint: input.agendaFingerprint,
recordedAt: staleObservedAt,
actionableCount: 1,
providerId: 'codex',
},
...Array.from({ length: 12 }, (_, index) => ({
id: `native-stale-would-nudge-${index}`,
teamName: input.teamName,
memberName: input.memberName,
kind: 'would_nudge',
state: 'needs_sync',
agendaFingerprint: input.agendaFingerprint,
recordedAt: new Date(nowMs - 5 * 60_000 + index * 5_000).toISOString(),
actionableCount: 1,
providerId: 'codex',
})),
],
},
null,
2
)}\n`,
'utf8'
);
}
async function waitForAssertion(assertion: () => Promise<void> | void): Promise<void> {
const deadline = Date.now() + 5_000;
let lastError: unknown;
@ -1067,6 +1130,130 @@ describe('createMemberWorkSyncFeature composition', () => {
}
});
it('delivers native stale in-progress recovery nudges despite noisy global metrics', async () => {
const claudeRoot = makeTempRoot();
setClaudeBasePathOverride(claudeRoot);
const teamsBasePath = getTeamsBasePath();
const teamName = 'team-native-stale-in-progress';
const memberName = 'alice';
const nudgeDeliveryWake = {
schedule: vi.fn(async () => undefined),
};
const feature = createMemberWorkSyncFeature({
teamsBasePath,
configReader: {
getConfig: vi.fn(async () => ({
name: teamName,
members: [{ name: memberName, providerId: 'codex' }],
})),
} as never,
taskReader: {
getTasks: vi.fn(async () => [
{
id: 'task-1',
displayId: '11111111',
subject: 'Review landing',
status: 'in_progress',
owner: memberName,
},
]),
} as never,
kanbanManager: {
getState: vi.fn(async () => ({
teamName,
reviewers: [],
tasks: {},
})),
} as never,
membersMetaStore: {
getMembers: vi.fn(async () => []),
} as never,
isTeamActive: vi.fn(async () => true),
nudgeDeliveryWake,
queueQuietWindowMs: 1,
});
try {
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
let agendaFingerprint = '';
await waitForAssertion(async () => {
const status = await feature.getStatus({ teamName, memberName });
expect(status).toMatchObject({
state: 'needs_sync',
providerId: 'codex',
diagnostics: expect.arrayContaining(['no_current_report']),
agenda: {
items: [
expect.objectContaining({
reason: 'owned_in_progress_task',
evidence: expect.objectContaining({ status: 'in_progress' }),
}),
],
},
});
agendaFingerprint = status.agenda.fingerprint;
});
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]);
expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled();
await seedNativeStaleInProgressBlockingMetrics({
teamsBasePath,
teamName,
memberName,
agendaFingerprint,
});
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
await waitForAssertion(async () => {
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
(message) => message.messageKind === 'member_work_sync_nudge'
);
expect(nudges).toHaveLength(1);
expect(nudges[0]?.text).toContain('Work sync check');
expect(nudges[0]?.text).toContain('11111111');
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1);
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({
teamName,
memberName,
messageId: nudges[0]?.messageId,
providerId: 'codex',
reason: 'member_work_sync_nudge_inserted',
delayMs: 500,
});
await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({
phase2Readiness: {
reasons: expect.arrayContaining(['would_nudge_rate_high']),
},
});
expect(
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
).toEqual([
expect.objectContaining({
status: 'delivered',
deliveredMessageId: nudges[0]?.messageId,
}),
]);
});
const journal = await fs.promises.readFile(
path.join(
teamsBasePath,
teamName,
'members',
memberName,
'.member-work-sync',
'journal.jsonl'
),
'utf8'
);
expect(journal).toContain('"event":"nudge_delivered"');
expect(journal).toContain('"reason":"created"');
} finally {
await feature.dispose();
}
});
it('delivers targeted OpenCode nudges even when global phase2 metrics are noisy', async () => {
const claudeRoot = makeTempRoot();
setClaudeBasePathOverride(claudeRoot);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: [],

View file

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

View file

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

View file

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

View file

@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import { buildProjectPathOptions } from '@renderer/components/team/dialogs/projectPathOptions';
import {
buildProjectPathOptions,
isDeletedProjectPathSelection,
isSelectableProjectPathProject,
} from '@renderer/components/team/dialogs/projectPathOptions';
import type { Project } from '@shared/types';
@ -88,4 +92,58 @@ 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',
},
},
]);
});
it('does not treat deleted project paths as selectable launch targets', () => {
const deletedProject = createProject({
id: 'project-deleted',
name: 'my-tes',
path: '/Users/belief/dev/projects/my-tes',
filesystemState: 'deleted',
});
expect(isSelectableProjectPathProject(deletedProject)).toBe(false);
expect(
isDeletedProjectPathSelection([deletedProject], '/Users/belief/dev/projects/my-tes')
).toBe(true);
});
it('keeps available project paths selectable', () => {
const availableProject = createProject({
id: 'project-available',
name: 'claude_team',
path: '/Users/belief/dev/projects/claude/claude_team',
filesystemState: 'available',
});
expect(isSelectableProjectPathProject(availableProject)).toBe(true);
expect(
isDeletedProjectPathSelection(
[availableProject],
'/Users/belief/dev/projects/claude/claude_team'
)
).toBe(false);
});
});

View file

@ -295,6 +295,16 @@ describe('editorSlice', () => {
expect(state.editorActiveTabId).toBe('/project/src/index.ts');
});
it('deduplicates Windows tabs across drive case and separator differences', () => {
store.getState().openFile('C:\\Repo\\src\\index.ts');
store.getState().openFile('c:/repo/src/index.ts');
const state = store.getState();
expect(state.editorOpenTabs).toHaveLength(1);
expect(state.editorOpenTabs[0].filePath).toBe('C:\\Repo\\src\\index.ts');
expect(state.editorActiveTabId).toBe('C:\\Repo\\src\\index.ts');
});
it('detects language from file extension', () => {
store.getState().openFile('/project/data.json');
@ -531,6 +541,31 @@ describe('editorSlice', () => {
});
});
describe('handleExternalFileChange', () => {
it('keys Windows watcher changes to the open tab path across separator differences', () => {
vi.useFakeTimers();
try {
store.getState().openFile('C:\\Repo\\src\\index.ts');
store.getState().handleExternalFileChange({
type: 'change',
path: 'c:/repo/src/index.ts',
});
expect(store.getState().editorExternalChanges).toEqual({
'C:\\Repo\\src\\index.ts': 'change',
});
store.getState().clearExternalChange('c:/repo/src/index.ts');
expect(store.getState().editorExternalChanges).toEqual({});
} finally {
vi.runOnlyPendingTimers();
vi.useRealTimers();
}
});
});
describe('closeEditor resets all state including Group 2+3', () => {
it('resets tabs, dirty, saving, errors', () => {
store.setState({

View file

@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import { getRelativePathWithinPrefix, isPathPrefix } from '../../../src/shared/utils/platformPath';
import {
getRelativePathWithinPrefix,
isAbsoluteOrHomePath,
isPathPrefix,
} from '../../../src/shared/utils/platformPath';
describe('platformPath Windows containment', () => {
it('matches Windows drive paths case-insensitively and preserves child path style', () => {
@ -35,4 +39,14 @@ describe('platformPath Windows containment', () => {
expect(isPathPrefix('', '/Users/Alice/Repo/src/app.ts')).toBe(false);
expect(getRelativePathWithinPrefix('', '/Users/Alice/Repo/src/app.ts')).toBe(null);
});
it('detects absolute and home-relative paths across platforms', () => {
expect(isAbsoluteOrHomePath('/Users/Alice/Repo')).toBe(true);
expect(isAbsoluteOrHomePath('C:\\Users\\Alice\\Repo')).toBe(true);
expect(isAbsoluteOrHomePath('\\\\server\\share\\Repo')).toBe(true);
expect(isAbsoluteOrHomePath('~')).toBe(true);
expect(isAbsoluteOrHomePath('~/Repo')).toBe(true);
expect(isAbsoluteOrHomePath('~\\Repo')).toBe(true);
expect(isAbsoluteOrHomePath('src/app.ts')).toBe(false);
});
});