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>
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
</p>
|
||||||
|
|
||||||
<img width="1304" height="820" alt="image" src="https://github.com/user-attachments/assets/dea53a01-68b3-4c36-bcf6-e4d1ad4cdb31" />
|
<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-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@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-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,9 @@ importers:
|
||||||
'@radix-ui/react-dialog':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.1.15
|
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)
|
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':
|
'@radix-ui/react-hover-card':
|
||||||
specifier: ^1.1.15
|
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)
|
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':
|
'@types/react-dom':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-focus-guards@1.1.3':
|
||||||
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
|
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -13999,6 +14015,21 @@ snapshots:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
'@types/react-dom': 19.2.3(@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)':
|
'@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
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).
|
* 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 { GraphView } from '@claude-teams/agent-graph';
|
||||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||||
|
|
||||||
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||||
|
import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel';
|
||||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||||
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
||||||
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
||||||
|
|
@ -36,6 +37,7 @@ export interface TeamGraphOverlayProps {
|
||||||
onPinAsTab?: () => void;
|
onPinAsTab?: () => void;
|
||||||
sidebarVisible?: boolean;
|
sidebarVisible?: boolean;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
|
messagesPanelEnabled?: boolean;
|
||||||
onSendMessage?: (memberName: string) => void;
|
onSendMessage?: (memberName: string) => void;
|
||||||
onOpenTaskDetail?: (taskId: string) => void;
|
onOpenTaskDetail?: (taskId: string) => void;
|
||||||
onOpenMemberProfile?: (
|
onOpenMemberProfile?: (
|
||||||
|
|
@ -53,6 +55,7 @@ export const TeamGraphOverlay = ({
|
||||||
onPinAsTab,
|
onPinAsTab,
|
||||||
sidebarVisible,
|
sidebarVisible,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
|
messagesPanelEnabled = true,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onOpenTaskDetail,
|
onOpenTaskDetail,
|
||||||
onOpenMemberProfile,
|
onOpenMemberProfile,
|
||||||
|
|
@ -67,8 +70,18 @@ export const TeamGraphOverlay = ({
|
||||||
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
|
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
|
||||||
useGraphSidebarVisibility();
|
useGraphSidebarVisibility();
|
||||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||||
|
const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState<HTMLDivElement | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
|
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
|
||||||
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
|
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)
|
// Task action dispatchers (same pattern as TeamGraphTab)
|
||||||
const dispatchTaskAction = useCallback(
|
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}
|
{createTaskDialog}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { GraphView } from '@claude-teams/agent-graph';
|
||||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||||
|
|
||||||
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||||
|
import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel';
|
||||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||||
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
||||||
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
||||||
|
|
@ -54,6 +55,9 @@ export const TeamGraphTab = ({
|
||||||
const { openTeamPage, commitOwnerSlotDrop, commitOwnerGridOrderDrop, setLayoutMode } =
|
const { openTeamPage, commitOwnerSlotDrop, commitOwnerGridOrderDrop, setLayoutMode } =
|
||||||
useTeamGraphSurfaceActions(teamName);
|
useTeamGraphSurfaceActions(teamName);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState<HTMLDivElement | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
||||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||||
|
|
||||||
|
|
@ -129,9 +133,16 @@ export const TeamGraphTab = ({
|
||||||
[dispatchOpenProfile]
|
[dispatchOpenProfile]
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
const graphMessagesPanel = useGraphMessagesPanel({
|
||||||
|
teamName,
|
||||||
|
enabled: isActive && isPaneFocused && !fullscreen,
|
||||||
|
mountPoint: messagesPanelMountPoint,
|
||||||
|
onOpenMemberProfile: dispatchOpenProfile,
|
||||||
|
onOpenTaskDetail: dispatchOpenTask,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full overflow-hidden" style={{ background: '#050510' }}>
|
<div className="relative flex size-full overflow-hidden" style={{ background: '#050510' }}>
|
||||||
{sidebarVisible ? (
|
{sidebarVisible ? (
|
||||||
<TeamSidebarHost
|
<TeamSidebarHost
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
|
@ -262,6 +273,13 @@ export const TeamGraphTab = ({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isActive && isPaneFocused && !fullscreen ? (
|
||||||
|
<div
|
||||||
|
ref={setMessagesPanelMountPoint}
|
||||||
|
className="pointer-events-none absolute inset-0 z-30"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{graphMessagesPanel}
|
||||||
{createTaskDialog}
|
{createTaskDialog}
|
||||||
{fullscreen && (
|
{fullscreen && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|
@ -273,6 +291,7 @@ export const TeamGraphTab = ({
|
||||||
onSendMessage={dispatchSendMessage}
|
onSendMessage={dispatchSendMessage}
|
||||||
onOpenTaskDetail={dispatchOpenTask}
|
onOpenTaskDetail={dispatchOpenTask}
|
||||||
onOpenMemberProfile={dispatchOpenProfile}
|
onOpenMemberProfile={dispatchOpenProfile}
|
||||||
|
messagesPanelEnabled={isActive && isPaneFocused}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,24 @@ import {
|
||||||
type MemberWorkSyncTargetedRecoveryReason,
|
type MemberWorkSyncTargetedRecoveryReason,
|
||||||
} from './MemberWorkSyncTargetedRecoveryPolicy';
|
} from './MemberWorkSyncTargetedRecoveryPolicy';
|
||||||
|
|
||||||
import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../contracts';
|
import type {
|
||||||
|
MemberWorkSyncMetricEvent,
|
||||||
|
MemberWorkSyncStatus,
|
||||||
|
MemberWorkSyncTeamMetrics,
|
||||||
|
} from '../../contracts';
|
||||||
|
|
||||||
export type MemberWorkSyncNudgeActivationReason =
|
export type MemberWorkSyncNudgeActivationReason =
|
||||||
| 'shadow_ready'
|
| 'shadow_ready'
|
||||||
| MemberWorkSyncTargetedRecoveryReason
|
| MemberWorkSyncTargetedRecoveryReason
|
||||||
| 'review_pickup_required'
|
| 'review_pickup_required'
|
||||||
|
| 'native_stale_in_progress'
|
||||||
| 'status_not_nudgeable'
|
| 'status_not_nudgeable'
|
||||||
| 'blocking_metrics'
|
| 'blocking_metrics'
|
||||||
| 'phase2_not_ready';
|
| '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 {
|
export interface MemberWorkSyncNudgeActivationDecision {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
reason: MemberWorkSyncNudgeActivationReason;
|
reason: MemberWorkSyncNudgeActivationReason;
|
||||||
|
|
@ -32,6 +40,129 @@ function hasBlockingMetrics(metrics: MemberWorkSyncTeamMetrics): boolean {
|
||||||
return metrics.phase2Readiness.reasons.some((reason) => BLOCKING_PHASE2_REASONS.has(reason));
|
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 {
|
function isReviewPickupRequiredCandidate(status: MemberWorkSyncStatus): boolean {
|
||||||
return (
|
return (
|
||||||
status.state === 'needs_sync' &&
|
status.state === 'needs_sync' &&
|
||||||
|
|
@ -61,6 +192,10 @@ export function decideMemberWorkSyncNudgeActivation(input: {
|
||||||
return { active: true, reason: targetedRecovery.reason };
|
return { active: true, reason: targetedRecovery.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isNativeStaleInProgressCandidate(input)) {
|
||||||
|
return { active: true, reason: 'native_stale_in_progress' };
|
||||||
|
}
|
||||||
|
|
||||||
if (hasBlockingMetrics(input.metrics)) {
|
if (hasBlockingMetrics(input.metrics)) {
|
||||||
return { active: false, reason: 'blocking_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 DashboardRecentProjectSource = 'claude' | 'codex' | 'mixed';
|
||||||
|
|
||||||
|
export type DashboardRecentProjectFilesystemState = 'available' | 'deleted';
|
||||||
|
|
||||||
export type DashboardRecentProjectOpenTarget =
|
export type DashboardRecentProjectOpenTarget =
|
||||||
| { type: 'existing-worktree'; repositoryId: string; worktreeId: string }
|
| { type: 'existing-worktree'; repositoryId: string; worktreeId: string }
|
||||||
| { type: 'synthetic-path'; path: string };
|
| { type: 'synthetic-path'; path: string };
|
||||||
|
|
@ -16,6 +18,7 @@ export interface DashboardRecentProject {
|
||||||
source: DashboardRecentProjectSource;
|
source: DashboardRecentProjectSource;
|
||||||
openTarget: DashboardRecentProjectOpenTarget;
|
openTarget: DashboardRecentProjectOpenTarget;
|
||||||
primaryBranch?: string;
|
primaryBranch?: string;
|
||||||
|
filesystemState?: DashboardRecentProjectFilesystemState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardRecentProjectsPayload {
|
export interface DashboardRecentProjectsPayload {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { ProviderId } from './ProviderId';
|
import type { ProviderId } from './ProviderId';
|
||||||
|
import type { RecentProjectFilesystemState } from './RecentProjectFilesystemState';
|
||||||
import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget';
|
import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget';
|
||||||
|
|
||||||
export interface RecentProjectAggregate {
|
export interface RecentProjectAggregate {
|
||||||
|
|
@ -11,4 +12,5 @@ export interface RecentProjectAggregate {
|
||||||
source: 'claude' | 'codex' | 'mixed';
|
source: 'claude' | 'codex' | 'mixed';
|
||||||
openTarget: RecentProjectOpenTarget;
|
openTarget: RecentProjectOpenTarget;
|
||||||
branchName?: string;
|
branchName?: string;
|
||||||
|
filesystemState: RecentProjectFilesystemState;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { ProviderId } from './ProviderId';
|
import type { ProviderId } from './ProviderId';
|
||||||
|
import type { RecentProjectFilesystemState } from './RecentProjectFilesystemState';
|
||||||
import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget';
|
import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget';
|
||||||
|
|
||||||
export interface RecentProjectCandidate {
|
export interface RecentProjectCandidate {
|
||||||
|
|
@ -11,4 +12,5 @@ export interface RecentProjectCandidate {
|
||||||
sourceKind: 'claude' | 'codex';
|
sourceKind: 'claude' | 'codex';
|
||||||
openTarget: RecentProjectOpenTarget;
|
openTarget: RecentProjectOpenTarget;
|
||||||
branchName?: string;
|
branchName?: string;
|
||||||
|
filesystemState?: RecentProjectFilesystemState;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export type RecentProjectFilesystemState = 'available' | 'deleted';
|
||||||
|
|
@ -25,10 +25,14 @@ function uniqueProviders(providerIds: readonly ProviderId[]): ProviderId[] {
|
||||||
function selectPreferredCandidate(
|
function selectPreferredCandidate(
|
||||||
candidates: readonly RecentProjectCandidate[]
|
candidates: readonly RecentProjectCandidate[]
|
||||||
): 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'
|
(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) => {
|
return [...pool].sort((left, right) => {
|
||||||
if (right.lastActivityAt !== left.lastActivityAt) {
|
if (right.lastActivityAt !== left.lastActivityAt) {
|
||||||
|
|
@ -81,6 +85,7 @@ export function mergeRecentProjectCandidates(
|
||||||
source: sourceKinds.size > 1 ? 'mixed' : sourceKinds.has('codex') ? 'codex' : 'claude',
|
source: sourceKinds.size > 1 ? 'mixed' : sourceKinds.has('codex') ? 'codex' : 'claude',
|
||||||
openTarget: preferred.openTarget,
|
openTarget: preferred.openTarget,
|
||||||
branchName: mergeBranchName(group),
|
branchName: mergeBranchName(group),
|
||||||
|
filesystemState: preferred.filesystemState ?? 'available',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export class DashboardRecentProjectsPresenter implements ListDashboardRecentProj
|
||||||
source: aggregate.source,
|
source: aggregate.source,
|
||||||
openTarget: aggregate.openTarget,
|
openTarget: aggregate.openTarget,
|
||||||
primaryBranch: aggregate.branchName,
|
primaryBranch: aggregate.branchName,
|
||||||
|
filesystemState: aggregate.filesystemState,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ function toCandidate(repo: RepositoryGroup): RecentProjectCandidate | null {
|
||||||
worktreeId: preferredWorktree.id,
|
worktreeId: preferredWorktree.id,
|
||||||
},
|
},
|
||||||
branchName: preferredWorktree.gitBranch,
|
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 { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
|
||||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
@ -129,7 +130,9 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
||||||
);
|
);
|
||||||
|
|
||||||
const candidates = (
|
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);
|
).filter((candidate): candidate is RecentProjectCandidate => candidate !== null);
|
||||||
|
|
||||||
if (!degraded) {
|
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();
|
const cwd = thread.cwd?.trim();
|
||||||
if (!cwd || isEphemeralProjectPath(cwd)) {
|
if (!cwd || isEphemeralProjectPath(cwd)) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -321,6 +327,7 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
||||||
path: cwd,
|
path: cwd,
|
||||||
},
|
},
|
||||||
branchName: thread.gitInfo?.branch ?? undefined,
|
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 os from 'node:os';
|
||||||
import path from 'node:path';
|
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 { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
|
||||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||||
|
|
||||||
|
|
@ -225,7 +226,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
||||||
try {
|
try {
|
||||||
const snapshots = await this.#listRecentSessionSnapshots();
|
const snapshots = await this.#listRecentSessionSnapshots();
|
||||||
const candidates = await Promise.all(
|
const candidates = await Promise.all(
|
||||||
snapshots.map((snapshot) => this.#toCandidate(snapshot))
|
snapshots.map((snapshot) => this.#toCandidate(snapshot, activeContext.fsProvider))
|
||||||
);
|
);
|
||||||
|
|
||||||
const validCandidates = candidates.filter(
|
const validCandidates = candidates.filter(
|
||||||
|
|
@ -303,7 +304,8 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
||||||
}
|
}
|
||||||
|
|
||||||
async #toCandidate(
|
async #toCandidate(
|
||||||
snapshot: CodexSessionProjectSnapshot
|
snapshot: CodexSessionProjectSnapshot,
|
||||||
|
fsProvider?: ServiceContext['fsProvider']
|
||||||
): Promise<RecentProjectCandidate | null> {
|
): Promise<RecentProjectCandidate | null> {
|
||||||
const identity = await this.deps.identityResolver.resolve(snapshot.cwd);
|
const identity = await this.deps.identityResolver.resolve(snapshot.cwd);
|
||||||
const displayName = identity?.name ?? path.basename(snapshot.cwd) ?? snapshot.cwd;
|
const displayName = identity?.name ?? path.basename(snapshot.cwd) ?? snapshot.cwd;
|
||||||
|
|
@ -321,6 +323,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
||||||
path: snapshot.cwd,
|
path: snapshot.cwd,
|
||||||
},
|
},
|
||||||
branchName: snapshot.branchName,
|
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;
|
lastActivityLabel: string;
|
||||||
providerIds: DashboardRecentProject['providerIds'];
|
providerIds: DashboardRecentProject['providerIds'];
|
||||||
primaryBranch?: string;
|
primaryBranch?: string;
|
||||||
|
filesystemState?: DashboardRecentProject['filesystemState'];
|
||||||
taskCounts?: TaskStatusCounts;
|
taskCounts?: TaskStatusCounts;
|
||||||
tasksLoading: boolean;
|
tasksLoading: boolean;
|
||||||
activeTeams?: TeamSummary[];
|
activeTeams?: TeamSummary[];
|
||||||
|
|
@ -121,6 +122,7 @@ export function adaptRecentProjectsSection({
|
||||||
}),
|
}),
|
||||||
providerIds: sortDashboardProviderIds(project.providerIds),
|
providerIds: sortDashboardProviderIds(project.providerIds),
|
||||||
primaryBranch: project.primaryBranch,
|
primaryBranch: project.primaryBranch,
|
||||||
|
filesystemState: project.filesystemState,
|
||||||
taskCounts: sumTaskCounts(project, taskCountsByProject),
|
taskCounts: sumTaskCounts(project, taskCountsByProject),
|
||||||
tasksLoading,
|
tasksLoading,
|
||||||
activeTeams: collectActiveTeams(project, activeTeamsByProject),
|
activeTeams: collectActiveTeams(project, activeTeamsByProject),
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,11 @@ export function useOpenRecentProject(): {
|
||||||
|
|
||||||
const openRecentProject = useCallback(
|
const openRecentProject = useCallback(
|
||||||
async (project: DashboardRecentProject): Promise<void> => {
|
async (project: DashboardRecentProject): Promise<void> => {
|
||||||
|
if (project.filesystemState === 'deleted') {
|
||||||
|
logger.warn('Skipped deleted recent project path', { path: project.primaryPath });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await openTarget(project.openTarget, project.associatedPaths);
|
await openTarget(project.openTarget, project.associatedPaths);
|
||||||
recordRecentProjectOpenPaths([project.primaryPath, ...project.associatedPaths]);
|
recordRecentProjectOpenPaths([project.primaryPath, ...project.associatedPaths]);
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import { useMemo } from 'react';
|
||||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||||
import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator';
|
import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { projectColor } from '@renderer/utils/projectColor';
|
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';
|
import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter';
|
||||||
|
|
||||||
|
|
@ -20,11 +21,17 @@ export const RecentProjectCard = ({
|
||||||
onOpenPath,
|
onOpenPath,
|
||||||
}: Readonly<RecentProjectCardProps>): React.JSX.Element => {
|
}: Readonly<RecentProjectCardProps>): React.JSX.Element => {
|
||||||
const color = useMemo(() => projectColor(card.name), [card.name]);
|
const color = useMemo(() => projectColor(card.name), [card.name]);
|
||||||
|
const isDeleted = card.filesystemState === 'deleted';
|
||||||
|
const FolderIcon = isDeleted ? FolderX : FolderGit2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={isDeleted ? undefined : 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"
|
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 && (
|
{card.activeTeams && card.activeTeams.length > 0 && (
|
||||||
<ActivePulseIndicator className="absolute right-3 top-3" />
|
<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="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">
|
<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"
|
className="size-4 transition-colors group-hover:text-text"
|
||||||
style={{ color: color.icon }}
|
style={{ color: isDeleted ? 'var(--field-error-text)' : color.icon }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<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">
|
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
|
||||||
{card.name}
|
{card.name}
|
||||||
</h3>
|
</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 && (
|
{card.pathSummary && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -91,21 +108,34 @@ export const RecentProjectCard = ({
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
if (isDeleted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onOpenPath();
|
onOpenPath();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
if (isDeleted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onOpenPath();
|
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" />
|
<FolderOpen className="size-3" />
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">Open</TooltipContent>
|
<TooltipContent side="bottom">
|
||||||
|
{isDeleted ? 'Project folder no longer exists' : 'Open'}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
import {
|
import {
|
||||||
type PaginatedSessionsResult,
|
type PaginatedSessionsResult,
|
||||||
type Project,
|
type Project,
|
||||||
|
type ProjectFilesystemState,
|
||||||
type RepositoryGroup,
|
type RepositoryGroup,
|
||||||
type SearchSessionsResult,
|
type SearchSessionsResult,
|
||||||
type Session,
|
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.
|
// for lookups and navigation; a small cap preserves that behavior without huge payloads.
|
||||||
const MAX_SESSION_IDS_EXPORTED = 200;
|
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 {
|
export interface ProjectScannerOptions {
|
||||||
/**
|
/**
|
||||||
* Directory for the persisted session-list metadata index.
|
* Directory for the persisted session-list metadata index.
|
||||||
|
|
@ -340,6 +356,7 @@ export class ProjectScanner {
|
||||||
totalSessions,
|
totalSessions,
|
||||||
createdAt: project.createdAt,
|
createdAt: project.createdAt,
|
||||||
mostRecentSession: project.mostRecentSession,
|
mostRecentSession: project.mostRecentSession,
|
||||||
|
filesystemState: project.filesystemState,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
name: project.name,
|
name: project.name,
|
||||||
|
|
@ -360,6 +377,7 @@ export class ProjectScanner {
|
||||||
const encodedId = customPath.replace(/[/\\]/g, '-');
|
const encodedId = customPath.replace(/[/\\]/g, '-');
|
||||||
const folderName = customPath.split(/[/\\]/).filter(Boolean).pop() ?? customPath;
|
const folderName = customPath.split(/[/\\]/).filter(Boolean).pop() ?? customPath;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const filesystemState = await resolveProjectFilesystemState(this.fsProvider, customPath);
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
id: encodedId,
|
id: encodedId,
|
||||||
|
|
@ -374,6 +392,7 @@ export class ProjectScanner {
|
||||||
sessions: [],
|
sessions: [],
|
||||||
totalSessions: 0,
|
totalSessions: 0,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
filesystemState,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
name: folderName,
|
name: folderName,
|
||||||
|
|
@ -550,6 +569,7 @@ export class ProjectScanner {
|
||||||
cwdHint: firstCwd ?? undefined,
|
cwdHint: firstCwd ?? undefined,
|
||||||
sessionPaths,
|
sessionPaths,
|
||||||
});
|
});
|
||||||
|
const filesystemState = await resolveProjectFilesystemState(this.fsProvider, actualPath);
|
||||||
|
|
||||||
// Derive name from resolved path — more reliable than decodePath for
|
// Derive name from resolved path — more reliable than decodePath for
|
||||||
// paths containing dashes (e.g. "test-project" encodes lossily).
|
// paths containing dashes (e.g. "test-project" encodes lossily).
|
||||||
|
|
@ -564,6 +584,7 @@ export class ProjectScanner {
|
||||||
totalSessions: allSessionIds.length,
|
totalSessions: allSessionIds.length,
|
||||||
createdAt: Math.floor(createdAt),
|
createdAt: Math.floor(createdAt),
|
||||||
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
|
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
|
||||||
|
filesystemState,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -623,6 +644,10 @@ export class ProjectScanner {
|
||||||
totalSessions: sessionIds.length,
|
totalSessions: sessionIds.length,
|
||||||
createdAt: Math.floor(createdAt),
|
createdAt: Math.floor(createdAt),
|
||||||
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
|
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
|
||||||
|
filesystemState: await resolveProjectFilesystemState(
|
||||||
|
this.fsProvider,
|
||||||
|
actualCwd ?? decodedFallback
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -501,7 +501,7 @@ export class CliInstallerService {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
displayName: 'OpenCode (75+ LLM providers)',
|
displayName: 'OpenCode (200+ models)',
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
).map((provider) => ({
|
).map((provider) => ({
|
||||||
|
|
|
||||||
|
|
@ -326,7 +326,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
|
||||||
case 'gemini':
|
case 'gemini':
|
||||||
return 'Gemini';
|
return 'Gemini';
|
||||||
case 'opencode':
|
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.
|
* Project information derived from ~/.claude/projects/ directory.
|
||||||
*/
|
*/
|
||||||
|
export type ProjectFilesystemState = 'available' | 'deleted';
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
/** Encoded directory name (e.g., "-Users-username-projectname") */
|
/** Encoded directory name (e.g., "-Users-username-projectname") */
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -62,6 +64,8 @@ export interface Project {
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
/** Unix timestamp of most recent session activity */
|
/** Unix timestamp of most recent session activity */
|
||||||
mostRecentSession?: number;
|
mostRecentSession?: number;
|
||||||
|
/** Filesystem state for the decoded working directory. */
|
||||||
|
filesystemState?: ProjectFilesystemState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -202,6 +206,8 @@ export interface Worktree {
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
/** Unix timestamp of most recent session activity */
|
/** Unix timestamp of most recent session activity */
|
||||||
mostRecentSession?: number;
|
mostRecentSession?: number;
|
||||||
|
/** Filesystem state for this worktree path. */
|
||||||
|
filesystemState?: ProjectFilesystemState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -385,7 +385,7 @@ function getProviderLabel(providerId: CliProviderId): string {
|
||||||
case 'gemini':
|
case 'gemini':
|
||||||
return 'Gemini';
|
return 'Gemini';
|
||||||
case 'opencode':
|
case 'opencode':
|
||||||
return 'OpenCode (75+ LLM providers)';
|
return 'OpenCode (200+ models)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export const TeamTabSectionNav = ({
|
||||||
if (messagesPanelMode === 'sidebar') {
|
if (messagesPanelMode === 'sidebar') {
|
||||||
return section.id !== 'messages' && section.id !== 'claude-logs';
|
return section.id !== 'messages' && section.id !== 'claude-logs';
|
||||||
}
|
}
|
||||||
if (messagesPanelMode === 'bottom-sheet') {
|
if (messagesPanelMode === 'bottom-sheet' || messagesPanelMode === 'floating-composer') {
|
||||||
return section.id !== 'messages';
|
return section.id !== 'messages';
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ function getProviderLabel(providerId: CliProviderId): string {
|
||||||
case 'gemini':
|
case 'gemini':
|
||||||
return 'Gemini';
|
return 'Gemini';
|
||||||
case 'opencode':
|
case 'opencode':
|
||||||
return 'OpenCode (75+ LLM providers)';
|
return 'OpenCode (200+ models)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3623,9 +3623,18 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
ref={setMessagesPanelMountPoint}
|
ref={setMessagesPanelMountPoint}
|
||||||
className="pointer-events-none absolute inset-0 z-30"
|
className="pointer-events-none absolute inset-0 z-30"
|
||||||
/>
|
/>
|
||||||
{messagesPanelMode === 'bottom-sheet' && (
|
{messagesPanelMode === 'bottom-sheet' && !graphOpen && (
|
||||||
<TeamMessagesPanelBridge position="bottom-sheet" {...sharedMessagesPanelProps} />
|
<TeamMessagesPanelBridge position="bottom-sheet" {...sharedMessagesPanelProps} />
|
||||||
)}
|
)}
|
||||||
|
{messagesPanelMode === 'floating-composer' &&
|
||||||
|
isThisTabActive &&
|
||||||
|
isPaneFocused &&
|
||||||
|
!graphOpen && (
|
||||||
|
<TeamMessagesPanelBridge
|
||||||
|
position="floating-composer"
|
||||||
|
{...sharedMessagesPanelProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -3650,6 +3659,12 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
.getState()
|
.getState()
|
||||||
.openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName });
|
.openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName });
|
||||||
}}
|
}}
|
||||||
|
messagesPanelEnabled={
|
||||||
|
(messagesPanelMode === 'floating-composer' ||
|
||||||
|
messagesPanelMode === 'bottom-sheet') &&
|
||||||
|
isThisTabActive &&
|
||||||
|
isPaneFocused
|
||||||
|
}
|
||||||
onSendMessage={(memberName) => {
|
onSendMessage={(memberName) => {
|
||||||
setSendDialogRecipient(memberName);
|
setSendDialogRecipient(memberName);
|
||||||
setSendDialogDefaultText(undefined);
|
setSendDialogDefaultText(undefined);
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,10 @@ import {
|
||||||
resolveProviderScopedMemberModel,
|
resolveProviderScopedMemberModel,
|
||||||
} from './memberModelScope';
|
} from './memberModelScope';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
|
import {
|
||||||
|
isDeletedProjectPathSelection,
|
||||||
|
isSelectableProjectPathProject,
|
||||||
|
} from './projectPathOptions';
|
||||||
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
|
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||||
|
|
@ -574,9 +578,17 @@ export const CreateTeamDialog = ({
|
||||||
[members]
|
[members]
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedProjectCwd = isEphemeralProjectPath(selectedProjectPath)
|
const selectedProjectPathDeleted = useMemo(
|
||||||
? ''
|
() =>
|
||||||
: selectedProjectPath.trim();
|
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 effectiveCwd = cwdMode === 'project' ? selectedProjectCwd : customCwd.trim();
|
||||||
const dialogTeamNameKey = sanitizeTeamName(teamName.trim());
|
const dialogTeamNameKey = sanitizeTeamName(teamName.trim());
|
||||||
/** All taken names: existing teams + teams currently being provisioned. */
|
/** All taken names: existing teams + teams currently being provisioned. */
|
||||||
|
|
@ -1214,7 +1226,7 @@ export const CreateTeamDialog = ({
|
||||||
if (cwdMode !== 'project') {
|
if (cwdMode !== 'project') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selectableProjects = projects.filter((project) => !isEphemeralProjectPath(project.path));
|
const selectableProjects = projects.filter(isSelectableProjectPathProject);
|
||||||
if (selectableProjects.length === 0) {
|
if (selectableProjects.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1247,17 +1259,20 @@ export const CreateTeamDialog = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSelectedProjectPath(selectableProjects[0].path);
|
setSelectedProjectPath(selectableProjects[0].path);
|
||||||
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath, setSelectedProjectPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || cwdMode !== 'project' || !selectedProjectPath) {
|
if (!open || cwdMode !== 'project' || !selectedProjectPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isEphemeralProjectPath(selectedProjectPath)) {
|
if (
|
||||||
|
!isEphemeralProjectPath(selectedProjectPath) &&
|
||||||
|
!isDeletedProjectPathSelection(projects, selectedProjectPath)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedProjectPath('');
|
setSelectedProjectPath('');
|
||||||
}, [open, cwdMode, selectedProjectPath, setSelectedProjectPath]);
|
}, [open, cwdMode, projects, selectedProjectPath, setSelectedProjectPath]);
|
||||||
|
|
||||||
useFileListCacheWarmer(effectiveCwd || null);
|
useFileListCacheWarmer(effectiveCwd || null);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ExternalLink,
|
||||||
Info,
|
Info,
|
||||||
Loader2,
|
Loader2,
|
||||||
X,
|
X,
|
||||||
|
|
@ -98,6 +99,10 @@ import {
|
||||||
resolveProviderScopedMemberModel,
|
resolveProviderScopedMemberModel,
|
||||||
} from './memberModelScope';
|
} from './memberModelScope';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
|
import {
|
||||||
|
isDeletedProjectPathSelection,
|
||||||
|
isSelectableProjectPathProject,
|
||||||
|
} from './projectPathOptions';
|
||||||
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
|
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||||
|
|
@ -230,6 +235,8 @@ export type LaunchTeamDialogProps =
|
||||||
| LaunchDialogScheduleMode;
|
| LaunchDialogScheduleMode;
|
||||||
|
|
||||||
const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate';
|
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
|
// Helpers
|
||||||
|
|
@ -1363,9 +1370,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
// Launch-only effects
|
// Launch-only effects
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const selectedProjectCwd = isEphemeralProjectPath(selectedProjectPath)
|
const selectedProjectPathDeleted = useMemo(
|
||||||
? ''
|
() =>
|
||||||
: selectedProjectPath.trim();
|
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 effectiveCwd = cwdMode === 'project' ? selectedProjectCwd : customCwd.trim();
|
||||||
const hasSelectedWorktreeIsolation =
|
const hasSelectedWorktreeIsolation =
|
||||||
isLaunchMode &&
|
isLaunchMode &&
|
||||||
|
|
@ -1697,7 +1712,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (cwdMode !== 'project') return;
|
if (cwdMode !== 'project') return;
|
||||||
const selectableProjects = projects.filter((project) => !isEphemeralProjectPath(project.path));
|
const selectableProjects = projects.filter(isSelectableProjectPathProject);
|
||||||
if (selectableProjects.length === 0) return;
|
if (selectableProjects.length === 0) return;
|
||||||
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
|
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
|
||||||
const normalizedDefaultProjectPath = normalizePath(defaultProjectPath);
|
const normalizedDefaultProjectPath = normalizePath(defaultProjectPath);
|
||||||
|
|
@ -1726,17 +1741,20 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSelectedProjectPath(selectableProjects[0].path);
|
setSelectedProjectPath(selectableProjects[0].path);
|
||||||
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath, setSelectedProjectPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || cwdMode !== 'project' || !selectedProjectPath) {
|
if (!open || cwdMode !== 'project' || !selectedProjectPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isEphemeralProjectPath(selectedProjectPath)) {
|
if (
|
||||||
|
!isEphemeralProjectPath(selectedProjectPath) &&
|
||||||
|
!isDeletedProjectPathSelection(projects, selectedProjectPath)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedProjectPath('');
|
setSelectedProjectPath('');
|
||||||
}, [open, cwdMode, selectedProjectPath, setSelectedProjectPath]);
|
}, [open, cwdMode, projects, selectedProjectPath, setSelectedProjectPath]);
|
||||||
|
|
||||||
// Pre-warm file list cache so @-mention file search is instant
|
// Pre-warm file list cache so @-mention file search is instant
|
||||||
useFileListCacheWarmer(effectiveCwd || null);
|
useFileListCacheWarmer(effectiveCwd || null);
|
||||||
|
|
@ -1874,7 +1892,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
|
|
||||||
const validationErrors = useMemo(() => {
|
const validationErrors = useMemo(() => {
|
||||||
const errors: string[] = [];
|
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 (worktreeGitBlockingMessage) errors.push(worktreeGitBlockingMessage);
|
||||||
if (isSchedule) {
|
if (isSchedule) {
|
||||||
if (!effectiveTeamName) errors.push('Team is required');
|
if (!effectiveTeamName) errors.push('Team is required');
|
||||||
|
|
@ -1884,6 +1906,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
return errors;
|
return errors;
|
||||||
}, [
|
}, [
|
||||||
effectiveCwd,
|
effectiveCwd,
|
||||||
|
selectedProjectPathDeleted,
|
||||||
worktreeGitBlockingMessage,
|
worktreeGitBlockingMessage,
|
||||||
isSchedule,
|
isSchedule,
|
||||||
effectiveTeamName,
|
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
|
This prompt will be passed to <code className="font-mono">claude -p</code> for
|
||||||
one-shot execution
|
one-shot execution
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Combobox } from '@renderer/components/ui/combobox';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { Check, FolderOpen } from 'lucide-react';
|
import { Check, FolderOpen, FolderX } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildProjectPathOptions,
|
buildProjectPathOptions,
|
||||||
|
|
@ -58,6 +58,10 @@ function getOptionSource(option: ComboboxOption): DashboardRecentProjectSource |
|
||||||
return (option.meta as ProjectPathOptionMeta | undefined)?.discoverySource;
|
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 {
|
function getSourceLabel(source: DashboardRecentProjectSource): string {
|
||||||
switch (source) {
|
switch (source) {
|
||||||
case 'claude':
|
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';
|
export type CwdMode = 'project' | 'custom';
|
||||||
|
|
||||||
interface ProjectPathSelectorProps {
|
interface ProjectPathSelectorProps {
|
||||||
|
|
@ -178,28 +192,38 @@ export const ProjectPathSelector = ({
|
||||||
renderTriggerLabel={(option) => (
|
renderTriggerLabel={(option) => (
|
||||||
<span className="flex min-w-0 items-center gap-1.5">
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
<ProjectSourceBadge source={getOptionSource(option)} />
|
<ProjectSourceBadge source={getOptionSource(option)} />
|
||||||
|
{isDeletedOption(option) ? <ProjectDeletedBadge /> : null}
|
||||||
<span className="min-w-0 truncate">{option.label}</span>
|
<span className="min-w-0 truncate">{option.label}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
renderOption={(option, isSelected, query) => (
|
renderOption={(option, isSelected, query) => {
|
||||||
<>
|
const isDeleted = isDeletedOption(option);
|
||||||
<Check
|
return (
|
||||||
className={cn(
|
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
'mr-2 size-3.5 shrink-0',
|
<Check
|
||||||
isSelected ? 'opacity-100' : 'opacity-0'
|
className={cn(
|
||||||
)}
|
'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)]">
|
<ProjectSourceBadge source={getOptionSource(option)} />
|
||||||
{renderHighlightedText(option.label, query)}
|
{isDeleted ? <ProjectDeletedBadge /> : null}
|
||||||
</p>
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-[var(--color-text-muted)]">
|
<p
|
||||||
{renderHighlightedText(option.description ?? '', query)}
|
className={cn(
|
||||||
</p>
|
'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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
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 { ComboboxOption } from '@renderer/components/ui/combobox';
|
||||||
import type { Project } from '@shared/types';
|
import type { Project } from '@shared/types';
|
||||||
|
|
||||||
export interface ProjectPathProject extends Project {
|
export interface ProjectPathProject extends Project {
|
||||||
discoverySource?: DashboardRecentProjectSource;
|
discoverySource?: DashboardRecentProjectSource;
|
||||||
|
filesystemState?: DashboardRecentProjectFilesystemState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectPathOptionMeta {
|
export interface ProjectPathOptionMeta extends Record<string, unknown> {
|
||||||
discoverySource?: DashboardRecentProjectSource;
|
discoverySource?: DashboardRecentProjectSource;
|
||||||
|
filesystemState?: DashboardRecentProjectFilesystemState;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toProjectOption(project: ProjectPathProject): ComboboxOption {
|
function toProjectOption(project: ProjectPathProject): ComboboxOption {
|
||||||
|
|
@ -20,15 +25,45 @@ function toProjectOption(project: ProjectPathProject): ComboboxOption {
|
||||||
description: project.path,
|
description: project.path,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (project.discoverySource !== undefined) {
|
if (project.filesystemState === 'deleted') {
|
||||||
option.meta = {
|
option.disabled = true;
|
||||||
discoverySource: project.discoverySource,
|
}
|
||||||
} satisfies ProjectPathOptionMeta;
|
|
||||||
|
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;
|
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.
|
* Collapse duplicate project entries that resolve to the same filesystem path.
|
||||||
* This keeps combobox item values unique even when scanner sources overlap.
|
* This keeps combobox item values unique even when scanner sources overlap.
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,14 @@ function mergeDiscoverySource(
|
||||||
return 'mixed';
|
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 {
|
function getPathName(projectPath: string): string {
|
||||||
return projectPath.split(/[/\\]/).filter(Boolean).pop() ?? projectPath;
|
return projectPath.split(/[/\\]/).filter(Boolean).pop() ?? projectPath;
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +55,10 @@ function upsertProject(
|
||||||
existing.discoverySource,
|
existing.discoverySource,
|
||||||
project.discoverySource
|
project.discoverySource
|
||||||
);
|
);
|
||||||
|
existing.filesystemState = mergeFilesystemState(
|
||||||
|
existing.filesystemState,
|
||||||
|
project.filesystemState
|
||||||
|
);
|
||||||
if (!existing.mostRecentSession && project.mostRecentSession) {
|
if (!existing.mostRecentSession && project.mostRecentSession) {
|
||||||
existing.mostRecentSession = project.mostRecentSession;
|
existing.mostRecentSession = project.mostRecentSession;
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +70,7 @@ function recentProjectToProject(project: {
|
||||||
primaryPath: string;
|
primaryPath: string;
|
||||||
mostRecentActivity: number;
|
mostRecentActivity: number;
|
||||||
source: DashboardRecentProjectSource;
|
source: DashboardRecentProjectSource;
|
||||||
|
filesystemState?: ProjectPathProject['filesystemState'];
|
||||||
}): ProjectPathProject {
|
}): ProjectPathProject {
|
||||||
return {
|
return {
|
||||||
id: `recent:${project.id}`,
|
id: `recent:${project.id}`,
|
||||||
|
|
@ -68,10 +81,13 @@ function recentProjectToProject(project: {
|
||||||
createdAt: project.mostRecentActivity,
|
createdAt: project.mostRecentActivity,
|
||||||
mostRecentSession: project.mostRecentActivity,
|
mostRecentSession: project.mostRecentActivity,
|
||||||
discoverySource: project.source,
|
discoverySource: project.source,
|
||||||
|
filesystemState: project.filesystemState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function repositoryWorktreeToProject(worktree: RepositoryGroup['worktrees'][number]): Project {
|
function repositoryWorktreeToProject(
|
||||||
|
worktree: RepositoryGroup['worktrees'][number]
|
||||||
|
): ProjectPathProject {
|
||||||
return {
|
return {
|
||||||
id: worktree.id,
|
id: worktree.id,
|
||||||
path: worktree.path,
|
path: worktree.path,
|
||||||
|
|
@ -79,6 +95,7 @@ function repositoryWorktreeToProject(worktree: RepositoryGroup['worktrees'][numb
|
||||||
sessions: [],
|
sessions: [],
|
||||||
totalSessions: 0,
|
totalSessions: 0,
|
||||||
createdAt: worktree.createdAt ?? Date.now(),
|
createdAt: worktree.createdAt ?? Date.now(),
|
||||||
|
filesystemState: worktree.filesystemState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
isPathPrefix,
|
isPathPrefix,
|
||||||
joinPath,
|
joinPath,
|
||||||
lastSeparatorIndex,
|
lastSeparatorIndex,
|
||||||
|
normalizePathForComparison,
|
||||||
splitPath,
|
splitPath,
|
||||||
} from '@shared/utils/platformPath';
|
} from '@shared/utils/platformPath';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
|
@ -81,6 +82,17 @@ const INDENT_PX = 12;
|
||||||
const MAX_DEPTH = 12;
|
const MAX_DEPTH = 12;
|
||||||
const AUTO_EXPAND_DELAY_MS = 500;
|
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
|
// Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -204,7 +216,7 @@ export const EditorFileTree = ({
|
||||||
// Scroll to file when selectedFilePath changes (e.g. from revealFileInEditor)
|
// Scroll to file when selectedFilePath changes (e.g. from revealFileInEditor)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedFilePath) return;
|
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) {
|
if (idx >= 0) {
|
||||||
virtualizer.scrollToIndex(idx, { align: 'center' });
|
virtualizer.scrollToIndex(idx, { align: 'center' });
|
||||||
}
|
}
|
||||||
|
|
@ -633,7 +645,7 @@ const DraggableTreeItem = React.memo(
|
||||||
onRenameCancel,
|
onRenameCancel,
|
||||||
}: DraggableTreeItemProps): React.ReactElement => {
|
}: DraggableTreeItemProps): React.ReactElement => {
|
||||||
const { node, depth, isExpanded } = item;
|
const { node, depth, isExpanded } = item;
|
||||||
const isSelected = activeNodePath === node.fullPath;
|
const isSelected = treePathsEqual(activeNodePath, node.fullPath);
|
||||||
const visualDepth = Math.min(depth, MAX_DEPTH);
|
const visualDepth = Math.min(depth, MAX_DEPTH);
|
||||||
const isSensitive = node.data?.isSensitive;
|
const isSensitive = node.data?.isSensitive;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ interface MessageComposerProps {
|
||||||
sendWarning?: string | null;
|
sendWarning?: string | null;
|
||||||
sendDebugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null;
|
sendDebugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null;
|
||||||
lastResult?: SendMessageResult | null;
|
lastResult?: SendMessageResult | null;
|
||||||
|
cornerActionPrefix?: React.ReactNode;
|
||||||
/** Ref to the underlying textarea element for external focus management. */
|
/** Ref to the underlying textarea element for external focus management. */
|
||||||
textareaRef?: React.Ref<HTMLTextAreaElement>;
|
textareaRef?: React.Ref<HTMLTextAreaElement>;
|
||||||
onSend: (
|
onSend: (
|
||||||
|
|
@ -112,6 +113,7 @@ export const MessageComposer = ({
|
||||||
sendWarning,
|
sendWarning,
|
||||||
sendDebugDetails,
|
sendDebugDetails,
|
||||||
lastResult,
|
lastResult,
|
||||||
|
cornerActionPrefix,
|
||||||
textareaRef: externalTextareaRef,
|
textareaRef: externalTextareaRef,
|
||||||
onSend,
|
onSend,
|
||||||
onCrossTeamSend,
|
onCrossTeamSend,
|
||||||
|
|
@ -143,6 +145,7 @@ export const MessageComposer = ({
|
||||||
const [recipientOpen, setRecipientOpen] = useState(false);
|
const [recipientOpen, setRecipientOpen] = useState(false);
|
||||||
const [recipientSearch, setRecipientSearch] = useState('');
|
const [recipientSearch, setRecipientSearch] = useState('');
|
||||||
const recipientSearchRef = useRef<HTMLInputElement>(null);
|
const recipientSearchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isTextareaFocused, setIsTextareaFocused] = useState(false);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -642,6 +645,8 @@ export const MessageComposer = ({
|
||||||
},
|
},
|
||||||
[canAttach, draftHandlePaste, showFileRestrictionError, validateSelectedAttachmentFiles]
|
[canAttach, draftHandlePaste, showFileRestrictionError, validateSelectedAttachmentFiles]
|
||||||
);
|
);
|
||||||
|
const handleTextareaFocus = useCallback(() => setIsTextareaFocused(true), []);
|
||||||
|
const handleTextareaBlur = useCallback(() => setIsTextareaFocused(false), []);
|
||||||
|
|
||||||
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
||||||
const hasAttachmentPreviewContent =
|
const hasAttachmentPreviewContent =
|
||||||
|
|
@ -666,6 +671,29 @@ export const MessageComposer = ({
|
||||||
Reused recent cross-team request
|
Reused recent cross-team request
|
||||||
</span>
|
</span>
|
||||||
) : null;
|
) : 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1045,6 +1073,8 @@ export const MessageComposer = ({
|
||||||
commandSuggestions={slashCommandSuggestions}
|
commandSuggestions={slashCommandSuggestions}
|
||||||
chips={draft.chips}
|
chips={draft.chips}
|
||||||
onChipRemove={draft.removeChip}
|
onChipRemove={draft.removeChip}
|
||||||
|
onFocus={handleTextareaFocus}
|
||||||
|
onBlur={handleTextareaBlur}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
onFileChipInsert={draft.addChip}
|
onFileChipInsert={draft.addChip}
|
||||||
onModEnter={handleSend}
|
onModEnter={handleSend}
|
||||||
|
|
@ -1059,7 +1089,7 @@ export const MessageComposer = ({
|
||||||
maxRows={6}
|
maxRows={6}
|
||||||
maxLength={MAX_TEXT_LENGTH}
|
maxLength={MAX_TEXT_LENGTH}
|
||||||
hintText={crossTeamHintText}
|
hintText={crossTeamHintText}
|
||||||
showHint={!isCompactLayout}
|
showHint={!isCompactLayout && isTextareaFocused}
|
||||||
cornerActionInset={isCompactLayout ? 'compact' : 'default'}
|
cornerActionInset={isCompactLayout ? 'compact' : 'default'}
|
||||||
cornerActionLeft={
|
cornerActionLeft={
|
||||||
<ActionModeSelector
|
<ActionModeSelector
|
||||||
|
|
@ -1071,6 +1101,7 @@ export const MessageComposer = ({
|
||||||
}
|
}
|
||||||
cornerAction={
|
cornerAction={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{cornerActionPrefix}
|
||||||
{/* NOTE: ContextRing disabled — usage formula is inaccurate */}
|
{/* NOTE: ContextRing disabled — usage formula is inaccurate */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -1108,27 +1139,7 @@ export const MessageComposer = ({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
footerRight={
|
footerRight={composerFooterRight}
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@ import { Sheet, type SheetRef } from 'react-modal-sheet';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
|
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
|
||||||
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
|
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
|
||||||
|
|
@ -33,7 +39,9 @@ import {
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
Dock,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
MoreHorizontal,
|
||||||
PanelBottom,
|
PanelBottom,
|
||||||
PanelBottomClose,
|
PanelBottomClose,
|
||||||
PanelBottomOpen,
|
PanelBottomOpen,
|
||||||
|
|
@ -807,6 +815,10 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
onPositionChange('bottom-sheet');
|
onPositionChange('bottom-sheet');
|
||||||
}, [onPositionChange]);
|
}, [onPositionChange]);
|
||||||
|
|
||||||
|
const moveToFloatingComposer = useCallback(() => {
|
||||||
|
onPositionChange('floating-composer');
|
||||||
|
}, [onPositionChange]);
|
||||||
|
|
||||||
const snapBottomSheetTo = useCallback((snapIndex: number) => {
|
const snapBottomSheetTo = useCallback((snapIndex: number) => {
|
||||||
setBottomSheetSnapIndex(snapIndex);
|
setBottomSheetSnapIndex(snapIndex);
|
||||||
bottomSheetRef.current?.snapTo(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 = (
|
const compactComposerSection = (
|
||||||
<MessagesComposerSection
|
<MessagesComposerSection
|
||||||
teamName={teamName}
|
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 = (
|
const inlineStatusSection = (
|
||||||
<MessagesStatusSection
|
<MessagesStatusSection
|
||||||
members={members}
|
members={members}
|
||||||
|
|
@ -1054,54 +1131,53 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
<Tooltip>
|
<DropdownMenu>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
variant="ghost"
|
<DropdownMenuTrigger asChild>
|
||||||
size="sm"
|
<Button
|
||||||
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
variant="ghost"
|
||||||
onClick={() => setMessagesCollapsed((v) => !v)}
|
size="sm"
|
||||||
aria-label={messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
|
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"
|
||||||
{messagesCollapsed ? <ChevronsUpDown size={14} /> : <ChevronsDownUp size={14} />}
|
>
|
||||||
</Button>
|
<MoreHorizontal size={15} />
|
||||||
</TooltipTrigger>
|
</Button>
|
||||||
<TooltipContent side="bottom">
|
</DropdownMenuTrigger>
|
||||||
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
|
</TooltipTrigger>
|
||||||
</TooltipContent>
|
<TooltipContent side="bottom">Message actions</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
<DropdownMenuContent align="end" side="bottom" className="w-48">
|
||||||
<TooltipTrigger asChild>
|
<DropdownMenuItem onSelect={() => setMessagesCollapsed((v) => !v)}>
|
||||||
<Button
|
{messagesCollapsed ? (
|
||||||
variant="ghost"
|
<ChevronsUpDown size={14} className="shrink-0" />
|
||||||
size="sm"
|
) : (
|
||||||
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
<ChevronsDownUp size={14} className="shrink-0" />
|
||||||
onClick={() => setMessagesSearchBarVisible((v) => !v)}
|
)}
|
||||||
aria-label={
|
<span>{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}</span>
|
||||||
messagesSearchBarVisible ? 'Hide message search' : 'Show message search'
|
</DropdownMenuItem>
|
||||||
}
|
<DropdownMenuItem onSelect={() => setMessagesSearchBarVisible((v) => !v)}>
|
||||||
>
|
{messagesSearchBarVisible ? (
|
||||||
{messagesSearchBarVisible ? <X size={14} /> : <Search size={14} />}
|
<X size={14} className="shrink-0" />
|
||||||
</Button>
|
) : (
|
||||||
</TooltipTrigger>
|
<Search size={14} className="shrink-0" />
|
||||||
<TooltipContent side="bottom">
|
)}
|
||||||
{messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
|
<span>{messagesSearchBarVisible ? 'Hide search' : 'Search messages'}</span>
|
||||||
</TooltipContent>
|
</DropdownMenuItem>
|
||||||
</Tooltip>
|
<DropdownMenuItem onSelect={moveToInline}>
|
||||||
<Tooltip>
|
<PanelLeftClose size={14} className="shrink-0" />
|
||||||
<TooltipTrigger asChild>
|
<span>Move to inline</span>
|
||||||
<Button
|
</DropdownMenuItem>
|
||||||
variant="ghost"
|
<DropdownMenuItem onSelect={moveToBottomSheet}>
|
||||||
size="sm"
|
<PanelBottomOpen size={14} className="shrink-0" />
|
||||||
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
<span>Move to bottom sheet</span>
|
||||||
onClick={moveToInline}
|
</DropdownMenuItem>
|
||||||
aria-label="Move messages to inline panel"
|
<DropdownMenuItem onSelect={moveToFloatingComposer}>
|
||||||
>
|
<Dock size={14} className="shrink-0" />
|
||||||
<PanelLeftClose size={14} />
|
<span>Float composer</span>
|
||||||
</Button>
|
</DropdownMenuItem>
|
||||||
</TooltipTrigger>
|
</DropdownMenuContent>
|
||||||
<TooltipContent side="bottom">Move to inline</TooltipContent>
|
</DropdownMenu>
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Search & filter bar (toggleable) */}
|
{/* 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 (position === 'bottom-sheet') {
|
||||||
if (!mountPoint) {
|
if (!mountPoint) {
|
||||||
return <div className="hidden" aria-hidden="true" />;
|
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"
|
className="ml-auto flex items-center gap-1"
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{messagesUnreadCount > 0 && (
|
<DropdownMenu>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<DropdownMenuTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="size-[22px] p-0 text-blue-400 hover:bg-blue-500/10 hover:text-blue-300"
|
size="sm"
|
||||||
onClick={handleMarkAllRead}
|
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="Mark all messages as read"
|
aria-label="Message bottom sheet actions"
|
||||||
>
|
>
|
||||||
<CheckCheck size={13} />
|
<MoreHorizontal size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">Mark all as read</TooltipContent>
|
<TooltipContent side="top">Message actions</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
<DropdownMenuContent align="end" side="top" className="w-48">
|
||||||
<Tooltip>
|
{messagesUnreadCount > 0 && (
|
||||||
<TooltipTrigger asChild>
|
<DropdownMenuItem
|
||||||
<Button
|
className="text-blue-400 focus:text-blue-300"
|
||||||
variant="ghost"
|
onSelect={handleMarkAllRead}
|
||||||
size="sm"
|
>
|
||||||
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
<CheckCheck size={14} className="shrink-0" />
|
||||||
onClick={() => setMessagesCollapsed((value) => !value)}
|
<span>Mark all as read</span>
|
||||||
aria-label={
|
</DropdownMenuItem>
|
||||||
messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'
|
)}
|
||||||
}
|
<DropdownMenuItem onSelect={() => setMessagesCollapsed((value) => !value)}>
|
||||||
>
|
|
||||||
{messagesCollapsed ? (
|
{messagesCollapsed ? (
|
||||||
<ChevronsUpDown size={14} />
|
<ChevronsUpDown size={14} className="shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronsDownUp size={14} />
|
<ChevronsDownUp size={14} className="shrink-0" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
<span>
|
||||||
</TooltipTrigger>
|
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
|
||||||
<TooltipContent side="top">
|
</span>
|
||||||
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
|
</DropdownMenuItem>
|
||||||
</TooltipContent>
|
<DropdownMenuItem
|
||||||
</Tooltip>
|
onSelect={() => setMessagesSearchBarVisible((value) => !value)}
|
||||||
<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'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
|
{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 ? (
|
{isBottomSheetCollapsed ? (
|
||||||
<PanelBottomOpen size={14} />
|
<PanelBottomOpen size={14} className="shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<PanelBottomClose size={14} />
|
<PanelBottomClose size={14} className="shrink-0" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
<span>{isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}</span>
|
||||||
</TooltipTrigger>
|
</DropdownMenuItem>
|
||||||
<TooltipContent side="top">
|
<DropdownMenuItem onSelect={moveToInline}>
|
||||||
{isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}
|
<PanelBottom size={14} className="shrink-0" />
|
||||||
</TooltipContent>
|
<span>Move to inline</span>
|
||||||
</Tooltip>
|
</DropdownMenuItem>
|
||||||
<Tooltip>
|
<DropdownMenuItem onSelect={moveToSidebar}>
|
||||||
<TooltipTrigger asChild>
|
<PanelLeft size={14} className="shrink-0" />
|
||||||
<Button
|
<span>Move to sidebar</span>
|
||||||
variant="ghost"
|
</DropdownMenuItem>
|
||||||
size="sm"
|
<DropdownMenuItem onSelect={moveToFloatingComposer}>
|
||||||
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
<Dock size={14} className="shrink-0" />
|
||||||
onClick={moveToInline}
|
<span>Float composer</span>
|
||||||
aria-label="Move messages to inline panel"
|
</DropdownMenuItem>
|
||||||
>
|
</DropdownMenuContent>
|
||||||
<PanelBottom size={14} />
|
</DropdownMenu>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1386,6 +1432,23 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">Move to bottom sheet</TooltipContent>
|
<TooltipContent side="top">Move to bottom sheet</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export interface ComboboxOption {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
disabled?: boolean;
|
||||||
/** Extra data for renderOption (e.g. sessionCount, path). */
|
/** Extra data for renderOption (e.g. sessionCount, path). */
|
||||||
meta?: Record<string, unknown>;
|
meta?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
@ -133,12 +134,20 @@ export const Combobox = ({
|
||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
|
disabled={option.disabled}
|
||||||
|
aria-disabled={option.disabled === true}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
|
if (option.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onValueChange(option.value);
|
onValueChange(option.value);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setSearch('');
|
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 ? (
|
||||||
renderOption(option, isSelected, search)
|
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: 'anthropic', displayName: 'Anthropic' },
|
||||||
{ providerId: 'codex', displayName: 'Codex' },
|
{ providerId: 'codex', displayName: 'Codex' },
|
||||||
{ providerId: 'gemini', displayName: 'Gemini' },
|
{ providerId: 'gemini', displayName: 'Gemini' },
|
||||||
{ providerId: 'opencode', displayName: 'OpenCode (75+ LLM providers)' },
|
{ providerId: 'opencode', displayName: 'OpenCode (200+ models)' },
|
||||||
] as const
|
] as const
|
||||||
).map((provider) => ({
|
).map((provider) => ({
|
||||||
...provider,
|
...provider,
|
||||||
|
|
@ -500,7 +500,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
|
||||||
case 'gemini':
|
case 'gemini':
|
||||||
return 'Gemini';
|
return 'Gemini';
|
||||||
case 'opencode':
|
case 'opencode':
|
||||||
return 'OpenCode (75+ LLM providers)';
|
return 'OpenCode (200+ models)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
isWindowsishPath,
|
isWindowsishPath,
|
||||||
joinPath,
|
joinPath,
|
||||||
lastSeparatorIndex,
|
lastSeparatorIndex,
|
||||||
|
normalizePathForComparison,
|
||||||
splitPath,
|
splitPath,
|
||||||
stripTrailingSeparators,
|
stripTrailingSeparators,
|
||||||
} from '@shared/utils/platformPath';
|
} from '@shared/utils/platformPath';
|
||||||
|
|
@ -42,6 +43,29 @@ function omitKey<V>(record: Record<string, V>, key: string): Record<string, V> {
|
||||||
return result;
|
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.
|
* Cooldown map: filePath → timestamp of last successful save.
|
||||||
*
|
*
|
||||||
|
|
@ -617,7 +641,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
const { editorOpenTabs } = get();
|
const { editorOpenTabs } = get();
|
||||||
|
|
||||||
// Dedup: if file already open, just activate it
|
// 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) {
|
if (existing) {
|
||||||
set({ editorActiveTabId: existing.id });
|
set({ editorActiveTabId: existing.id });
|
||||||
return;
|
return;
|
||||||
|
|
@ -1216,26 +1240,27 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
const { editorOpenTabs, editorProjectPath, editorSaving } = get();
|
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)
|
// 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
|
// Ignore watcher events within cooldown after save
|
||||||
// (covers race: save completes → editorSaving cleared → watcher fires late)
|
// (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;
|
if (lastSaveTime && Date.now() - lastSaveTime < SAVE_COOLDOWN_MS) return;
|
||||||
|
|
||||||
// Ignore watcher events within cooldown after move
|
// 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;
|
if (lastMoveTime && Date.now() - lastMoveTime < MOVE_COOLDOWN_MS) return;
|
||||||
|
|
||||||
// Track changes for open files
|
// Track changes for open files
|
||||||
const isOpenFile = editorOpenTabs.some((t) => t.filePath === event.path);
|
if (openTab || event.type === 'delete') {
|
||||||
if (isOpenFile || event.type === 'delete') {
|
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
editorExternalChanges: {
|
editorExternalChanges: {
|
||||||
...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) => {
|
clearExternalChange: (filePath: string) => {
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
editorExternalChanges: omitKey(s.editorExternalChanges, filePath),
|
editorExternalChanges: omitMatchingPathKey(s.editorExternalChanges, filePath),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* Path resolution utilities for the store.
|
* 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.
|
* 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 {
|
export function resolveFilePath(base: string, relativePath: string): string {
|
||||||
// If already absolute, return as-is
|
// If already absolute, return as-is
|
||||||
if (isAbsolutePath(relativePath)) {
|
if (isAbsoluteOrHomePath(relativePath)) {
|
||||||
return relativePath;
|
return relativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,13 +27,7 @@ export function resolveFilePath(base: string, relativePath: string): string {
|
||||||
cleanRelative = cleanRelative.slice(1);
|
cleanRelative = cleanRelative.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAbsolutePath(cleanRelative)) {
|
if (isAbsoluteOrHomePath(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 === '~') {
|
|
||||||
return cleanRelative;
|
return cleanRelative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,10 +63,6 @@ export function resolveFilePath(base: string, relativePath: string): string {
|
||||||
return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase;
|
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 {
|
function normalizeSeparators(input: string, separator: '/' | '\\'): string {
|
||||||
let output = '';
|
let output = '';
|
||||||
let prevWasSeparator = false;
|
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 {
|
import {
|
||||||
|
isAbsoluteOrHomePath,
|
||||||
isPathPrefix,
|
isPathPrefix,
|
||||||
lastSeparatorIndex,
|
lastSeparatorIndex,
|
||||||
normalizePathForComparison,
|
normalizePathForComparison,
|
||||||
|
|
@ -63,20 +64,6 @@ export function getDisplayName(path: string, _source: ClaudeMdSource): string {
|
||||||
return path;
|
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.
|
* Join paths, handling various path formats properly.
|
||||||
* Handles:
|
* Handles:
|
||||||
|
|
@ -87,7 +74,7 @@ function isAbsolutePath(path: string): boolean {
|
||||||
* - Paths with @ prefix: @apps/foo/bar.tsx (strips @ then joins)
|
* - Paths with @ prefix: @apps/foo/bar.tsx (strips @ then joins)
|
||||||
*/
|
*/
|
||||||
function joinPaths(base: string, relative: string): string {
|
function joinPaths(base: string, relative: string): string {
|
||||||
if (isAbsolutePath(relative)) {
|
if (isAbsoluteOrHomePath(relative)) {
|
||||||
return relative;
|
return relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,7 +86,7 @@ function joinPaths(base: string, relative: string): string {
|
||||||
if (cleanRelative.startsWith('@')) {
|
if (cleanRelative.startsWith('@')) {
|
||||||
cleanRelative = cleanRelative.slice(1);
|
cleanRelative = cleanRelative.slice(1);
|
||||||
}
|
}
|
||||||
if (isAbsolutePath(cleanRelative)) {
|
if (isAbsoluteOrHomePath(cleanRelative)) {
|
||||||
return cleanRelative;
|
return cleanRelative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,7 +226,9 @@ export function extractUserMentionPaths(
|
||||||
for (const ref of fileReferences) {
|
for (const ref of fileReferences) {
|
||||||
if (ref.path) {
|
if (ref.path) {
|
||||||
// Convert to absolute if relative
|
// 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);
|
paths.push(absolutePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -525,7 +514,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
|
||||||
const responseRefs = extractFileRefsFromResponses(aiGroup.responses);
|
const responseRefs = extractFileRefsFromResponses(aiGroup.responses);
|
||||||
for (const ref of responseRefs) {
|
for (const ref of responseRefs) {
|
||||||
if (ref.path) {
|
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);
|
allFilePaths.push(absPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,11 @@
|
||||||
* This builds on claudeMdTracker.ts and extends it to track all context sources.
|
* 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 { estimateTokens } from '@shared/utils/tokenFormatting';
|
||||||
|
|
||||||
import { MAX_MENTIONED_FILE_TOKENS } from '../types/contextInjection';
|
import { MAX_MENTIONED_FILE_TOKENS } from '../types/contextInjection';
|
||||||
|
|
@ -447,20 +451,6 @@ interface ComputeContextStatsParams {
|
||||||
directoryTokenData?: Record<string, ClaudeMdFileInfo>;
|
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.
|
* Helper to join paths, handling various path formats properly.
|
||||||
* Handles:
|
* Handles:
|
||||||
|
|
@ -471,7 +461,7 @@ function isAbsolutePath(path: string): boolean {
|
||||||
* - Paths with @ prefix: @apps/foo/bar.tsx (strips @ then joins)
|
* - Paths with @ prefix: @apps/foo/bar.tsx (strips @ then joins)
|
||||||
*/
|
*/
|
||||||
function joinPaths(base: string, relative: string): string {
|
function joinPaths(base: string, relative: string): string {
|
||||||
if (isAbsolutePath(relative)) {
|
if (isAbsoluteOrHomePath(relative)) {
|
||||||
return relative;
|
return relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -482,7 +472,7 @@ function joinPaths(base: string, relative: string): string {
|
||||||
if (cleanRelative.startsWith('@')) {
|
if (cleanRelative.startsWith('@')) {
|
||||||
cleanRelative = cleanRelative.slice(1);
|
cleanRelative = cleanRelative.slice(1);
|
||||||
}
|
}
|
||||||
if (isAbsolutePath(cleanRelative)) {
|
if (isAbsoluteOrHomePath(cleanRelative)) {
|
||||||
return cleanRelative;
|
return cleanRelative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -679,7 +669,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
|
||||||
const responseRefs = extractFileRefsFromResponses(aiGroup.responses);
|
const responseRefs = extractFileRefsFromResponses(aiGroup.responses);
|
||||||
for (const ref of responseRefs) {
|
for (const ref of responseRefs) {
|
||||||
if (ref.path) {
|
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);
|
allFilePaths.push(absPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -735,7 +725,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
|
||||||
if (!fileRef.path) continue;
|
if (!fileRef.path) continue;
|
||||||
|
|
||||||
// Convert to absolute path if needed
|
// Convert to absolute path if needed
|
||||||
const absolutePath = isAbsolutePath(fileRef.path)
|
const absolutePath = isAbsoluteOrHomePath(fileRef.path)
|
||||||
? fileRef.path
|
? fileRef.path
|
||||||
: joinPaths(projectRoot, fileRef.path);
|
: joinPaths(projectRoot, fileRef.path);
|
||||||
|
|
||||||
|
|
@ -768,7 +758,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
|
||||||
for (const fileRef of responseRefs) {
|
for (const fileRef of responseRefs) {
|
||||||
if (!fileRef.path) continue;
|
if (!fileRef.path) continue;
|
||||||
|
|
||||||
const absolutePath = isAbsolutePath(fileRef.path)
|
const absolutePath = isAbsoluteOrHomePath(fileRef.path)
|
||||||
? fileRef.path
|
? fileRef.path
|
||||||
: joinPaths(projectRoot, fileRef.path);
|
: joinPaths(projectRoot, fileRef.path);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,18 @@ export function isWindowsishPath(filePath: string): boolean {
|
||||||
return /^[A-Za-z]:\//.test(p) || p.startsWith('//');
|
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:
|
* Normalize for comparisons:
|
||||||
* - Convert `\` → `/`
|
* - 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', () => {
|
describe('MemberWorkSyncNudgeActivationPolicy', () => {
|
||||||
it('activates OpenCode targeted nudges while shadow data is still collecting', () => {
|
it('activates OpenCode targeted nudges while shadow data is still collecting', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -348,6 +404,313 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => {
|
||||||
).toEqual({ active: false, reason: 'blocking_metrics' });
|
).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', () => {
|
it('keeps existing shadow_ready behavior for all providers', () => {
|
||||||
expect(
|
expect(
|
||||||
decideMemberWorkSyncNudgeActivation({
|
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> {
|
async function waitForAssertion(assertion: () => Promise<void> | void): Promise<void> {
|
||||||
const deadline = Date.now() + 5_000;
|
const deadline = Date.now() + 5_000;
|
||||||
let lastError: unknown;
|
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 () => {
|
it('delivers targeted OpenCode nudges even when global phase2 metrics are noisy', async () => {
|
||||||
const claudeRoot = makeTempRoot();
|
const claudeRoot = makeTempRoot();
|
||||||
setClaudeBasePathOverride(claudeRoot);
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
|
|
||||||
|
|
@ -96,4 +96,40 @@ describe('mergeRecentProjectCandidates', () => {
|
||||||
expect(result[0].identity).toBe('repo:beta');
|
expect(result[0].identity).toBe('repo:beta');
|
||||||
expect(result[0].branchName).toBeUndefined();
|
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');
|
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 () => {
|
it('loads Codex projects from large session metadata lines without parsing the full line', async () => {
|
||||||
const codexHome = path.join(tempDir, '.codex');
|
const codexHome = path.join(tempDir, '.codex');
|
||||||
const logger = createLogger();
|
const logger = createLogger();
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ describe('adaptRecentProjectsSection', () => {
|
||||||
worktreeId: 'wt-alpha',
|
worktreeId: 'wt-alpha',
|
||||||
},
|
},
|
||||||
primaryBranch: 'main',
|
primaryBranch: 'main',
|
||||||
|
filesystemState: 'deleted',
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeTeam: TeamSummary = {
|
const activeTeam: TeamSummary = {
|
||||||
|
|
@ -52,11 +53,11 @@ describe('adaptRecentProjectsSection', () => {
|
||||||
taskCounts: { pending: 5, inProgress: 7, completed: 9 },
|
taskCounts: { pending: 5, inProgress: 7, completed: 9 },
|
||||||
additionalPathCount: 1,
|
additionalPathCount: 1,
|
||||||
primaryBranch: 'main',
|
primaryBranch: 'main',
|
||||||
|
filesystemState: 'deleted',
|
||||||
activeTeams: [activeTeam],
|
activeTeams: [activeTeam],
|
||||||
pathSummary: {
|
pathSummary: {
|
||||||
badgeLabel: '2 paths',
|
badgeLabel: '2 paths',
|
||||||
description:
|
description: 'This card merges recent activity from related worktrees and project paths.',
|
||||||
'This card merges recent activity from related worktrees and project paths.',
|
|
||||||
paths: [
|
paths: [
|
||||||
{
|
{
|
||||||
label: 'Primary path',
|
label: 'Primary path',
|
||||||
|
|
|
||||||
|
|
@ -153,4 +153,26 @@ describe('ProjectScanner cwd split logic', () => {
|
||||||
expect(worktree?.isMainWorktree).toBe(false);
|
expect(worktree?.isMainWorktree).toBe(false);
|
||||||
expect(worktree?.source).toBe('claude-desktop');
|
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',
|
'opencode',
|
||||||
]);
|
]);
|
||||||
expect(openCodeStatus).toMatchObject({
|
expect(openCodeStatus).toMatchObject({
|
||||||
displayName: 'OpenCode (75+ LLM providers)',
|
displayName: 'OpenCode (200+ models)',
|
||||||
supported: false,
|
supported: false,
|
||||||
statusMessage: 'Runtime not found.',
|
statusMessage: 'Runtime not found.',
|
||||||
canLoginFromUi: false,
|
canLoginFromUi: false,
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
});
|
});
|
||||||
expect(providers[3]).toMatchObject({
|
expect(providers[3]).toMatchObject({
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
displayName: 'OpenCode (75+ LLM providers)',
|
displayName: 'OpenCode (200+ models)',
|
||||||
supported: false,
|
supported: false,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
models: [],
|
models: [],
|
||||||
|
|
|
||||||
|
|
@ -450,7 +450,7 @@ describe('CLI status visibility during completed install state', () => {
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
displayName: 'OpenCode (75+ LLM providers)',
|
displayName: 'OpenCode (200+ models)',
|
||||||
supported: false,
|
supported: false,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
|
|
@ -476,7 +476,7 @@ describe('CLI status visibility during completed install state', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(host.textContent).toContain('OpenCode (75+ LLM providers)');
|
expect(host.textContent).toContain('OpenCode (200+ models)');
|
||||||
expect(host.textContent).toContain('Install');
|
expect(host.textContent).toContain('Install');
|
||||||
|
|
||||||
const installButton = Array.from(host.querySelectorAll('button')).find(
|
const installButton = Array.from(host.querySelectorAll('button')).find(
|
||||||
|
|
@ -523,7 +523,7 @@ describe('CLI status visibility during completed install state', () => {
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
displayName: 'OpenCode (75+ LLM providers)',
|
displayName: 'OpenCode (200+ models)',
|
||||||
supported: false,
|
supported: false,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
|
|
@ -575,7 +575,7 @@ describe('CLI status visibility during completed install state', () => {
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
displayName: 'OpenCode (75+ LLM providers)',
|
displayName: 'OpenCode (200+ models)',
|
||||||
supported: true,
|
supported: true,
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
authMethod: 'opencode_managed',
|
authMethod: 'opencode_managed',
|
||||||
|
|
|
||||||
|
|
@ -559,7 +559,7 @@ describe('SkillsPanel', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(host.textContent).toContain(
|
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');
|
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('model:claude-opus-4-6');
|
||||||
expect(host.textContent).toContain('effort:max');
|
expect(host.textContent).toContain('effort:max');
|
||||||
expect(host.textContent).toContain('fast:on');
|
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(
|
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
||||||
(button) => button.textContent === 'Save Changes'
|
(button) => button.textContent === 'Save Changes'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
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';
|
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');
|
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', () => {
|
it('detects language from file extension', () => {
|
||||||
store.getState().openFile('/project/data.json');
|
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', () => {
|
describe('closeEditor resets all state including Group 2+3', () => {
|
||||||
it('resets tabs, dirty, saving, errors', () => {
|
it('resets tabs, dirty, saving, errors', () => {
|
||||||
store.setState({
|
store.setState({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
describe('platformPath Windows containment', () => {
|
||||||
it('matches Windows drive paths case-insensitively and preserves child path style', () => {
|
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(isPathPrefix('', '/Users/Alice/Repo/src/app.ts')).toBe(false);
|
||||||
expect(getRelativePathWithinPrefix('', '/Users/Alice/Repo/src/app.ts')).toBe(null);
|
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