feat: improve desktop workflows
This commit is contained in:
parent
4cb1b6ef5f
commit
2beb4dae96
49 changed files with 900 additions and 239 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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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,4 +1,5 @@
|
||||||
import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
|
import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
|
||||||
|
import { resolveProjectFilesystemState } from '@features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState';
|
||||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
import { 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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
|
import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
|
||||||
|
import { resolveProjectFilesystemState } from '@features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState';
|
||||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||||
|
|
||||||
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort';
|
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort';
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,11 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
||||||
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
||||||
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
|
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
|
||||||
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
|
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
|
||||||
|
const useHttpMcpBridge = bridgeEnv.CLAUDE_TEAM_OPENCODE_MCP_HTTP === '1';
|
||||||
|
if (!useHttpMcpBridge) {
|
||||||
|
// The OpenCode bridge direct tools/list proof currently requires a local MCP command.
|
||||||
|
delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL;
|
||||||
|
}
|
||||||
const applyMcpLaunchSpecEnv = async (
|
const applyMcpLaunchSpecEnv = async (
|
||||||
targetEnv: NodeJS.ProcessEnv,
|
targetEnv: NodeJS.ProcessEnv,
|
||||||
options: { emitProgress?: boolean } = {}
|
options: { emitProgress?: boolean } = {}
|
||||||
|
|
@ -408,17 +413,19 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
if (useHttpMcpBridge) {
|
||||||
reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
|
try {
|
||||||
const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
|
reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
|
||||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
|
const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
|
||||||
reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
|
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
|
||||||
} catch (error) {
|
reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
|
||||||
logger.warn(
|
} catch (error) {
|
||||||
`[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${
|
logger.warn(
|
||||||
error instanceof Error ? error.message : String(error)
|
`[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${
|
||||||
}`
|
error instanceof Error ? error.message : String(error)
|
||||||
);
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
|
if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
|
||||||
await applyMcpLaunchSpecEnv(bridgeEnv, { emitProgress: true });
|
await applyMcpLaunchSpecEnv(bridgeEnv, { emitProgress: true });
|
||||||
|
|
@ -427,7 +434,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
||||||
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
|
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
|
||||||
const resolveBridgeCommandEnv = async (): Promise<NodeJS.ProcessEnv> => {
|
const resolveBridgeCommandEnv = async (): Promise<NodeJS.ProcessEnv> => {
|
||||||
const nextEnv = { ...bridgeEnv };
|
const nextEnv = { ...bridgeEnv };
|
||||||
if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
|
if (!useHttpMcpBridge || !bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
|
||||||
return nextEnv;
|
return nextEnv;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -671,6 +671,23 @@ export const CreateTeamDialog = ({
|
||||||
);
|
);
|
||||||
const lastPrepareRequestSignatureRef = useRef<string | null>(null);
|
const lastPrepareRequestSignatureRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const generation = ++prepareUnmountGenerationRef.current;
|
||||||
|
return () => {
|
||||||
|
// React StrictMode replays effect cleanup/setup in development; defer
|
||||||
|
// invalidation so the replay does not cancel the live prepare request.
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!isCurrentPrepareGeneration(prepareUnmountGenerationRef, generation)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelScheduledIdle(prepareIdleHandleRef.current);
|
||||||
|
prepareIdleHandleRef.current = null;
|
||||||
|
prepareRequestSeqRef.current += 1;
|
||||||
|
lastPrepareRequestSignatureRef.current = null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider;
|
runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider;
|
||||||
}, [runtimeBackendSummaryByProvider]);
|
}, [runtimeBackendSummaryByProvider]);
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ExternalLink,
|
||||||
Info,
|
Info,
|
||||||
Loader2,
|
Loader2,
|
||||||
X,
|
X,
|
||||||
|
|
@ -230,6 +231,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
|
||||||
|
|
@ -2730,6 +2733,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 {
|
||||||
discoverySource?: DashboardRecentProjectSource;
|
discoverySource?: DashboardRecentProjectSource;
|
||||||
|
filesystemState?: DashboardRecentProjectFilesystemState;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toProjectOption(project: ProjectPathProject): ComboboxOption {
|
function toProjectOption(project: ProjectPathProject): ComboboxOption {
|
||||||
|
|
@ -20,10 +25,19 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet';
|
export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet' | 'floating-composer';
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -88,4 +88,27 @@ describe('buildProjectPathOptions', () => {
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks deleted project paths as disabled options', () => {
|
||||||
|
const options = buildProjectPathOptions([
|
||||||
|
createProject({
|
||||||
|
id: 'project-deleted',
|
||||||
|
name: 'my-tes',
|
||||||
|
path: '/Users/belief/dev/projects/my-tes',
|
||||||
|
filesystemState: 'deleted',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(options).toEqual([
|
||||||
|
{
|
||||||
|
value: '/Users/belief/dev/projects/my-tes',
|
||||||
|
label: 'my-tes',
|
||||||
|
description: '/Users/belief/dev/projects/my-tes',
|
||||||
|
disabled: true,
|
||||||
|
meta: {
|
||||||
|
filesystemState: 'deleted',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue