From d29f3a23d416ddb3d81d199ddf3a022e6024c88d Mon Sep 17 00:00:00 2001 From: infiniti <52129260+developerInfiniti@users.noreply.github.com> Date: Sat, 16 May 2026 19:57:11 +0300 Subject: [PATCH] fix: harden Windows frontend stability (#125) --- README.md | 2 +- package.json | 1 + pnpm-lock.yaml | 31 ++ .../renderer/hooks/useGraphMessagesPanel.tsx | 86 +++++ .../renderer/ui/TeamGraphOverlay.tsx | 22 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 21 +- .../MemberWorkSyncNudgeActivationPolicy.ts | 137 ++++++- src/features/recent-projects/contracts/dto.ts | 3 + .../domain/models/RecentProjectAggregate.ts | 2 + .../domain/models/RecentProjectCandidate.ts | 2 + .../models/RecentProjectFilesystemState.ts | 1 + .../policies/mergeRecentProjectCandidates.ts | 9 +- .../DashboardRecentProjectsPresenter.ts | 1 + .../ClaudeRecentProjectsSourceAdapter.ts | 1 + .../CodexRecentProjectsSourceAdapter.ts | 11 +- ...xSessionFileRecentProjectsSourceAdapter.ts | 7 +- .../resolveProjectFilesystemState.ts | 21 + .../adapters/RecentProjectsSectionAdapter.ts | 2 + .../renderer/hooks/useOpenRecentProject.ts | 5 + .../renderer/ui/RecentProjectCard.tsx | 44 ++- src/main/services/discovery/ProjectScanner.ts | 25 ++ .../infrastructure/CliInstallerService.ts | 2 +- .../runtime/ClaudeMultimodelBridgeService.ts | 2 +- src/main/types/domain.ts | 6 + .../components/dashboard/CliStatusBanner.tsx | 2 +- .../components/layout/TeamTabSectionNav.tsx | 2 +- .../settings/sections/CliStatusSection.tsx | 2 +- .../components/team/TeamDetailView.tsx | 17 +- .../team/dialogs/CreateTeamDialog.tsx | 29 +- .../team/dialogs/LaunchTeamDialog.tsx | 60 ++- .../team/dialogs/ProjectPathSelector.tsx | 62 ++- .../team/dialogs/projectPathOptions.ts | 47 ++- .../team/dialogs/projectPathProjects.ts | 19 +- .../components/team/editor/EditorFileTree.tsx | 16 +- .../team/messages/MessageComposer.tsx | 55 +-- .../team/messages/MessagesPanel.tsx | 353 ++++++++++------- src/renderer/components/ui/combobox.tsx | 11 +- src/renderer/components/ui/dropdown-menu.tsx | 65 ++++ .../store/slices/cliInstallerSlice.ts | 4 +- src/renderer/store/slices/editorSlice.ts | 41 +- src/renderer/store/utils/pathResolution.ts | 16 +- src/renderer/types/teamMessagesPanelMode.ts | 2 +- src/renderer/utils/claudeMdTracker.ts | 25 +- src/renderer/utils/contextTracker.ts | 30 +- src/shared/utils/platformPath.ts | 12 + ...emberWorkSyncNudgeActivationPolicy.test.ts | 363 ++++++++++++++++++ .../main/createMemberWorkSyncFeature.test.ts | 187 +++++++++ .../mergeRecentProjectCandidates.test.ts | 36 ++ ...ionFileRecentProjectsSourceAdapter.test.ts | 36 ++ .../RecentProjectsSectionAdapter.test.ts | 5 +- .../discovery/ProjectScanner.cwdSplit.test.ts | 22 ++ .../CliInstallerService.test.ts | 2 +- .../ClaudeMultimodelBridgeService.test.ts | 2 +- .../cli/CliStatusVisibility.test.ts | 8 +- .../extensions/skills/SkillsPanel.test.ts | 2 +- .../team/dialogs/LaunchTeamDialog.test.ts | 6 + .../team/dialogs/projectPathOptions.test.ts | 60 ++- test/renderer/store/editorSlice.test.ts | 35 ++ test/shared/utils/platformPath.test.ts | 16 +- 59 files changed, 1787 insertions(+), 307 deletions(-) create mode 100644 src/features/agent-graph/renderer/hooks/useGraphMessagesPanel.tsx create mode 100644 src/features/recent-projects/core/domain/models/RecentProjectFilesystemState.ts create mode 100644 src/features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState.ts create mode 100644 src/renderer/components/ui/dropdown-menu.tsx diff --git a/README.md b/README.md index c32e26f8..678494d5 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@

- 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. + 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.

image diff --git a/package.json b/package.json index 7eb4d707..76167bab 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bb07233..d8b7e807 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/features/agent-graph/renderer/hooks/useGraphMessagesPanel.tsx b/src/features/agent-graph/renderer/hooks/useGraphMessagesPanel.tsx new file mode 100644 index 00000000..70ee7043 --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphMessagesPanel.tsx @@ -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) => Record) => { + setPendingRepliesByMember(updater); + }, + [] + ); + + if ( + !enabled || + (messagesPanelMode !== 'floating-composer' && messagesPanelMode !== 'bottom-sheet') + ) { + return <>; + } + + return ( + onOpenMemberProfile(member.name)} + onTaskClick={(task) => onOpenTaskDetail(task.id)} + onTaskIdClick={onOpenTaskDetail} + /> + ); +} diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index aebbfd07..40693276 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -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( + 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 ? ( +
+ ) : null} + {graphMessagesPanel} {createTaskDialog}
); diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index a0ab67f2..29356208 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -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( + 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 ( -
+
{sidebarVisible ? (
+ {isActive && isPaneFocused && !fullscreen ? ( +
+ ) : null} + {graphMessagesPanel} {createTaskDialog} {fullscreen && ( @@ -273,6 +291,7 @@ export const TeamGraphTab = ({ onSendMessage={dispatchSendMessage} onOpenTaskDetail={dispatchOpenTask} onOpenMemberProfile={dispatchOpenProfile} + messagesPanelEnabled={isActive && isPaneFocused} /> )} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts index 49439b5c..24d5b21b 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts @@ -7,16 +7,24 @@ import { type MemberWorkSyncTargetedRecoveryReason, } from './MemberWorkSyncTargetedRecoveryPolicy'; -import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../contracts'; +import type { + MemberWorkSyncMetricEvent, + MemberWorkSyncStatus, + MemberWorkSyncTeamMetrics, +} from '../../contracts'; export type MemberWorkSyncNudgeActivationReason = | 'shadow_ready' | MemberWorkSyncTargetedRecoveryReason | 'review_pickup_required' + | 'native_stale_in_progress' | 'status_not_nudgeable' | 'blocking_metrics' | 'phase2_not_ready'; +const NATIVE_STALE_IN_PROGRESS_MIN_AGE_MS = 6 * 60_000; +const NATIVE_STALE_IN_PROGRESS_PROVIDERS = new Set(['anthropic', 'codex', 'gemini']); + export interface MemberWorkSyncNudgeActivationDecision { active: boolean; reason: MemberWorkSyncNudgeActivationReason; @@ -32,6 +40,129 @@ function hasBlockingMetrics(metrics: MemberWorkSyncTeamMetrics): boolean { return metrics.phase2Readiness.reasons.some((reason) => BLOCKING_PHASE2_REASONS.has(reason)); } +function normalizeMemberName(value: string): string { + return value.trim().toLowerCase(); +} + +function isLeadLikeMemberName(memberName: string): boolean { + const normalized = normalizeMemberName(memberName).replace(/[\s_]+/g, '-'); + return ( + normalized === 'lead' || + normalized === 'team-lead' || + normalized === 'teamlead' || + normalized === 'team-leader' + ); +} + +function parseTime(value: string | undefined): number | null { + if (!value) { + return null; + } + const time = Date.parse(value); + return Number.isFinite(time) ? time : null; +} + +function eventsForMember( + status: MemberWorkSyncStatus, + metrics: MemberWorkSyncTeamMetrics +): MemberWorkSyncMetricEvent[] { + const memberName = normalizeMemberName(status.memberName); + return metrics.recentEvents + .filter((event) => normalizeMemberName(event.memberName) === memberName) + .sort((left, right) => left.recordedAt.localeCompare(right.recordedAt)); +} + +function hasAcceptedReportForCurrentFingerprint( + status: MemberWorkSyncStatus, + metrics: MemberWorkSyncTeamMetrics +): boolean { + return eventsForMember(status, metrics).some( + (event) => + event.kind === 'report_accepted' && event.agendaFingerprint === status.agenda.fingerprint + ); +} + +function isDifferentFingerprintBoundary( + event: MemberWorkSyncMetricEvent, + currentFingerprint: string +): boolean { + if (event.agendaFingerprint !== currentFingerprint) { + return true; + } + return ( + event.kind === 'fingerprint_changed' && + event.previousFingerprint !== undefined && + event.previousFingerprint !== currentFingerprint + ); +} + +function getCurrentFingerprintStableSinceMs( + status: MemberWorkSyncStatus, + metrics: MemberWorkSyncTeamMetrics, + nowMs: number +): number | null { + const currentFingerprint = status.agenda.fingerprint; + const memberEvents = eventsForMember(status, metrics).filter((event) => { + const recordedAt = parseTime(event.recordedAt); + return recordedAt != null && recordedAt <= nowMs; + }); + let latestDifferentFingerprintMs = Number.NEGATIVE_INFINITY; + for (const event of memberEvents) { + const recordedAt = parseTime(event.recordedAt); + if (recordedAt != null && isDifferentFingerprintBoundary(event, currentFingerprint)) { + latestDifferentFingerprintMs = Math.max(latestDifferentFingerprintMs, recordedAt); + } + } + + const currentNeedsSyncEventTimes = memberEvents.flatMap((event) => { + const recordedAt = parseTime(event.recordedAt); + return event.kind === 'status_evaluated' && + event.state === 'needs_sync' && + event.agendaFingerprint === currentFingerprint && + recordedAt != null && + recordedAt >= latestDifferentFingerprintMs + ? [recordedAt] + : []; + }); + + return currentNeedsSyncEventTimes.length > 0 ? Math.min(...currentNeedsSyncEventTimes) : null; +} + +function isNativeStaleInProgressCandidate(input: { + status: MemberWorkSyncStatus; + metrics: MemberWorkSyncTeamMetrics; +}): boolean { + const { status, metrics } = input; + if ( + status.state !== 'needs_sync' || + status.shadow?.wouldNudge !== true || + !status.diagnostics.includes('no_current_report') || + !status.providerId || + !NATIVE_STALE_IN_PROGRESS_PROVIDERS.has(status.providerId) || + isLeadLikeMemberName(status.memberName) || + status.agenda.items.length !== 1 || + hasAcceptedReportForCurrentFingerprint(status, metrics) + ) { + return false; + } + + const [item] = status.agenda.items; + if ( + item.kind !== 'work' || + item.reason !== 'owned_in_progress_task' || + item.evidence.status !== 'in_progress' + ) { + return false; + } + + const nowMs = parseTime(metrics.generatedAt) ?? parseTime(status.evaluatedAt); + if (nowMs == null) { + return false; + } + const stableSinceMs = getCurrentFingerprintStableSinceMs(status, metrics, nowMs); + return stableSinceMs != null && nowMs - stableSinceMs >= NATIVE_STALE_IN_PROGRESS_MIN_AGE_MS; +} + function isReviewPickupRequiredCandidate(status: MemberWorkSyncStatus): boolean { return ( status.state === 'needs_sync' && @@ -61,6 +192,10 @@ export function decideMemberWorkSyncNudgeActivation(input: { return { active: true, reason: targetedRecovery.reason }; } + if (isNativeStaleInProgressCandidate(input)) { + return { active: true, reason: 'native_stale_in_progress' }; + } + if (hasBlockingMetrics(input.metrics)) { return { active: false, reason: 'blocking_metrics' }; } diff --git a/src/features/recent-projects/contracts/dto.ts b/src/features/recent-projects/contracts/dto.ts index bdb4eda0..1fa6e5c6 100644 --- a/src/features/recent-projects/contracts/dto.ts +++ b/src/features/recent-projects/contracts/dto.ts @@ -2,6 +2,8 @@ export type DashboardProviderId = 'anthropic' | 'codex' | 'gemini'; export type DashboardRecentProjectSource = 'claude' | 'codex' | 'mixed'; +export type DashboardRecentProjectFilesystemState = 'available' | 'deleted'; + export type DashboardRecentProjectOpenTarget = | { type: 'existing-worktree'; repositoryId: string; worktreeId: string } | { type: 'synthetic-path'; path: string }; @@ -16,6 +18,7 @@ export interface DashboardRecentProject { source: DashboardRecentProjectSource; openTarget: DashboardRecentProjectOpenTarget; primaryBranch?: string; + filesystemState?: DashboardRecentProjectFilesystemState; } export interface DashboardRecentProjectsPayload { diff --git a/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts b/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts index 9c098a65..26402f5d 100644 --- a/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts +++ b/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts @@ -1,4 +1,5 @@ import type { ProviderId } from './ProviderId'; +import type { RecentProjectFilesystemState } from './RecentProjectFilesystemState'; import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget'; export interface RecentProjectAggregate { @@ -11,4 +12,5 @@ export interface RecentProjectAggregate { source: 'claude' | 'codex' | 'mixed'; openTarget: RecentProjectOpenTarget; branchName?: string; + filesystemState: RecentProjectFilesystemState; } diff --git a/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts b/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts index 2abc5315..17c389d2 100644 --- a/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts +++ b/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts @@ -1,4 +1,5 @@ import type { ProviderId } from './ProviderId'; +import type { RecentProjectFilesystemState } from './RecentProjectFilesystemState'; import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget'; export interface RecentProjectCandidate { @@ -11,4 +12,5 @@ export interface RecentProjectCandidate { sourceKind: 'claude' | 'codex'; openTarget: RecentProjectOpenTarget; branchName?: string; + filesystemState?: RecentProjectFilesystemState; } diff --git a/src/features/recent-projects/core/domain/models/RecentProjectFilesystemState.ts b/src/features/recent-projects/core/domain/models/RecentProjectFilesystemState.ts new file mode 100644 index 00000000..22a3029a --- /dev/null +++ b/src/features/recent-projects/core/domain/models/RecentProjectFilesystemState.ts @@ -0,0 +1 @@ +export type RecentProjectFilesystemState = 'available' | 'deleted'; diff --git a/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts b/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts index ce3be598..41efa95b 100644 --- a/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts +++ b/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts @@ -25,10 +25,14 @@ function uniqueProviders(providerIds: readonly ProviderId[]): ProviderId[] { function selectPreferredCandidate( candidates: readonly RecentProjectCandidate[] ): RecentProjectCandidate { - const existingWorktreeCandidates = candidates.filter( + const availableCandidates = candidates.filter( + (candidate) => candidate.filesystemState !== 'deleted' + ); + const candidatePool = availableCandidates.length > 0 ? availableCandidates : candidates; + const existingWorktreeCandidates = candidatePool.filter( (candidate) => candidate.openTarget.type === 'existing-worktree' ); - const pool = existingWorktreeCandidates.length > 0 ? existingWorktreeCandidates : candidates; + const pool = existingWorktreeCandidates.length > 0 ? existingWorktreeCandidates : candidatePool; return [...pool].sort((left, right) => { if (right.lastActivityAt !== left.lastActivityAt) { @@ -81,6 +85,7 @@ export function mergeRecentProjectCandidates( source: sourceKinds.size > 1 ? 'mixed' : sourceKinds.has('codex') ? 'codex' : 'claude', openTarget: preferred.openTarget, branchName: mergeBranchName(group), + filesystemState: preferred.filesystemState ?? 'available', }; }); diff --git a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts index c9a3e5fb..887ae7c8 100644 --- a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts +++ b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts @@ -20,6 +20,7 @@ export class DashboardRecentProjectsPresenter implements ListDashboardRecentProj source: aggregate.source, openTarget: aggregate.openTarget, primaryBranch: aggregate.branchName, + filesystemState: aggregate.filesystemState, }) ), }; diff --git a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts index d1d55a94..faefffb6 100644 --- a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts @@ -44,6 +44,7 @@ function toCandidate(repo: RepositoryGroup): RecentProjectCandidate | null { worktreeId: preferredWorktree.id, }, branchName: preferredWorktree.gitBranch, + filesystemState: preferredWorktree.filesystemState ?? 'available', }; } diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index 75558354..d8311372 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -1,3 +1,4 @@ +import { resolveProjectFilesystemState } from '@features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState'; import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath'; import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath'; import path from 'path'; @@ -129,7 +130,9 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor ); const candidates = ( - await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread))) + await Promise.all( + interactiveThreads.map((thread) => this.#toCandidate(thread, activeContext.fsProvider)) + ) ).filter((candidate): candidate is RecentProjectCandidate => candidate !== null); if (!degraded) { @@ -299,7 +302,10 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor } } - async #toCandidate(thread: CodexThreadSummary): Promise { + async #toCandidate( + thread: CodexThreadSummary, + fsProvider?: ServiceContext['fsProvider'] + ): Promise { const cwd = thread.cwd?.trim(); if (!cwd || isEphemeralProjectPath(cwd)) { return null; @@ -321,6 +327,7 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor path: cwd, }, branchName: thread.gitInfo?.branch ?? undefined, + filesystemState: await resolveProjectFilesystemState(cwd, fsProvider), }; } } diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts index cec73762..1ee20721 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts @@ -2,6 +2,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { resolveProjectFilesystemState } from '@features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState'; import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath'; import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath'; @@ -225,7 +226,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec try { const snapshots = await this.#listRecentSessionSnapshots(); const candidates = await Promise.all( - snapshots.map((snapshot) => this.#toCandidate(snapshot)) + snapshots.map((snapshot) => this.#toCandidate(snapshot, activeContext.fsProvider)) ); const validCandidates = candidates.filter( @@ -303,7 +304,8 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec } async #toCandidate( - snapshot: CodexSessionProjectSnapshot + snapshot: CodexSessionProjectSnapshot, + fsProvider?: ServiceContext['fsProvider'] ): Promise { const identity = await this.deps.identityResolver.resolve(snapshot.cwd); const displayName = identity?.name ?? path.basename(snapshot.cwd) ?? snapshot.cwd; @@ -321,6 +323,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec path: snapshot.cwd, }, branchName: snapshot.branchName, + filesystemState: await resolveProjectFilesystemState(snapshot.cwd, fsProvider), }; } } diff --git a/src/features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState.ts b/src/features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState.ts new file mode 100644 index 00000000..5f1a1d7f --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState.ts @@ -0,0 +1,21 @@ +import type { RecentProjectFilesystemState } from '../../../core/domain/models/RecentProjectFilesystemState'; +import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider'; + +export async function resolveProjectFilesystemState( + projectPath: string, + fsProvider?: Pick +): Promise { + if (!projectPath.trim()) { + return 'deleted'; + } + + if (!fsProvider) { + return 'available'; + } + + try { + return (await fsProvider.exists(projectPath)) ? 'available' : 'deleted'; + } catch { + return 'deleted'; + } +} diff --git a/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts b/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts index 58b0cd79..7568b4aa 100644 --- a/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts +++ b/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts @@ -15,6 +15,7 @@ export interface RecentProjectCardModel { lastActivityLabel: string; providerIds: DashboardRecentProject['providerIds']; primaryBranch?: string; + filesystemState?: DashboardRecentProject['filesystemState']; taskCounts?: TaskStatusCounts; tasksLoading: boolean; activeTeams?: TeamSummary[]; @@ -121,6 +122,7 @@ export function adaptRecentProjectsSection({ }), providerIds: sortDashboardProviderIds(project.providerIds), primaryBranch: project.primaryBranch, + filesystemState: project.filesystemState, taskCounts: sumTaskCounts(project, taskCountsByProject), tasksLoading, activeTeams: collectActiveTeams(project, activeTeamsByProject), diff --git a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts index fb7fd848..f01387f8 100644 --- a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts +++ b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts @@ -106,6 +106,11 @@ export function useOpenRecentProject(): { const openRecentProject = useCallback( async (project: DashboardRecentProject): Promise => { + if (project.filesystemState === 'deleted') { + logger.warn('Skipped deleted recent project path', { path: project.primaryPath }); + return; + } + try { await openTarget(project.openTarget, project.associatedPaths); recordRecentProjectOpenPaths([project.primaryPath, ...project.associatedPaths]); diff --git a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx index ad187f70..cdc03227 100644 --- a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx +++ b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx @@ -3,8 +3,9 @@ import { useMemo } from 'react'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { cn } from '@renderer/lib/utils'; import { projectColor } from '@renderer/utils/projectColor'; -import { FolderGit2, FolderOpen, GitBranch, Terminal } from 'lucide-react'; +import { FolderGit2, FolderOpen, FolderX, GitBranch, Terminal } from 'lucide-react'; import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter'; @@ -20,11 +21,17 @@ export const RecentProjectCard = ({ onOpenPath, }: Readonly): React.JSX.Element => { const color = useMemo(() => projectColor(card.name), [card.name]); + const isDeleted = card.filesystemState === 'deleted'; + const FolderIcon = isDeleted ? FolderX : FolderGit2; return (
@@ -3650,6 +3659,12 @@ export const TeamDetailView = memo(function TeamDetailView({ .getState() .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); }} + messagesPanelEnabled={ + (messagesPanelMode === 'floating-composer' || + messagesPanelMode === 'bottom-sheet') && + isThisTabActive && + isPaneFocused + } onSendMessage={(memberName) => { setSendDialogRecipient(memberName); setSendDialogDefaultText(undefined); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 3a22a60d..cfab2cd9 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -95,6 +95,10 @@ import { resolveProviderScopedMemberModel, } from './memberModelScope'; import { OptionalSettingsSection } from './OptionalSettingsSection'; +import { + isDeletedProjectPathSelection, + isSelectableProjectPathProject, +} from './projectPathOptions'; import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects'; import { ProjectPathSelector } from './ProjectPathSelector'; import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; @@ -574,9 +578,17 @@ export const CreateTeamDialog = ({ [members] ); - const selectedProjectCwd = isEphemeralProjectPath(selectedProjectPath) - ? '' - : selectedProjectPath.trim(); + const selectedProjectPathDeleted = useMemo( + () => + cwdMode === 'project' && + selectedProjectPath.length > 0 && + isDeletedProjectPathSelection(projects, selectedProjectPath), + [cwdMode, projects, selectedProjectPath] + ); + const selectedProjectCwd = + isEphemeralProjectPath(selectedProjectPath) || selectedProjectPathDeleted + ? '' + : selectedProjectPath.trim(); const effectiveCwd = cwdMode === 'project' ? selectedProjectCwd : customCwd.trim(); const dialogTeamNameKey = sanitizeTeamName(teamName.trim()); /** All taken names: existing teams + teams currently being provisioned. */ @@ -1214,7 +1226,7 @@ export const CreateTeamDialog = ({ if (cwdMode !== 'project') { return; } - const selectableProjects = projects.filter((project) => !isEphemeralProjectPath(project.path)); + const selectableProjects = projects.filter(isSelectableProjectPathProject); if (selectableProjects.length === 0) { return; } @@ -1247,17 +1259,20 @@ export const CreateTeamDialog = ({ } } setSelectedProjectPath(selectableProjects[0].path); - }, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]); + }, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath, setSelectedProjectPath]); useEffect(() => { if (!open || cwdMode !== 'project' || !selectedProjectPath) { return; } - if (!isEphemeralProjectPath(selectedProjectPath)) { + if ( + !isEphemeralProjectPath(selectedProjectPath) && + !isDeletedProjectPathSelection(projects, selectedProjectPath) + ) { return; } setSelectedProjectPath(''); - }, [open, cwdMode, selectedProjectPath, setSelectedProjectPath]); + }, [open, cwdMode, projects, selectedProjectPath, setSelectedProjectPath]); useFileListCacheWarmer(effectiveCwd || null); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 838a497e..7dde457b 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -79,6 +79,7 @@ import { CheckCircle2, ChevronDown, ChevronRight, + ExternalLink, Info, Loader2, X, @@ -98,6 +99,10 @@ import { resolveProviderScopedMemberModel, } from './memberModelScope'; import { OptionalSettingsSection } from './OptionalSettingsSection'; +import { + isDeletedProjectPathSelection, + isSelectableProjectPathProject, +} from './projectPathOptions'; import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects'; import { ProjectPathSelector } from './ProjectPathSelector'; import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; @@ -230,6 +235,8 @@ export type LaunchTeamDialogProps = | LaunchDialogScheduleMode; const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate'; +const ANTHROPIC_AGENT_SDK_CREDIT_ARTICLE_URL = + 'https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan'; // ============================================================================= // Helpers @@ -1363,9 +1370,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // Launch-only effects // --------------------------------------------------------------------------- - const selectedProjectCwd = isEphemeralProjectPath(selectedProjectPath) - ? '' - : selectedProjectPath.trim(); + const selectedProjectPathDeleted = useMemo( + () => + cwdMode === 'project' && + selectedProjectPath.length > 0 && + isDeletedProjectPathSelection(projects, selectedProjectPath), + [cwdMode, projects, selectedProjectPath] + ); + const selectedProjectCwd = + isEphemeralProjectPath(selectedProjectPath) || selectedProjectPathDeleted + ? '' + : selectedProjectPath.trim(); const effectiveCwd = cwdMode === 'project' ? selectedProjectCwd : customCwd.trim(); const hasSelectedWorktreeIsolation = isLaunchMode && @@ -1697,7 +1712,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return; } if (cwdMode !== 'project') return; - const selectableProjects = projects.filter((project) => !isEphemeralProjectPath(project.path)); + const selectableProjects = projects.filter(isSelectableProjectPathProject); if (selectableProjects.length === 0) return; if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) { const normalizedDefaultProjectPath = normalizePath(defaultProjectPath); @@ -1726,17 +1741,20 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } } setSelectedProjectPath(selectableProjects[0].path); - }, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]); + }, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath, setSelectedProjectPath]); useEffect(() => { if (!open || cwdMode !== 'project' || !selectedProjectPath) { return; } - if (!isEphemeralProjectPath(selectedProjectPath)) { + if ( + !isEphemeralProjectPath(selectedProjectPath) && + !isDeletedProjectPathSelection(projects, selectedProjectPath) + ) { return; } setSelectedProjectPath(''); - }, [open, cwdMode, selectedProjectPath, setSelectedProjectPath]); + }, [open, cwdMode, projects, selectedProjectPath, setSelectedProjectPath]); // Pre-warm file list cache so @-mention file search is instant useFileListCacheWarmer(effectiveCwd || null); @@ -1874,7 +1892,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const validationErrors = useMemo(() => { const errors: string[] = []; - if (!effectiveCwd) errors.push('Working directory is required'); + if (selectedProjectPathDeleted) { + errors.push('Project folder no longer exists'); + } else if (!effectiveCwd) { + errors.push('Working directory is required'); + } if (worktreeGitBlockingMessage) errors.push(worktreeGitBlockingMessage); if (isSchedule) { if (!effectiveTeamName) errors.push('Team is required'); @@ -1884,6 +1906,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return errors; }, [ effectiveCwd, + selectedProjectPathDeleted, worktreeGitBlockingMessage, isSchedule, effectiveTeamName, @@ -2730,6 +2753,27 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen This prompt will be passed to claude -p for one-shot execution

+ {selectedProviderId === 'anthropic' ? ( +
+ +

+ Starting June 15, 2026, Anthropic bills claude -p and Agent SDK + usage from the monthly Agent SDK credit, separate from interactive Claude Code + limits. The credit resets each billing cycle and unused credit does not roll + over.{' '} + + Read Anthropic article + + + . +

+
+ ) : null}
diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx index bb49c9ca..49d8af7c 100644 --- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx +++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx @@ -7,7 +7,7 @@ import { Combobox } from '@renderer/components/ui/combobox'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { cn } from '@renderer/lib/utils'; -import { Check, FolderOpen } from 'lucide-react'; +import { Check, FolderOpen, FolderX } from 'lucide-react'; import { buildProjectPathOptions, @@ -58,6 +58,10 @@ function getOptionSource(option: ComboboxOption): DashboardRecentProjectSource | return (option.meta as ProjectPathOptionMeta | undefined)?.discoverySource; } +function isDeletedOption(option: ComboboxOption): boolean { + return (option.meta as ProjectPathOptionMeta | undefined)?.filesystemState === 'deleted'; +} + function getSourceLabel(source: DashboardRecentProjectSource): string { switch (source) { case 'claude': @@ -97,6 +101,16 @@ const ProjectSourceBadge = ({ ); }; +const ProjectDeletedBadge = (): React.JSX.Element => ( + + + Deleted + +); + export type CwdMode = 'project' | 'custom'; interface ProjectPathSelectorProps { @@ -178,28 +192,38 @@ export const ProjectPathSelector = ({ renderTriggerLabel={(option) => ( + {isDeletedOption(option) ? : null} {option.label} )} - renderOption={(option, isSelected, query) => ( - <> - - -
-

- {renderHighlightedText(option.label, query)} -

-

- {renderHighlightedText(option.description ?? '', query)} -

+ renderOption={(option, isSelected, query) => { + const isDeleted = isDeletedOption(option); + return ( +
+ + + {isDeleted ? : null} +
+

+ {renderHighlightedText(option.label, query)} +

+

+ {renderHighlightedText(option.description ?? '', query)} +

+
- - )} + ); + }} />
diff --git a/src/renderer/components/team/dialogs/projectPathOptions.ts b/src/renderer/components/team/dialogs/projectPathOptions.ts index 8dd5af03..e4a56562 100644 --- a/src/renderer/components/team/dialogs/projectPathOptions.ts +++ b/src/renderer/components/team/dialogs/projectPathOptions.ts @@ -1,16 +1,21 @@ import { normalizePath } from '@renderer/utils/pathNormalize'; import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath'; -import type { DashboardRecentProjectSource } from '@features/recent-projects/contracts'; +import type { + DashboardRecentProjectFilesystemState, + DashboardRecentProjectSource, +} from '@features/recent-projects/contracts'; import type { ComboboxOption } from '@renderer/components/ui/combobox'; import type { Project } from '@shared/types'; export interface ProjectPathProject extends Project { discoverySource?: DashboardRecentProjectSource; + filesystemState?: DashboardRecentProjectFilesystemState; } -export interface ProjectPathOptionMeta { +export interface ProjectPathOptionMeta extends Record { 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 +): 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. diff --git a/src/renderer/components/team/dialogs/projectPathProjects.ts b/src/renderer/components/team/dialogs/projectPathProjects.ts index 60dcbbd6..b180a5bc 100644 --- a/src/renderer/components/team/dialogs/projectPathProjects.ts +++ b/src/renderer/components/team/dialogs/projectPathProjects.ts @@ -22,6 +22,14 @@ function mergeDiscoverySource( return 'mixed'; } +function mergeFilesystemState( + current: ProjectPathProject['filesystemState'], + next: ProjectPathProject['filesystemState'] +): ProjectPathProject['filesystemState'] { + if (current === 'available' || next === 'available') return 'available'; + return current ?? next; +} + function getPathName(projectPath: string): string { return projectPath.split(/[/\\]/).filter(Boolean).pop() ?? projectPath; } @@ -47,6 +55,10 @@ function upsertProject( existing.discoverySource, project.discoverySource ); + existing.filesystemState = mergeFilesystemState( + existing.filesystemState, + project.filesystemState + ); if (!existing.mostRecentSession && project.mostRecentSession) { existing.mostRecentSession = project.mostRecentSession; } @@ -58,6 +70,7 @@ function recentProjectToProject(project: { primaryPath: string; mostRecentActivity: number; source: DashboardRecentProjectSource; + filesystemState?: ProjectPathProject['filesystemState']; }): ProjectPathProject { return { id: `recent:${project.id}`, @@ -68,10 +81,13 @@ function recentProjectToProject(project: { createdAt: project.mostRecentActivity, mostRecentSession: project.mostRecentActivity, discoverySource: project.source, + filesystemState: project.filesystemState, }; } -function repositoryWorktreeToProject(worktree: RepositoryGroup['worktrees'][number]): Project { +function repositoryWorktreeToProject( + worktree: RepositoryGroup['worktrees'][number] +): ProjectPathProject { return { id: worktree.id, path: worktree.path, @@ -79,6 +95,7 @@ function repositoryWorktreeToProject(worktree: RepositoryGroup['worktrees'][numb sessions: [], totalSessions: 0, createdAt: worktree.createdAt ?? Date.now(), + filesystemState: worktree.filesystemState, }; } diff --git a/src/renderer/components/team/editor/EditorFileTree.tsx b/src/renderer/components/team/editor/EditorFileTree.tsx index 3172ccab..b637abe9 100644 --- a/src/renderer/components/team/editor/EditorFileTree.tsx +++ b/src/renderer/components/team/editor/EditorFileTree.tsx @@ -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; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 58c647f8..c3f66dc6 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -65,6 +65,7 @@ interface MessageComposerProps { sendWarning?: string | null; sendDebugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null; lastResult?: SendMessageResult | null; + cornerActionPrefix?: React.ReactNode; /** Ref to the underlying textarea element for external focus management. */ textareaRef?: React.Ref; onSend: ( @@ -112,6 +113,7 @@ export const MessageComposer = ({ sendWarning, sendDebugDetails, lastResult, + cornerActionPrefix, textareaRef: externalTextareaRef, onSend, onCrossTeamSend, @@ -143,6 +145,7 @@ export const MessageComposer = ({ const [recipientOpen, setRecipientOpen] = useState(false); const [recipientSearch, setRecipientSearch] = useState(''); const recipientSearchRef = useRef(null); + const [isTextareaFocused, setIsTextareaFocused] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); const fileInputRef = useRef(null); @@ -642,6 +645,8 @@ export const MessageComposer = ({ }, [canAttach, draftHandlePaste, showFileRestrictionError, validateSelectedAttachmentFiles] ); + const handleTextareaFocus = useCallback(() => setIsTextareaFocused(true), []); + const handleTextareaBlur = useCallback(() => setIsTextareaFocused(false), []); const remaining = MAX_TEXT_LENGTH - trimmed.length; const hasAttachmentPreviewContent = @@ -666,6 +671,29 @@ export const MessageComposer = ({ Reused recent cross-team request ) : null; + const shouldShowFooterCharCount = remaining < 200; + const shouldShowSavedIndicator = isTextareaFocused && draft.isSaved; + const nonCompactFooterRight = + compactFooterNotice || shouldShowFooterCharCount || shouldShowSavedIndicator ? ( +
+ {compactFooterNotice} + {shouldShowFooterCharCount || shouldShowSavedIndicator ? ( +
+ {shouldShowFooterCharCount ? ( + + {remaining} chars left + + ) : null} + {shouldShowSavedIndicator ? ( + Saved + ) : null} +
+ ) : null} +
+ ) : null; + const composerFooterRight = isCompactLayout ? compactFooterNotice : nonCompactFooterRight; return (
+ {cornerActionPrefix} {/* NOTE: ContextRing disabled — usage formula is inaccurate */} @@ -1108,27 +1139,7 @@ export const MessageComposer = ({
} - footerRight={ - isCompactLayout ? ( - compactFooterNotice - ) : ( -
- {compactFooterNotice} -
- {remaining < 200 ? ( - - {remaining} chars left - - ) : null} - {draft.isSaved ? ( - Saved - ) : null} -
-
- ) - } + footerRight={composerFooterRight} /> diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 54e856b0..ac725343 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -13,6 +13,12 @@ import { Sheet, type SheetRef } from 'react-modal-sheet'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@renderer/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; @@ -33,7 +39,9 @@ import { CheckCheck, ChevronsDownUp, ChevronsUpDown, + Dock, MessageSquare, + MoreHorizontal, PanelBottom, PanelBottomClose, PanelBottomOpen, @@ -807,6 +815,10 @@ export const MessagesPanel = memo(function MessagesPanel({ onPositionChange('bottom-sheet'); }, [onPositionChange]); + const moveToFloatingComposer = useCallback(() => { + onPositionChange('floating-composer'); + }, [onPositionChange]); + const snapBottomSheetTo = useCallback((snapIndex: number) => { setBottomSheetSnapIndex(snapIndex); bottomSheetRef.current?.snapTo(snapIndex); @@ -864,6 +876,53 @@ export const MessagesPanel = memo(function MessagesPanel({ /> ); + const floatingComposerModeControls = ( +
+ + + + + Move to inline + + + + + + Move to bottom sheet + + + + + + Move to sidebar + +
+ ); + const compactComposerSection = ( ); + const floatingComposerSection = ( + + ); + const inlineStatusSection = ( )}
- - - - - - {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} - - - - - - - - {messagesSearchBarVisible ? 'Hide search' : 'Search messages'} - - - - - - - Move to inline - + + + + + + + + Message actions + + + setMessagesCollapsed((v) => !v)}> + {messagesCollapsed ? ( + + ) : ( + + )} + {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} + + setMessagesSearchBarVisible((v) => !v)}> + {messagesSearchBarVisible ? ( + + ) : ( + + )} + {messagesSearchBarVisible ? 'Hide search' : 'Search messages'} + + + + Move to inline + + + + Move to bottom sheet + + + + Float composer + + +
{/* Search & filter bar (toggleable) */} @@ -1126,6 +1202,16 @@ export const MessagesPanel = memo(function MessagesPanel({ ); } + if (position === 'floating-composer') { + return ( +
+
+
{floatingComposerSection}
+
+
+ ); + } + if (position === 'bottom-sheet') { if (!mountPoint) { return @@ -1386,6 +1432,23 @@ export const MessagesPanel = memo(function MessagesPanel({ Move to bottom sheet + + + + + Float composer +