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 align="center">
|
||||
<sub>Free desktop app for AI agent teams. Auto-detects Claude/Codex/OpenCode (75+ LLM providers). Use the provider access you already have - subscriptions or API keys. Not just coding agents.</sub>
|
||||
<sub>Free desktop app for AI agent teams. Auto-detects Claude/Codex/OpenCode (200+ models). Use the provider access you already have - subscriptions or API keys. Not just coding agents.</sub>
|
||||
</p>
|
||||
|
||||
<img width="1304" height="820" alt="image" src="https://github.com/user-attachments/assets/dea53a01-68b3-4c36-bcf6-e4d1ad4cdb31" />
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@
|
|||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
|
|
|
|||
|
|
@ -126,6 +126,9 @@ importers:
|
|||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-dropdown-menu':
|
||||
specifier: 2.1.16
|
||||
version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-hover-card':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -3191,6 +3194,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dropdown-menu@2.1.16':
|
||||
resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.3':
|
||||
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
|
||||
peerDependencies:
|
||||
|
|
@ -13999,6 +14015,21 @@ snapshots:
|
|||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import { type ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { MessagesPanel } from '@renderer/components/team/messages/MessagesPanel';
|
||||
import {
|
||||
getTeamPendingRepliesState,
|
||||
setTeamPendingRepliesState,
|
||||
} from '@renderer/components/team/sidebar/teamSidebarUiState';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
interface UseGraphMessagesPanelInput {
|
||||
teamName: string;
|
||||
enabled?: boolean;
|
||||
mountPoint?: Element | null;
|
||||
onOpenMemberProfile: (memberName: string) => void;
|
||||
onOpenTaskDetail: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export function useGraphMessagesPanel({
|
||||
teamName,
|
||||
enabled = true,
|
||||
mountPoint,
|
||||
onOpenMemberProfile,
|
||||
onOpenTaskDetail,
|
||||
}: UseGraphMessagesPanelInput): ReactElement {
|
||||
const [pendingRepliesByMember, setPendingRepliesByMember] = useState(() =>
|
||||
getTeamPendingRepliesState(teamName)
|
||||
);
|
||||
const { messagesPanelMode, setMessagesPanelMode, members, tasks, isTeamAlive } = useStore(
|
||||
useShallow((state) => {
|
||||
const teamData = selectTeamDataForName(state, teamName);
|
||||
return {
|
||||
messagesPanelMode: state.messagesPanelMode,
|
||||
setMessagesPanelMode: state.setMessagesPanelMode,
|
||||
members: selectResolvedMembersForTeamName(state, teamName),
|
||||
tasks: teamData?.tasks ?? [],
|
||||
isTeamAlive: teamData?.isAlive,
|
||||
};
|
||||
})
|
||||
);
|
||||
const activeMembers = useMemo(() => members.filter((member) => !member.removedAt), [members]);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingRepliesByMember(getTeamPendingRepliesState(teamName));
|
||||
}, [teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
setTeamPendingRepliesState(teamName, pendingRepliesByMember);
|
||||
}, [pendingRepliesByMember, teamName]);
|
||||
|
||||
const handlePendingReplyChange = useCallback(
|
||||
(updater: (prev: Record<string, number>) => Record<string, number>) => {
|
||||
setPendingRepliesByMember(updater);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (
|
||||
!enabled ||
|
||||
(messagesPanelMode !== 'floating-composer' && messagesPanelMode !== 'bottom-sheet')
|
||||
) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<MessagesPanel
|
||||
teamName={teamName}
|
||||
position={messagesPanelMode}
|
||||
onPositionChange={setMessagesPanelMode}
|
||||
mountPoint={messagesPanelMode === 'bottom-sheet' ? mountPoint : undefined}
|
||||
members={activeMembers}
|
||||
tasks={tasks}
|
||||
isTeamAlive={isTeamAlive}
|
||||
timeWindow={null}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
onPendingReplyChange={handlePendingReplyChange}
|
||||
onMemberClick={(member) => onOpenMemberProfile(member.name)}
|
||||
onTaskClick={(task) => onOpenTaskDetail(task.id)}
|
||||
onTaskIdClick={onOpenTaskDetail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,12 +3,13 @@
|
|||
* Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50).
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { GraphView } from '@claude-teams/agent-graph';
|
||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||
|
||||
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||
import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel';
|
||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
||||
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
||||
|
|
@ -36,6 +37,7 @@ export interface TeamGraphOverlayProps {
|
|||
onPinAsTab?: () => void;
|
||||
sidebarVisible?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
messagesPanelEnabled?: boolean;
|
||||
onSendMessage?: (memberName: string) => void;
|
||||
onOpenTaskDetail?: (taskId: string) => void;
|
||||
onOpenMemberProfile?: (
|
||||
|
|
@ -53,6 +55,7 @@ export const TeamGraphOverlay = ({
|
|||
onPinAsTab,
|
||||
sidebarVisible,
|
||||
onToggleSidebar,
|
||||
messagesPanelEnabled = true,
|
||||
onSendMessage,
|
||||
onOpenTaskDetail,
|
||||
onOpenMemberProfile,
|
||||
|
|
@ -67,8 +70,18 @@ export const TeamGraphOverlay = ({
|
|||
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
|
||||
useGraphSidebarVisibility();
|
||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||
const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState<HTMLDivElement | null>(
|
||||
null
|
||||
);
|
||||
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
|
||||
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
|
||||
const graphMessagesPanel = useGraphMessagesPanel({
|
||||
teamName,
|
||||
enabled: messagesPanelEnabled,
|
||||
mountPoint: messagesPanelMountPoint,
|
||||
onOpenMemberProfile: (memberName) => onOpenMemberProfile?.(memberName),
|
||||
onOpenTaskDetail: (taskId) => onOpenTaskDetail?.(taskId),
|
||||
});
|
||||
|
||||
// Task action dispatchers (same pattern as TeamGraphTab)
|
||||
const dispatchTaskAction = useCallback(
|
||||
|
|
@ -242,6 +255,13 @@ export const TeamGraphOverlay = ({
|
|||
/>
|
||||
)}
|
||||
/>
|
||||
{messagesPanelEnabled ? (
|
||||
<div
|
||||
ref={setMessagesPanelMountPoint}
|
||||
className="pointer-events-none absolute inset-0 z-30"
|
||||
/>
|
||||
) : null}
|
||||
{graphMessagesPanel}
|
||||
{createTaskDialog}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { GraphView } from '@claude-teams/agent-graph';
|
|||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||
|
||||
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||
import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel';
|
||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
||||
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
||||
|
|
@ -54,6 +55,9 @@ export const TeamGraphTab = ({
|
|||
const { openTeamPage, commitOwnerSlotDrop, commitOwnerGridOrderDrop, setLayoutMode } =
|
||||
useTeamGraphSurfaceActions(teamName);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState<HTMLDivElement | null>(
|
||||
null
|
||||
);
|
||||
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||
|
||||
|
|
@ -129,9 +133,16 @@ export const TeamGraphTab = ({
|
|||
[dispatchOpenProfile]
|
||||
),
|
||||
};
|
||||
const graphMessagesPanel = useGraphMessagesPanel({
|
||||
teamName,
|
||||
enabled: isActive && isPaneFocused && !fullscreen,
|
||||
mountPoint: messagesPanelMountPoint,
|
||||
onOpenMemberProfile: dispatchOpenProfile,
|
||||
onOpenTaskDetail: dispatchOpenTask,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex size-full overflow-hidden" style={{ background: '#050510' }}>
|
||||
<div className="relative flex size-full overflow-hidden" style={{ background: '#050510' }}>
|
||||
{sidebarVisible ? (
|
||||
<TeamSidebarHost
|
||||
teamName={teamName}
|
||||
|
|
@ -262,6 +273,13 @@ export const TeamGraphTab = ({
|
|||
)}
|
||||
/>
|
||||
</div>
|
||||
{isActive && isPaneFocused && !fullscreen ? (
|
||||
<div
|
||||
ref={setMessagesPanelMountPoint}
|
||||
className="pointer-events-none absolute inset-0 z-30"
|
||||
/>
|
||||
) : null}
|
||||
{graphMessagesPanel}
|
||||
{createTaskDialog}
|
||||
{fullscreen && (
|
||||
<Suspense fallback={null}>
|
||||
|
|
@ -273,6 +291,7 @@ export const TeamGraphTab = ({
|
|||
onSendMessage={dispatchSendMessage}
|
||||
onOpenTaskDetail={dispatchOpenTask}
|
||||
onOpenMemberProfile={dispatchOpenProfile}
|
||||
messagesPanelEnabled={isActive && isPaneFocused}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ export type DashboardProviderId = 'anthropic' | 'codex' | 'gemini';
|
|||
|
||||
export type DashboardRecentProjectSource = 'claude' | 'codex' | 'mixed';
|
||||
|
||||
export type DashboardRecentProjectFilesystemState = 'available' | 'deleted';
|
||||
|
||||
export type DashboardRecentProjectOpenTarget =
|
||||
| { type: 'existing-worktree'; repositoryId: string; worktreeId: string }
|
||||
| { type: 'synthetic-path'; path: string };
|
||||
|
|
@ -16,6 +18,7 @@ export interface DashboardRecentProject {
|
|||
source: DashboardRecentProjectSource;
|
||||
openTarget: DashboardRecentProjectOpenTarget;
|
||||
primaryBranch?: string;
|
||||
filesystemState?: DashboardRecentProjectFilesystemState;
|
||||
}
|
||||
|
||||
export interface DashboardRecentProjectsPayload {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ProviderId } from './ProviderId';
|
||||
import type { RecentProjectFilesystemState } from './RecentProjectFilesystemState';
|
||||
import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget';
|
||||
|
||||
export interface RecentProjectAggregate {
|
||||
|
|
@ -11,4 +12,5 @@ export interface RecentProjectAggregate {
|
|||
source: 'claude' | 'codex' | 'mixed';
|
||||
openTarget: RecentProjectOpenTarget;
|
||||
branchName?: string;
|
||||
filesystemState: RecentProjectFilesystemState;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ProviderId } from './ProviderId';
|
||||
import type { RecentProjectFilesystemState } from './RecentProjectFilesystemState';
|
||||
import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget';
|
||||
|
||||
export interface RecentProjectCandidate {
|
||||
|
|
@ -11,4 +12,5 @@ export interface RecentProjectCandidate {
|
|||
sourceKind: 'claude' | 'codex';
|
||||
openTarget: RecentProjectOpenTarget;
|
||||
branchName?: string;
|
||||
filesystemState?: RecentProjectFilesystemState;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export type RecentProjectFilesystemState = 'available' | 'deleted';
|
||||
|
|
@ -25,10 +25,14 @@ function uniqueProviders(providerIds: readonly ProviderId[]): ProviderId[] {
|
|||
function selectPreferredCandidate(
|
||||
candidates: readonly RecentProjectCandidate[]
|
||||
): RecentProjectCandidate {
|
||||
const existingWorktreeCandidates = candidates.filter(
|
||||
const availableCandidates = candidates.filter(
|
||||
(candidate) => candidate.filesystemState !== 'deleted'
|
||||
);
|
||||
const candidatePool = availableCandidates.length > 0 ? availableCandidates : candidates;
|
||||
const existingWorktreeCandidates = candidatePool.filter(
|
||||
(candidate) => candidate.openTarget.type === 'existing-worktree'
|
||||
);
|
||||
const pool = existingWorktreeCandidates.length > 0 ? existingWorktreeCandidates : candidates;
|
||||
const pool = existingWorktreeCandidates.length > 0 ? existingWorktreeCandidates : candidatePool;
|
||||
|
||||
return [...pool].sort((left, right) => {
|
||||
if (right.lastActivityAt !== left.lastActivityAt) {
|
||||
|
|
@ -81,6 +85,7 @@ export function mergeRecentProjectCandidates(
|
|||
source: sourceKinds.size > 1 ? 'mixed' : sourceKinds.has('codex') ? 'codex' : 'claude',
|
||||
openTarget: preferred.openTarget,
|
||||
branchName: mergeBranchName(group),
|
||||
filesystemState: preferred.filesystemState ?? 'available',
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export class DashboardRecentProjectsPresenter implements ListDashboardRecentProj
|
|||
source: aggregate.source,
|
||||
openTarget: aggregate.openTarget,
|
||||
primaryBranch: aggregate.branchName,
|
||||
filesystemState: aggregate.filesystemState,
|
||||
})
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ function toCandidate(repo: RepositoryGroup): RecentProjectCandidate | null {
|
|||
worktreeId: preferredWorktree.id,
|
||||
},
|
||||
branchName: preferredWorktree.gitBranch,
|
||||
filesystemState: preferredWorktree.filesystemState ?? 'available',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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 path from 'path';
|
||||
|
||||
|
|
@ -129,7 +130,9 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
);
|
||||
|
||||
const candidates = (
|
||||
await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread)))
|
||||
await Promise.all(
|
||||
interactiveThreads.map((thread) => this.#toCandidate(thread, activeContext.fsProvider))
|
||||
)
|
||||
).filter((candidate): candidate is RecentProjectCandidate => candidate !== null);
|
||||
|
||||
if (!degraded) {
|
||||
|
|
@ -299,7 +302,10 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
}
|
||||
}
|
||||
|
||||
async #toCandidate(thread: CodexThreadSummary): Promise<RecentProjectCandidate | null> {
|
||||
async #toCandidate(
|
||||
thread: CodexThreadSummary,
|
||||
fsProvider?: ServiceContext['fsProvider']
|
||||
): Promise<RecentProjectCandidate | null> {
|
||||
const cwd = thread.cwd?.trim();
|
||||
if (!cwd || isEphemeralProjectPath(cwd)) {
|
||||
return null;
|
||||
|
|
@ -321,6 +327,7 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
path: cwd,
|
||||
},
|
||||
branchName: thread.gitInfo?.branch ?? undefined,
|
||||
filesystemState: await resolveProjectFilesystemState(cwd, fsProvider),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import os from 'node:os';
|
|||
import path from 'node:path';
|
||||
|
||||
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 type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort';
|
||||
|
|
@ -225,7 +226,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
try {
|
||||
const snapshots = await this.#listRecentSessionSnapshots();
|
||||
const candidates = await Promise.all(
|
||||
snapshots.map((snapshot) => this.#toCandidate(snapshot))
|
||||
snapshots.map((snapshot) => this.#toCandidate(snapshot, activeContext.fsProvider))
|
||||
);
|
||||
|
||||
const validCandidates = candidates.filter(
|
||||
|
|
@ -303,7 +304,8 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
}
|
||||
|
||||
async #toCandidate(
|
||||
snapshot: CodexSessionProjectSnapshot
|
||||
snapshot: CodexSessionProjectSnapshot,
|
||||
fsProvider?: ServiceContext['fsProvider']
|
||||
): Promise<RecentProjectCandidate | null> {
|
||||
const identity = await this.deps.identityResolver.resolve(snapshot.cwd);
|
||||
const displayName = identity?.name ?? path.basename(snapshot.cwd) ?? snapshot.cwd;
|
||||
|
|
@ -321,6 +323,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
path: snapshot.cwd,
|
||||
},
|
||||
branchName: snapshot.branchName,
|
||||
filesystemState: await resolveProjectFilesystemState(snapshot.cwd, fsProvider),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import type { RecentProjectFilesystemState } from '../../../core/domain/models/RecentProjectFilesystemState';
|
||||
import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider';
|
||||
|
||||
export async function resolveProjectFilesystemState(
|
||||
projectPath: string,
|
||||
fsProvider?: Pick<FileSystemProvider, 'exists'>
|
||||
): Promise<RecentProjectFilesystemState> {
|
||||
if (!projectPath.trim()) {
|
||||
return 'deleted';
|
||||
}
|
||||
|
||||
if (!fsProvider) {
|
||||
return 'available';
|
||||
}
|
||||
|
||||
try {
|
||||
return (await fsProvider.exists(projectPath)) ? 'available' : 'deleted';
|
||||
} catch {
|
||||
return 'deleted';
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ export interface RecentProjectCardModel {
|
|||
lastActivityLabel: string;
|
||||
providerIds: DashboardRecentProject['providerIds'];
|
||||
primaryBranch?: string;
|
||||
filesystemState?: DashboardRecentProject['filesystemState'];
|
||||
taskCounts?: TaskStatusCounts;
|
||||
tasksLoading: boolean;
|
||||
activeTeams?: TeamSummary[];
|
||||
|
|
@ -121,6 +122,7 @@ export function adaptRecentProjectsSection({
|
|||
}),
|
||||
providerIds: sortDashboardProviderIds(project.providerIds),
|
||||
primaryBranch: project.primaryBranch,
|
||||
filesystemState: project.filesystemState,
|
||||
taskCounts: sumTaskCounts(project, taskCountsByProject),
|
||||
tasksLoading,
|
||||
activeTeams: collectActiveTeams(project, activeTeamsByProject),
|
||||
|
|
|
|||
|
|
@ -106,6 +106,11 @@ export function useOpenRecentProject(): {
|
|||
|
||||
const openRecentProject = useCallback(
|
||||
async (project: DashboardRecentProject): Promise<void> => {
|
||||
if (project.filesystemState === 'deleted') {
|
||||
logger.warn('Skipped deleted recent project path', { path: project.primaryPath });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await openTarget(project.openTarget, project.associatedPaths);
|
||||
recordRecentProjectOpenPaths([project.primaryPath, ...project.associatedPaths]);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import { useMemo } from 'react';
|
|||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { projectColor } from '@renderer/utils/projectColor';
|
||||
import { FolderGit2, FolderOpen, GitBranch, Terminal } from 'lucide-react';
|
||||
import { FolderGit2, FolderOpen, FolderX, GitBranch, Terminal } from 'lucide-react';
|
||||
|
||||
import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter';
|
||||
|
||||
|
|
@ -20,11 +21,17 @@ export const RecentProjectCard = ({
|
|||
onOpenPath,
|
||||
}: Readonly<RecentProjectCardProps>): React.JSX.Element => {
|
||||
const color = useMemo(() => projectColor(card.name), [card.name]);
|
||||
const isDeleted = card.filesystemState === 'deleted';
|
||||
const FolderIcon = isDeleted ? FolderX : FolderGit2;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="project-row-zebra-card group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis"
|
||||
onClick={isDeleted ? undefined : onClick}
|
||||
aria-disabled={isDeleted}
|
||||
className={cn(
|
||||
'project-row-zebra-card group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis',
|
||||
isDeleted && 'cursor-default border-red-500/25 bg-red-500/[0.03] hover:border-red-500/35'
|
||||
)}
|
||||
>
|
||||
{card.activeTeams && card.activeTeams.length > 0 && (
|
||||
<ActivePulseIndicator className="absolute right-3 top-3" />
|
||||
|
|
@ -32,9 +39,9 @@ export const RecentProjectCard = ({
|
|||
|
||||
<div className="mb-1 flex items-center gap-2.5">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
|
||||
<FolderGit2
|
||||
<FolderIcon
|
||||
className="size-4 transition-colors group-hover:text-text"
|
||||
style={{ color: color.icon }}
|
||||
style={{ color: isDeleted ? 'var(--field-error-text)' : color.icon }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
|
@ -42,6 +49,16 @@ export const RecentProjectCard = ({
|
|||
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
|
||||
{card.name}
|
||||
</h3>
|
||||
{isDeleted && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex shrink-0 items-center rounded-full border border-red-500/30 bg-red-500/10 px-1.5 py-0.5 text-[9px] font-medium text-red-300">
|
||||
Deleted
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Project folder no longer exists</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{card.pathSummary && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -91,21 +108,34 @@ export const RecentProjectCard = ({
|
|||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (isDeleted) {
|
||||
return;
|
||||
}
|
||||
onOpenPath();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isDeleted) {
|
||||
return;
|
||||
}
|
||||
onOpenPath();
|
||||
}
|
||||
}}
|
||||
className="shrink-0 cursor-pointer rounded p-0.5 transition-colors hover:bg-white/5 hover:text-text-secondary"
|
||||
className={cn(
|
||||
'shrink-0 rounded p-0.5 transition-colors',
|
||||
isDeleted
|
||||
? 'cursor-not-allowed text-red-300/70'
|
||||
: 'cursor-pointer hover:bg-white/5 hover:text-text-secondary'
|
||||
)}
|
||||
>
|
||||
<FolderOpen className="size-3" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Open</TooltipContent>
|
||||
<TooltipContent side="bottom">
|
||||
{isDeleted ? 'Project folder no longer exists' : 'Open'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -353,6 +353,11 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
||||
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
|
||||
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 (
|
||||
targetEnv: NodeJS.ProcessEnv,
|
||||
options: { emitProgress?: boolean } = {}
|
||||
|
|
@ -408,17 +413,19 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
|
||||
const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
|
||||
reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
if (useHttpMcpBridge) {
|
||||
try {
|
||||
reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
|
||||
const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
|
||||
reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
|
||||
await applyMcpLaunchSpecEnv(bridgeEnv, { emitProgress: true });
|
||||
|
|
@ -427,7 +434,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
|
||||
const resolveBridgeCommandEnv = async (): Promise<NodeJS.ProcessEnv> => {
|
||||
const nextEnv = { ...bridgeEnv };
|
||||
if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
|
||||
if (!useHttpMcpBridge || !bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
|
||||
return nextEnv;
|
||||
}
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
import {
|
||||
type PaginatedSessionsResult,
|
||||
type Project,
|
||||
type ProjectFilesystemState,
|
||||
type RepositoryGroup,
|
||||
type SearchSessionsResult,
|
||||
type Session,
|
||||
|
|
@ -82,6 +83,21 @@ const SEARCH_PROJECT_CACHE_TTL_MS = 30_000;
|
|||
// for lookups and navigation; a small cap preserves that behavior without huge payloads.
|
||||
const MAX_SESSION_IDS_EXPORTED = 200;
|
||||
|
||||
async function resolveProjectFilesystemState(
|
||||
fsProvider: FileSystemProvider,
|
||||
projectPath: string
|
||||
): Promise<ProjectFilesystemState> {
|
||||
if (!projectPath.trim()) {
|
||||
return 'deleted';
|
||||
}
|
||||
|
||||
try {
|
||||
return (await fsProvider.exists(projectPath)) ? 'available' : 'deleted';
|
||||
} catch {
|
||||
return 'deleted';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProjectScannerOptions {
|
||||
/**
|
||||
* Directory for the persisted session-list metadata index.
|
||||
|
|
@ -340,6 +356,7 @@ export class ProjectScanner {
|
|||
totalSessions,
|
||||
createdAt: project.createdAt,
|
||||
mostRecentSession: project.mostRecentSession,
|
||||
filesystemState: project.filesystemState,
|
||||
},
|
||||
],
|
||||
name: project.name,
|
||||
|
|
@ -360,6 +377,7 @@ export class ProjectScanner {
|
|||
const encodedId = customPath.replace(/[/\\]/g, '-');
|
||||
const folderName = customPath.split(/[/\\]/).filter(Boolean).pop() ?? customPath;
|
||||
const now = Date.now();
|
||||
const filesystemState = await resolveProjectFilesystemState(this.fsProvider, customPath);
|
||||
|
||||
groups.push({
|
||||
id: encodedId,
|
||||
|
|
@ -374,6 +392,7 @@ export class ProjectScanner {
|
|||
sessions: [],
|
||||
totalSessions: 0,
|
||||
createdAt: now,
|
||||
filesystemState,
|
||||
},
|
||||
],
|
||||
name: folderName,
|
||||
|
|
@ -550,6 +569,7 @@ export class ProjectScanner {
|
|||
cwdHint: firstCwd ?? undefined,
|
||||
sessionPaths,
|
||||
});
|
||||
const filesystemState = await resolveProjectFilesystemState(this.fsProvider, actualPath);
|
||||
|
||||
// Derive name from resolved path — more reliable than decodePath for
|
||||
// paths containing dashes (e.g. "test-project" encodes lossily).
|
||||
|
|
@ -564,6 +584,7 @@ export class ProjectScanner {
|
|||
totalSessions: allSessionIds.length,
|
||||
createdAt: Math.floor(createdAt),
|
||||
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
|
||||
filesystemState,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -623,6 +644,10 @@ export class ProjectScanner {
|
|||
totalSessions: sessionIds.length,
|
||||
createdAt: Math.floor(createdAt),
|
||||
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
|
||||
filesystemState: await resolveProjectFilesystemState(
|
||||
this.fsProvider,
|
||||
actualCwd ?? decodedFallback
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -501,7 +501,7 @@ export class CliInstallerService {
|
|||
},
|
||||
{
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode (75+ LLM providers)',
|
||||
displayName: 'OpenCode (200+ models)',
|
||||
},
|
||||
] as const
|
||||
).map((provider) => ({
|
||||
|
|
|
|||
|
|
@ -326,7 +326,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
|
|||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode (75+ LLM providers)';
|
||||
return 'OpenCode (200+ models)';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ export type MessageCategory = 'user' | 'system' | 'hardNoise' | 'ai' | 'compact'
|
|||
/**
|
||||
* Project information derived from ~/.claude/projects/ directory.
|
||||
*/
|
||||
export type ProjectFilesystemState = 'available' | 'deleted';
|
||||
|
||||
export interface Project {
|
||||
/** Encoded directory name (e.g., "-Users-username-projectname") */
|
||||
id: string;
|
||||
|
|
@ -62,6 +64,8 @@ export interface Project {
|
|||
createdAt: number;
|
||||
/** Unix timestamp of most recent session activity */
|
||||
mostRecentSession?: number;
|
||||
/** Filesystem state for the decoded working directory. */
|
||||
filesystemState?: ProjectFilesystemState;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -202,6 +206,8 @@ export interface Worktree {
|
|||
createdAt: number;
|
||||
/** Unix timestamp of most recent session activity */
|
||||
mostRecentSession?: number;
|
||||
/** Filesystem state for this worktree path. */
|
||||
filesystemState?: ProjectFilesystemState;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -385,7 +385,7 @@ function getProviderLabel(providerId: CliProviderId): string {
|
|||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode (75+ LLM providers)';
|
||||
return 'OpenCode (200+ models)';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export const TeamTabSectionNav = ({
|
|||
if (messagesPanelMode === 'sidebar') {
|
||||
return section.id !== 'messages' && section.id !== 'claude-logs';
|
||||
}
|
||||
if (messagesPanelMode === 'bottom-sheet') {
|
||||
if (messagesPanelMode === 'bottom-sheet' || messagesPanelMode === 'floating-composer') {
|
||||
return section.id !== 'messages';
|
||||
}
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ function getProviderLabel(providerId: CliProviderId): string {
|
|||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode (75+ LLM providers)';
|
||||
return 'OpenCode (200+ models)';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3623,9 +3623,18 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
ref={setMessagesPanelMountPoint}
|
||||
className="pointer-events-none absolute inset-0 z-30"
|
||||
/>
|
||||
{messagesPanelMode === 'bottom-sheet' && (
|
||||
{messagesPanelMode === 'bottom-sheet' && !graphOpen && (
|
||||
<TeamMessagesPanelBridge position="bottom-sheet" {...sharedMessagesPanelProps} />
|
||||
)}
|
||||
{messagesPanelMode === 'floating-composer' &&
|
||||
isThisTabActive &&
|
||||
isPaneFocused &&
|
||||
!graphOpen && (
|
||||
<TeamMessagesPanelBridge
|
||||
position="floating-composer"
|
||||
{...sharedMessagesPanelProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -3650,6 +3659,12 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
.getState()
|
||||
.openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName });
|
||||
}}
|
||||
messagesPanelEnabled={
|
||||
(messagesPanelMode === 'floating-composer' ||
|
||||
messagesPanelMode === 'bottom-sheet') &&
|
||||
isThisTabActive &&
|
||||
isPaneFocused
|
||||
}
|
||||
onSendMessage={(memberName) => {
|
||||
setSendDialogRecipient(memberName);
|
||||
setSendDialogDefaultText(undefined);
|
||||
|
|
|
|||
|
|
@ -671,6 +671,23 @@ export const CreateTeamDialog = ({
|
|||
);
|
||||
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(() => {
|
||||
runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider;
|
||||
}, [runtimeBackendSummaryByProvider]);
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ import {
|
|||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Info,
|
||||
Loader2,
|
||||
X,
|
||||
|
|
@ -230,6 +231,8 @@ export type LaunchTeamDialogProps =
|
|||
| LaunchDialogScheduleMode;
|
||||
|
||||
const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate';
|
||||
const ANTHROPIC_AGENT_SDK_CREDIT_ARTICLE_URL =
|
||||
'https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan';
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
|
|
@ -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
|
||||
one-shot execution
|
||||
</p>
|
||||
{selectedProviderId === 'anthropic' ? (
|
||||
<div className="flex gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 p-2 text-[11px] leading-relaxed text-amber-100">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<p>
|
||||
Starting June 15, 2026, Anthropic bills <code>claude -p</code> and Agent SDK
|
||||
usage from the monthly Agent SDK credit, separate from interactive Claude Code
|
||||
limits. The credit resets each billing cycle and unused credit does not roll
|
||||
over.{' '}
|
||||
<a
|
||||
href={ANTHROPIC_AGENT_SDK_CREDIT_ARTICLE_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium underline underline-offset-2 hover:text-white"
|
||||
>
|
||||
Read Anthropic article
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Combobox } from '@renderer/components/ui/combobox';
|
|||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { Check, FolderOpen } from 'lucide-react';
|
||||
import { Check, FolderOpen, FolderX } from 'lucide-react';
|
||||
|
||||
import {
|
||||
buildProjectPathOptions,
|
||||
|
|
@ -58,6 +58,10 @@ function getOptionSource(option: ComboboxOption): DashboardRecentProjectSource |
|
|||
return (option.meta as ProjectPathOptionMeta | undefined)?.discoverySource;
|
||||
}
|
||||
|
||||
function isDeletedOption(option: ComboboxOption): boolean {
|
||||
return (option.meta as ProjectPathOptionMeta | undefined)?.filesystemState === 'deleted';
|
||||
}
|
||||
|
||||
function getSourceLabel(source: DashboardRecentProjectSource): string {
|
||||
switch (source) {
|
||||
case 'claude':
|
||||
|
|
@ -97,6 +101,16 @@ const ProjectSourceBadge = ({
|
|||
);
|
||||
};
|
||||
|
||||
const ProjectDeletedBadge = (): React.JSX.Element => (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-red-500/30 bg-red-500/10 px-1.5 py-0.5 text-[10px] font-medium text-red-300"
|
||||
title="Project folder no longer exists"
|
||||
>
|
||||
<FolderX className="size-3" />
|
||||
Deleted
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CwdMode = 'project' | 'custom';
|
||||
|
||||
interface ProjectPathSelectorProps {
|
||||
|
|
@ -178,28 +192,38 @@ export const ProjectPathSelector = ({
|
|||
renderTriggerLabel={(option) => (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ProjectSourceBadge source={getOptionSource(option)} />
|
||||
{isDeletedOption(option) ? <ProjectDeletedBadge /> : null}
|
||||
<span className="min-w-0 truncate">{option.label}</span>
|
||||
</span>
|
||||
)}
|
||||
renderOption={(option, isSelected, query) => (
|
||||
<>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 size-3.5 shrink-0',
|
||||
isSelected ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<ProjectSourceBadge source={getOptionSource(option)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-[var(--color-text)]">
|
||||
{renderHighlightedText(option.label, query)}
|
||||
</p>
|
||||
<p className="truncate text-[var(--color-text-muted)]">
|
||||
{renderHighlightedText(option.description ?? '', query)}
|
||||
</p>
|
||||
renderOption={(option, isSelected, query) => {
|
||||
const isDeleted = isDeletedOption(option);
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<Check
|
||||
className={cn(
|
||||
'size-3.5 shrink-0',
|
||||
isSelected ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<ProjectSourceBadge source={getOptionSource(option)} />
|
||||
{isDeleted ? <ProjectDeletedBadge /> : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
'truncate font-medium text-[var(--color-text)]',
|
||||
isDeleted && 'text-red-200'
|
||||
)}
|
||||
>
|
||||
{renderHighlightedText(option.label, query)}
|
||||
</p>
|
||||
<p className="truncate text-[var(--color-text-muted)]">
|
||||
{renderHighlightedText(option.description ?? '', query)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||
|
||||
import type { DashboardRecentProjectSource } from '@features/recent-projects/contracts';
|
||||
import type {
|
||||
DashboardRecentProjectFilesystemState,
|
||||
DashboardRecentProjectSource,
|
||||
} from '@features/recent-projects/contracts';
|
||||
import type { ComboboxOption } from '@renderer/components/ui/combobox';
|
||||
import type { Project } from '@shared/types';
|
||||
|
||||
export interface ProjectPathProject extends Project {
|
||||
discoverySource?: DashboardRecentProjectSource;
|
||||
filesystemState?: DashboardRecentProjectFilesystemState;
|
||||
}
|
||||
|
||||
export interface ProjectPathOptionMeta {
|
||||
discoverySource?: DashboardRecentProjectSource;
|
||||
filesystemState?: DashboardRecentProjectFilesystemState;
|
||||
}
|
||||
|
||||
function toProjectOption(project: ProjectPathProject): ComboboxOption {
|
||||
|
|
@ -20,10 +25,19 @@ function toProjectOption(project: ProjectPathProject): ComboboxOption {
|
|||
description: project.path,
|
||||
};
|
||||
|
||||
if (project.discoverySource !== undefined) {
|
||||
option.meta = {
|
||||
discoverySource: project.discoverySource,
|
||||
} satisfies ProjectPathOptionMeta;
|
||||
if (project.filesystemState === 'deleted') {
|
||||
option.disabled = true;
|
||||
}
|
||||
|
||||
if (project.discoverySource !== undefined || project.filesystemState !== undefined) {
|
||||
const meta: ProjectPathOptionMeta = {};
|
||||
if (project.discoverySource !== undefined) {
|
||||
meta.discoverySource = project.discoverySource;
|
||||
}
|
||||
if (project.filesystemState !== undefined) {
|
||||
meta.filesystemState = project.filesystemState;
|
||||
}
|
||||
option.meta = meta;
|
||||
}
|
||||
|
||||
return option;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ function mergeDiscoverySource(
|
|||
return 'mixed';
|
||||
}
|
||||
|
||||
function mergeFilesystemState(
|
||||
current: ProjectPathProject['filesystemState'],
|
||||
next: ProjectPathProject['filesystemState']
|
||||
): ProjectPathProject['filesystemState'] {
|
||||
if (current === 'available' || next === 'available') return 'available';
|
||||
return current ?? next;
|
||||
}
|
||||
|
||||
function getPathName(projectPath: string): string {
|
||||
return projectPath.split(/[/\\]/).filter(Boolean).pop() ?? projectPath;
|
||||
}
|
||||
|
|
@ -47,6 +55,10 @@ function upsertProject(
|
|||
existing.discoverySource,
|
||||
project.discoverySource
|
||||
);
|
||||
existing.filesystemState = mergeFilesystemState(
|
||||
existing.filesystemState,
|
||||
project.filesystemState
|
||||
);
|
||||
if (!existing.mostRecentSession && project.mostRecentSession) {
|
||||
existing.mostRecentSession = project.mostRecentSession;
|
||||
}
|
||||
|
|
@ -58,6 +70,7 @@ function recentProjectToProject(project: {
|
|||
primaryPath: string;
|
||||
mostRecentActivity: number;
|
||||
source: DashboardRecentProjectSource;
|
||||
filesystemState?: ProjectPathProject['filesystemState'];
|
||||
}): ProjectPathProject {
|
||||
return {
|
||||
id: `recent:${project.id}`,
|
||||
|
|
@ -68,10 +81,13 @@ function recentProjectToProject(project: {
|
|||
createdAt: project.mostRecentActivity,
|
||||
mostRecentSession: project.mostRecentActivity,
|
||||
discoverySource: project.source,
|
||||
filesystemState: project.filesystemState,
|
||||
};
|
||||
}
|
||||
|
||||
function repositoryWorktreeToProject(worktree: RepositoryGroup['worktrees'][number]): Project {
|
||||
function repositoryWorktreeToProject(
|
||||
worktree: RepositoryGroup['worktrees'][number]
|
||||
): ProjectPathProject {
|
||||
return {
|
||||
id: worktree.id,
|
||||
path: worktree.path,
|
||||
|
|
@ -79,6 +95,7 @@ function repositoryWorktreeToProject(worktree: RepositoryGroup['worktrees'][numb
|
|||
sessions: [],
|
||||
totalSessions: 0,
|
||||
createdAt: worktree.createdAt ?? Date.now(),
|
||||
filesystemState: worktree.filesystemState,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ interface MessageComposerProps {
|
|||
sendWarning?: string | null;
|
||||
sendDebugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null;
|
||||
lastResult?: SendMessageResult | null;
|
||||
cornerActionPrefix?: React.ReactNode;
|
||||
/** Ref to the underlying textarea element for external focus management. */
|
||||
textareaRef?: React.Ref<HTMLTextAreaElement>;
|
||||
onSend: (
|
||||
|
|
@ -112,6 +113,7 @@ export const MessageComposer = ({
|
|||
sendWarning,
|
||||
sendDebugDetails,
|
||||
lastResult,
|
||||
cornerActionPrefix,
|
||||
textareaRef: externalTextareaRef,
|
||||
onSend,
|
||||
onCrossTeamSend,
|
||||
|
|
@ -143,6 +145,7 @@ export const MessageComposer = ({
|
|||
const [recipientOpen, setRecipientOpen] = useState(false);
|
||||
const [recipientSearch, setRecipientSearch] = useState('');
|
||||
const recipientSearchRef = useRef<HTMLInputElement>(null);
|
||||
const [isTextareaFocused, setIsTextareaFocused] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -642,6 +645,8 @@ export const MessageComposer = ({
|
|||
},
|
||||
[canAttach, draftHandlePaste, showFileRestrictionError, validateSelectedAttachmentFiles]
|
||||
);
|
||||
const handleTextareaFocus = useCallback(() => setIsTextareaFocused(true), []);
|
||||
const handleTextareaBlur = useCallback(() => setIsTextareaFocused(false), []);
|
||||
|
||||
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
||||
const hasAttachmentPreviewContent =
|
||||
|
|
@ -666,6 +671,29 @@ export const MessageComposer = ({
|
|||
Reused recent cross-team request
|
||||
</span>
|
||||
) : null;
|
||||
const shouldShowFooterCharCount = remaining < 200;
|
||||
const shouldShowSavedIndicator = isTextareaFocused && draft.isSaved;
|
||||
const nonCompactFooterRight =
|
||||
compactFooterNotice || shouldShowFooterCharCount || shouldShowSavedIndicator ? (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{compactFooterNotice}
|
||||
{shouldShowFooterCharCount || shouldShowSavedIndicator ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{shouldShowFooterCharCount ? (
|
||||
<span
|
||||
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
||||
>
|
||||
{remaining} chars left
|
||||
</span>
|
||||
) : null}
|
||||
{shouldShowSavedIndicator ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
const composerFooterRight = isCompactLayout ? compactFooterNotice : nonCompactFooterRight;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -1045,6 +1073,8 @@ export const MessageComposer = ({
|
|||
commandSuggestions={slashCommandSuggestions}
|
||||
chips={draft.chips}
|
||||
onChipRemove={draft.removeChip}
|
||||
onFocus={handleTextareaFocus}
|
||||
onBlur={handleTextareaBlur}
|
||||
projectPath={projectPath}
|
||||
onFileChipInsert={draft.addChip}
|
||||
onModEnter={handleSend}
|
||||
|
|
@ -1059,7 +1089,7 @@ export const MessageComposer = ({
|
|||
maxRows={6}
|
||||
maxLength={MAX_TEXT_LENGTH}
|
||||
hintText={crossTeamHintText}
|
||||
showHint={!isCompactLayout}
|
||||
showHint={!isCompactLayout && isTextareaFocused}
|
||||
cornerActionInset={isCompactLayout ? 'compact' : 'default'}
|
||||
cornerActionLeft={
|
||||
<ActionModeSelector
|
||||
|
|
@ -1071,6 +1101,7 @@ export const MessageComposer = ({
|
|||
}
|
||||
cornerAction={
|
||||
<div className="flex items-center gap-2">
|
||||
{cornerActionPrefix}
|
||||
{/* NOTE: ContextRing disabled — usage formula is inaccurate */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -1108,27 +1139,7 @@ export const MessageComposer = ({
|
|||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
footerRight={
|
||||
isCompactLayout ? (
|
||||
compactFooterNotice
|
||||
) : (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{compactFooterNotice}
|
||||
<div className="flex items-center gap-2">
|
||||
{remaining < 200 ? (
|
||||
<span
|
||||
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
||||
>
|
||||
{remaining} chars left
|
||||
</span>
|
||||
) : null}
|
||||
{draft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
footerRight={composerFooterRight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ import { Sheet, type SheetRef } from 'react-modal-sheet';
|
|||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@renderer/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
|
||||
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
|
||||
|
|
@ -33,7 +39,9 @@ import {
|
|||
CheckCheck,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Dock,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
PanelBottom,
|
||||
PanelBottomClose,
|
||||
PanelBottomOpen,
|
||||
|
|
@ -807,6 +815,10 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
onPositionChange('bottom-sheet');
|
||||
}, [onPositionChange]);
|
||||
|
||||
const moveToFloatingComposer = useCallback(() => {
|
||||
onPositionChange('floating-composer');
|
||||
}, [onPositionChange]);
|
||||
|
||||
const snapBottomSheetTo = useCallback((snapIndex: number) => {
|
||||
setBottomSheetSnapIndex(snapIndex);
|
||||
bottomSheetRef.current?.snapTo(snapIndex);
|
||||
|
|
@ -864,6 +876,53 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
/>
|
||||
);
|
||||
|
||||
const floatingComposerModeControls = (
|
||||
<div className="inline-flex items-center gap-0.5 pr-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={moveToInline}
|
||||
aria-label="Move messages to inline panel"
|
||||
>
|
||||
<PanelBottom size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Move to inline</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={moveToBottomSheet}
|
||||
aria-label="Move messages to bottom sheet"
|
||||
>
|
||||
<PanelBottomOpen size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Move to bottom sheet</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={moveToSidebar}
|
||||
aria-label="Move messages to sidebar"
|
||||
>
|
||||
<PanelLeft size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Move to sidebar</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
const compactComposerSection = (
|
||||
<MessagesComposerSection
|
||||
teamName={teamName}
|
||||
|
|
@ -881,6 +940,24 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
/>
|
||||
);
|
||||
|
||||
const floatingComposerSection = (
|
||||
<MessagesComposerSection
|
||||
teamName={teamName}
|
||||
layout="compact"
|
||||
members={members}
|
||||
isTeamAlive={isTeamAlive}
|
||||
sending={sendingMessage}
|
||||
sendError={sendMessageError}
|
||||
sendWarning={effectiveSendMessageWarning}
|
||||
sendDebugDetails={effectiveSendMessageDebugDetails}
|
||||
lastResult={lastSendMessageResult}
|
||||
cornerActionPrefix={floatingComposerModeControls}
|
||||
textareaRef={composerTextareaRef}
|
||||
onSend={handleSend}
|
||||
onCrossTeamSend={handleCrossTeamSend}
|
||||
/>
|
||||
);
|
||||
|
||||
const inlineStatusSection = (
|
||||
<MessagesStatusSection
|
||||
members={members}
|
||||
|
|
@ -1054,54 +1131,53 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
</Tooltip>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setMessagesCollapsed((v) => !v)}
|
||||
aria-label={messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
|
||||
>
|
||||
{messagesCollapsed ? <ChevronsUpDown size={14} /> : <ChevronsDownUp size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setMessagesSearchBarVisible((v) => !v)}
|
||||
aria-label={
|
||||
messagesSearchBarVisible ? 'Hide message search' : 'Show message search'
|
||||
}
|
||||
>
|
||||
{messagesSearchBarVisible ? <X size={14} /> : <Search size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={moveToInline}
|
||||
aria-label="Move messages to inline panel"
|
||||
>
|
||||
<PanelLeftClose size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Move to inline</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] data-[state=open]:bg-[var(--color-surface-raised)] data-[state=open]:text-[var(--color-text-secondary)]"
|
||||
aria-label="Message panel actions"
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Message actions</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" side="bottom" className="w-48">
|
||||
<DropdownMenuItem onSelect={() => setMessagesCollapsed((v) => !v)}>
|
||||
{messagesCollapsed ? (
|
||||
<ChevronsUpDown size={14} className="shrink-0" />
|
||||
) : (
|
||||
<ChevronsDownUp size={14} className="shrink-0" />
|
||||
)}
|
||||
<span>{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setMessagesSearchBarVisible((v) => !v)}>
|
||||
{messagesSearchBarVisible ? (
|
||||
<X size={14} className="shrink-0" />
|
||||
) : (
|
||||
<Search size={14} className="shrink-0" />
|
||||
)}
|
||||
<span>{messagesSearchBarVisible ? 'Hide search' : 'Search messages'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={moveToInline}>
|
||||
<PanelLeftClose size={14} className="shrink-0" />
|
||||
<span>Move to inline</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={moveToBottomSheet}>
|
||||
<PanelBottomOpen size={14} className="shrink-0" />
|
||||
<span>Move to bottom sheet</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={moveToFloatingComposer}>
|
||||
<Dock size={14} className="shrink-0" />
|
||||
<span>Float composer</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
{/* Search & filter bar (toggleable) */}
|
||||
|
|
@ -1126,6 +1202,16 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
);
|
||||
}
|
||||
|
||||
if (position === 'floating-composer') {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-40 px-4 pb-5 sm:px-6 sm:pb-6">
|
||||
<div className="mx-auto w-full max-w-[500px]">
|
||||
<div className="pointer-events-auto">{floatingComposerSection}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (position === 'bottom-sheet') {
|
||||
if (!mountPoint) {
|
||||
return <div className="hidden" aria-hidden="true" />;
|
||||
|
|
@ -1196,114 +1282,74 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
className="ml-auto flex items-center gap-1"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{messagesUnreadCount > 0 && (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-[22px] p-0 text-blue-400 hover:bg-blue-500/10 hover:text-blue-300"
|
||||
onClick={handleMarkAllRead}
|
||||
aria-label="Mark all messages as read"
|
||||
>
|
||||
<CheckCheck size={13} />
|
||||
</Button>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] data-[state=open]:bg-[var(--color-surface-raised)] data-[state=open]:text-[var(--color-text-secondary)]"
|
||||
aria-label="Message bottom sheet actions"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Mark all as read</TooltipContent>
|
||||
<TooltipContent side="top">Message actions</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setMessagesCollapsed((value) => !value)}
|
||||
aria-label={
|
||||
messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'
|
||||
}
|
||||
>
|
||||
<DropdownMenuContent align="end" side="top" className="w-48">
|
||||
{messagesUnreadCount > 0 && (
|
||||
<DropdownMenuItem
|
||||
className="text-blue-400 focus:text-blue-300"
|
||||
onSelect={handleMarkAllRead}
|
||||
>
|
||||
<CheckCheck size={14} className="shrink-0" />
|
||||
<span>Mark all as read</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => setMessagesCollapsed((value) => !value)}>
|
||||
{messagesCollapsed ? (
|
||||
<ChevronsUpDown size={14} />
|
||||
<ChevronsUpDown size={14} className="shrink-0" />
|
||||
) : (
|
||||
<ChevronsDownUp size={14} />
|
||||
<ChevronsDownUp size={14} className="shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setMessagesSearchBarVisible((value) => !value)}
|
||||
aria-label={
|
||||
messagesSearchBarVisible ? 'Hide message search' : 'Show message search'
|
||||
}
|
||||
>
|
||||
{messagesSearchBarVisible ? <X size={14} /> : <Search size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={toggleBottomSheetExpansion}
|
||||
aria-label={
|
||||
isBottomSheetCollapsed
|
||||
? 'Expand messages bottom sheet'
|
||||
: 'Collapse messages bottom sheet'
|
||||
}
|
||||
<span>
|
||||
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setMessagesSearchBarVisible((value) => !value)}
|
||||
>
|
||||
{messagesSearchBarVisible ? (
|
||||
<X size={14} className="shrink-0" />
|
||||
) : (
|
||||
<Search size={14} className="shrink-0" />
|
||||
)}
|
||||
<span>{messagesSearchBarVisible ? 'Hide search' : 'Search messages'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={toggleBottomSheetExpansion}>
|
||||
{isBottomSheetCollapsed ? (
|
||||
<PanelBottomOpen size={14} />
|
||||
<PanelBottomOpen size={14} className="shrink-0" />
|
||||
) : (
|
||||
<PanelBottomClose size={14} />
|
||||
<PanelBottomClose size={14} className="shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={moveToInline}
|
||||
aria-label="Move messages to inline panel"
|
||||
>
|
||||
<PanelBottom size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Move to inline</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={moveToSidebar}
|
||||
aria-label="Move messages to sidebar"
|
||||
>
|
||||
<PanelLeft size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Move to sidebar</TooltipContent>
|
||||
</Tooltip>
|
||||
<span>{isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={moveToInline}>
|
||||
<PanelBottom size={14} className="shrink-0" />
|
||||
<span>Move to inline</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={moveToSidebar}>
|
||||
<PanelLeft size={14} className="shrink-0" />
|
||||
<span>Move to sidebar</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={moveToFloatingComposer}>
|
||||
<Dock size={14} className="shrink-0" />
|
||||
<span>Float composer</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1386,6 +1432,23 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Move to bottom sheet</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveToFloatingComposer();
|
||||
}}
|
||||
aria-label="Float messages composer"
|
||||
>
|
||||
<Dock size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Float composer</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export interface ComboboxOption {
|
|||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
/** Extra data for renderOption (e.g. sessionCount, path). */
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -133,12 +134,20 @@ export const Combobox = ({
|
|||
<CommandPrimitive.Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
aria-disabled={option.disabled === true}
|
||||
onSelect={() => {
|
||||
if (option.disabled) {
|
||||
return;
|
||||
}
|
||||
onValueChange(option.value);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
}}
|
||||
className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]',
|
||||
option.disabled && 'cursor-not-allowed opacity-60'
|
||||
)}
|
||||
>
|
||||
{renderOption ? (
|
||||
renderOption(option, isSelected, search)
|
||||
|
|
|
|||
65
src/renderer/components/ui/dropdown-menu.tsx
Normal file
65
src/renderer/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
|
||||
import * as React from 'react';
|
||||
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[160px] rounded-md border border-[var(--color-border-emphasis)] bg-[var(--color-surface-overlay)] p-1 text-[12px] text-[var(--color-text)] shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center gap-2 rounded px-2 py-1.5 text-[12px] text-[var(--color-text-secondary)] outline-none transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-[var(--color-text)] focus:bg-[rgba(255,255,255,0.06)] focus:text-[var(--color-text)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('my-1 h-px bg-[var(--color-border)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */
|
||||
|
|
@ -38,7 +38,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
|||
{ providerId: 'anthropic', displayName: 'Anthropic' },
|
||||
{ providerId: 'codex', displayName: 'Codex' },
|
||||
{ providerId: 'gemini', displayName: 'Gemini' },
|
||||
{ providerId: 'opencode', displayName: 'OpenCode (75+ LLM providers)' },
|
||||
{ providerId: 'opencode', displayName: 'OpenCode (200+ models)' },
|
||||
] as const
|
||||
).map((provider) => ({
|
||||
...provider,
|
||||
|
|
@ -500,7 +500,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
|
|||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode (75+ LLM providers)';
|
||||
return 'OpenCode (200+ models)';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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].branchName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers an available candidate over a newer deleted path', () => {
|
||||
const result = mergeRecentProjectCandidates([
|
||||
makeCandidate({
|
||||
lastActivityAt: 1_000,
|
||||
primaryPath: '/workspace/alpha',
|
||||
associatedPaths: ['/workspace/alpha'],
|
||||
filesystemState: 'available',
|
||||
openTarget: {
|
||||
type: 'synthetic-path',
|
||||
path: '/workspace/alpha',
|
||||
},
|
||||
}),
|
||||
makeCandidate({
|
||||
lastActivityAt: 5_000,
|
||||
primaryPath: '/workspace/alpha-deleted',
|
||||
associatedPaths: ['/workspace/alpha-deleted'],
|
||||
filesystemState: 'deleted',
|
||||
openTarget: {
|
||||
type: 'synthetic-path',
|
||||
path: '/workspace/alpha-deleted',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
primaryPath: '/workspace/alpha',
|
||||
lastActivityAt: 5_000,
|
||||
filesystemState: 'available',
|
||||
openTarget: {
|
||||
type: 'synthetic-path',
|
||||
path: '/workspace/alpha',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -114,6 +114,42 @@ describe('CodexSessionFileRecentProjectsSourceAdapter', () => {
|
|||
expect(identityResolver.resolve).toHaveBeenCalledWith('/Users/test/projects/alpha');
|
||||
});
|
||||
|
||||
it('marks a Codex session project as deleted when its cwd is gone', async () => {
|
||||
const codexHome = path.join(tempDir, '.codex');
|
||||
const logger = createLogger();
|
||||
const identityResolver = {
|
||||
resolve: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as RecentProjectIdentityResolver;
|
||||
const fsProvider = {
|
||||
exists: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
await writeRollout(
|
||||
path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-deleted.jsonl'),
|
||||
{
|
||||
cwd: '/Users/test/projects/deleted',
|
||||
},
|
||||
new Date('2026-04-14T12:00:00.000Z')
|
||||
);
|
||||
|
||||
const adapter = new CodexSessionFileRecentProjectsSourceAdapter({
|
||||
getActiveContext: () => ({ type: 'local', id: 'local-1', fsProvider }) as never,
|
||||
getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||
identityResolver,
|
||||
logger,
|
||||
codexHome,
|
||||
});
|
||||
|
||||
const result = await adapter.list();
|
||||
|
||||
expect(result.candidates[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
primaryPath: '/Users/test/projects/deleted',
|
||||
filesystemState: 'deleted',
|
||||
})
|
||||
);
|
||||
expect(fsProvider.exists).toHaveBeenCalledWith('/Users/test/projects/deleted');
|
||||
});
|
||||
|
||||
it('loads Codex projects from large session metadata lines without parsing the full line', async () => {
|
||||
const codexHome = path.join(tempDir, '.codex');
|
||||
const logger = createLogger();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ describe('adaptRecentProjectsSection', () => {
|
|||
worktreeId: 'wt-alpha',
|
||||
},
|
||||
primaryBranch: 'main',
|
||||
filesystemState: 'deleted',
|
||||
};
|
||||
|
||||
const activeTeam: TeamSummary = {
|
||||
|
|
@ -52,11 +53,11 @@ describe('adaptRecentProjectsSection', () => {
|
|||
taskCounts: { pending: 5, inProgress: 7, completed: 9 },
|
||||
additionalPathCount: 1,
|
||||
primaryBranch: 'main',
|
||||
filesystemState: 'deleted',
|
||||
activeTeams: [activeTeam],
|
||||
pathSummary: {
|
||||
badgeLabel: '2 paths',
|
||||
description:
|
||||
'This card merges recent activity from related worktrees and project paths.',
|
||||
description: 'This card merges recent activity from related worktrees and project paths.',
|
||||
paths: [
|
||||
{
|
||||
label: 'Primary path',
|
||||
|
|
|
|||
|
|
@ -153,4 +153,26 @@ describe('ProjectScanner cwd split logic', () => {
|
|||
expect(worktree?.isMainWorktree).toBe(false);
|
||||
expect(worktree?.source).toBe('claude-desktop');
|
||||
});
|
||||
|
||||
it('marks decoded project paths as deleted when the working directory no longer exists', async () => {
|
||||
const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-'));
|
||||
tempDirs.push(projectsDir);
|
||||
|
||||
const encodedName = '-Users-test-deleted-project';
|
||||
const projectDir = path.join(projectsDir, encodedName);
|
||||
fs.mkdirSync(projectDir);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'session-deleted.jsonl'),
|
||||
createSessionLine({ cwd: '/Users/test/deleted-project' }) + '\n'
|
||||
);
|
||||
|
||||
const scanner = new ProjectScanner(projectsDir);
|
||||
const projects = await scanner.scan();
|
||||
|
||||
expect(projects[0]).toMatchObject({
|
||||
path: '/Users/test/deleted-project',
|
||||
filesystemState: 'deleted',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ describe('CliInstallerService', () => {
|
|||
'opencode',
|
||||
]);
|
||||
expect(openCodeStatus).toMatchObject({
|
||||
displayName: 'OpenCode (75+ LLM providers)',
|
||||
displayName: 'OpenCode (200+ models)',
|
||||
supported: false,
|
||||
statusMessage: 'Runtime not found.',
|
||||
canLoginFromUi: false,
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
});
|
||||
expect(providers[3]).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode (75+ LLM providers)',
|
||||
displayName: 'OpenCode (200+ models)',
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
models: [],
|
||||
|
|
|
|||
|
|
@ -450,7 +450,7 @@ describe('CLI status visibility during completed install state', () => {
|
|||
providers: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode (75+ LLM providers)',
|
||||
displayName: 'OpenCode (200+ models)',
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
|
|
@ -476,7 +476,7 @@ describe('CLI status visibility during completed install state', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('OpenCode (75+ LLM providers)');
|
||||
expect(host.textContent).toContain('OpenCode (200+ models)');
|
||||
expect(host.textContent).toContain('Install');
|
||||
|
||||
const installButton = Array.from(host.querySelectorAll('button')).find(
|
||||
|
|
@ -523,7 +523,7 @@ describe('CLI status visibility during completed install state', () => {
|
|||
providers: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode (75+ LLM providers)',
|
||||
displayName: 'OpenCode (200+ models)',
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
|
|
@ -575,7 +575,7 @@ describe('CLI status visibility during completed install state', () => {
|
|||
providers: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode (75+ LLM providers)',
|
||||
displayName: 'OpenCode (200+ models)',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
|
|
|
|||
|
|
@ -559,7 +559,7 @@ describe('SkillsPanel', () => {
|
|||
});
|
||||
|
||||
expect(host.textContent).toContain(
|
||||
'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic, Codex, and OpenCode (75+ LLM providers).'
|
||||
'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic, Codex, and OpenCode (200+ models).'
|
||||
);
|
||||
expect(host.textContent).toContain('Codex only');
|
||||
|
||||
|
|
|
|||
|
|
@ -1405,6 +1405,12 @@ describe('LaunchTeamDialog', () => {
|
|||
expect(host.textContent).toContain('model:claude-opus-4-6');
|
||||
expect(host.textContent).toContain('effort:max');
|
||||
expect(host.textContent).toContain('fast:on');
|
||||
expect(host.textContent).toContain('monthly Agent SDK credit');
|
||||
expect(
|
||||
host.querySelector(
|
||||
'a[href="https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan"]'
|
||||
)
|
||||
).toBeTruthy();
|
||||
|
||||
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Save Changes'
|
||||
|
|
|
|||
|
|
@ -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