fix: harden Windows frontend stability (#125)
This commit is contained in:
parent
a6ba6072c0
commit
d29f3a23d4
59 changed files with 1787 additions and 307 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export type RecentProjectFilesystemState = 'available' | 'deleted';
|
||||
|
|
@ -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',
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export class DashboardRecentProjectsPresenter implements ListDashboardRecentProj
|
|||
source: aggregate.source,
|
||||
openTarget: aggregate.openTarget,
|
||||
primaryBranch: aggregate.branchName,
|
||||
filesystemState: aggregate.filesystemState,
|
||||
})
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ function toCandidate(repo: RepositoryGroup): RecentProjectCandidate | null {
|
|||
worktreeId: preferredWorktree.id,
|
||||
},
|
||||
branchName: preferredWorktree.gitBranch,
|
||||
filesystemState: preferredWorktree.filesystemState ?? 'available',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -501,7 +501,7 @@ export class CliInstallerService {
|
|||
},
|
||||
{
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode (75+ LLM providers)',
|
||||
displayName: 'OpenCode (200+ models)',
|
||||
},
|
||||
] as const
|
||||
).map((provider) => ({
|
||||
|
|
|
|||
|
|
@ -326,7 +326,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
|
|||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode (75+ LLM providers)';
|
||||
return 'OpenCode (200+ models)';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -385,7 +385,7 @@ function getProviderLabel(providerId: CliProviderId): string {
|
|||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode (75+ LLM providers)';
|
||||
return 'OpenCode (200+ models)';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ function getProviderLabel(providerId: CliProviderId): string {
|
|||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode (75+ LLM providers)';
|
||||
return 'OpenCode (200+ models)';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
65
src/renderer/components/ui/dropdown-menu.tsx
Normal file
65
src/renderer/components/ui/dropdown-menu.tsx
Normal 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 */
|
||||
|
|
@ -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)';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet';
|
||||
export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet' | 'floating-composer';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `\` → `/`
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue