merge: resolve conflicts with main (ExtensionStoreView, InstallButton)
Merged new fields from main (fetchCliStatus, cliStatusLoading, openDashboard, authMissing, disableReason) into our useShallow selectors.
This commit is contained in:
commit
f07eb914ae
8 changed files with 243 additions and 104 deletions
|
|
@ -27,8 +27,6 @@ import { randomUUID } from 'crypto';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
|
||||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
||||
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
||||
|
|
@ -691,10 +689,6 @@ export class TeamDataService {
|
|||
);
|
||||
mark('resolveMembers');
|
||||
|
||||
// Enrich members with git branch when it differs from lead's branch
|
||||
await this.enrichMemberBranches(members, config);
|
||||
mark('enrichBranches');
|
||||
|
||||
mark('syncComments');
|
||||
|
||||
let processes: TeamProcess[] = [];
|
||||
|
|
@ -714,9 +708,9 @@ export class TeamDataService {
|
|||
'sentMessages'
|
||||
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
|
||||
'kanbanGc'
|
||||
)} resolveMembers=${msSince('resolveMembers')} enrichBranches=${msSince(
|
||||
'enrichBranches'
|
||||
)} syncComments=${msSince('syncComments')} processes=${msSince('processes')}`
|
||||
)} resolveMembers=${msSince('resolveMembers')} syncComments=${msSince('syncComments')} processes=${msSince(
|
||||
'processes'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -811,68 +805,6 @@ export class TeamDataService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches members with gitBranch when their cwd differs from the lead's.
|
||||
* Mutates members in-place for efficiency (called right after resolveMembers).
|
||||
*/
|
||||
private async enrichMemberBranches(
|
||||
members: ResolvedTeamMember[],
|
||||
config: TeamConfig
|
||||
): Promise<void> {
|
||||
// Determine lead's cwd — prefer explicit member entry, fall back to config.projectPath
|
||||
const leadEntry = config.members?.find((m) => isLeadMember(m));
|
||||
const leadCwd = leadEntry?.cwd ?? config.projectPath;
|
||||
if (!leadCwd) return;
|
||||
|
||||
const withTimeout = async <T>(p: Promise<T>, ms: number): Promise<T> => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
p,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('timeout')), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
let leadBranch: string | null = null;
|
||||
try {
|
||||
// Git can hang on some Windows setups (network drives, locked repos, credential prompts).
|
||||
// Branch is best-effort; never block team:getData on it.
|
||||
leadBranch = await withTimeout(gitIdentityResolver.getBranch(path.normalize(leadCwd)), 2000);
|
||||
} catch {
|
||||
// Lead cwd may not be a git repo — skip enrichment entirely
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = members.filter((m) => m.cwd && m.cwd !== leadCwd);
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
const concurrency = process.platform === 'win32' ? 4 : 8;
|
||||
for (let i = 0; i < candidates.length; i += concurrency) {
|
||||
const batch = candidates.slice(i, i + concurrency);
|
||||
await Promise.all(
|
||||
batch.map(async (member) => {
|
||||
if (!member.cwd) return;
|
||||
try {
|
||||
const branch = await withTimeout(
|
||||
gitIdentityResolver.getBranch(path.normalize(member.cwd)),
|
||||
2000
|
||||
);
|
||||
if (branch && branch !== leadBranch) {
|
||||
member.gitBranch = branch;
|
||||
}
|
||||
} catch {
|
||||
// Member cwd may not be a git repo — skip silently
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a member exists in members.meta.json.
|
||||
* Members can appear in the UI from three sources (see TeamMemberResolver):
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ export class TeamInboxReader {
|
|||
|
||||
return entries
|
||||
.filter((name) => name.endsWith('.json') && !name.startsWith('.'))
|
||||
.map((name) => name.replace(/\.json$/, ''));
|
||||
.map((name) => name.replace(/\.json$/, ''))
|
||||
.filter((name) => name !== '*');
|
||||
}
|
||||
|
||||
async getMessagesFor(teamName: string, member: string): Promise<InboxMessage[]> {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
const tabId = useTabIdOptional();
|
||||
const {
|
||||
fetchPluginCatalog,
|
||||
fetchCliStatus,
|
||||
fetchApiKeys,
|
||||
fetchSkillsCatalog,
|
||||
mcpBrowse,
|
||||
|
|
@ -40,11 +41,14 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
mcpBrowseLoading,
|
||||
skillsLoading,
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
openDashboard,
|
||||
sessions,
|
||||
projects,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
fetchPluginCatalog: s.fetchPluginCatalog,
|
||||
fetchCliStatus: s.fetchCliStatus,
|
||||
fetchApiKeys: s.fetchApiKeys,
|
||||
fetchSkillsCatalog: s.fetchSkillsCatalog,
|
||||
mcpBrowse: s.mcpBrowse,
|
||||
|
|
@ -53,6 +57,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
mcpBrowseLoading: s.mcpBrowseLoading,
|
||||
skillsLoading: s.skillsLoading,
|
||||
cliStatus: s.cliStatus,
|
||||
cliStatusLoading: s.cliStatusLoading,
|
||||
openDashboard: s.openDashboard,
|
||||
sessions: s.sessions,
|
||||
projects: s.projects,
|
||||
}))
|
||||
|
|
@ -115,6 +121,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
void fetchPluginCatalog(projectPath ?? undefined);
|
||||
}, [fetchPluginCatalog, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchCliStatus();
|
||||
}, [fetchCliStatus]);
|
||||
|
||||
// Fetch MCP installed state on mount
|
||||
useEffect(() => {
|
||||
void mcpFetchInstalled(projectPath ?? undefined);
|
||||
|
|
@ -139,6 +149,71 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
}, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]);
|
||||
|
||||
const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
||||
const cliStatusBanner = useMemo(() => {
|
||||
if (cliStatusLoading || cliStatus === null) {
|
||||
return (
|
||||
<div className="bg-surface/70 mx-4 mt-3 flex items-start gap-3 rounded-md border border-border px-4 py-3">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-text-secondary" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text">Checking Claude CLI availability</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
Extensions need Claude CLI to install plugins, run MCP servers, and validate auth.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cliStatus.installed) {
|
||||
return (
|
||||
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-amber-300">Claude CLI is not available</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
Plugin installs are disabled until Claude CLI is installed. Open the Dashboard to
|
||||
install it and retry.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={openDashboard}>
|
||||
Open Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cliStatus.authLoggedIn) {
|
||||
return (
|
||||
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-amber-300">Claude CLI needs sign-in</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
Claude CLI was found
|
||||
{cliStatus.installedVersion ? ` (${cliStatus.installedVersion})` : ''}, but plugin
|
||||
installs are disabled until you sign in from the Dashboard.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={openDashboard}>
|
||||
Open Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-4 py-3">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-emerald-300" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-emerald-300">Claude CLI is ready</p>
|
||||
<p className="mt-0.5 text-xs text-text-muted">
|
||||
Plugins can be installed from this page
|
||||
{cliStatus.installedVersion ? ` using Claude CLI ${cliStatus.installedVersion}` : ''}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [cliStatus, cliStatusLoading, openDashboard]);
|
||||
|
||||
// Browser mode guard
|
||||
if (!api.plugins && !api.mcpRegistry && !api.skills) {
|
||||
|
|
@ -156,6 +231,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{cliStatusBanner}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
|
|
|
|||
|
|
@ -37,9 +37,25 @@ export const InstallButton = ({
|
|||
size = 'sm',
|
||||
errorMessage,
|
||||
}: InstallButtonProps) => {
|
||||
const cliStatus = useStore(useShallow((s) => s.cliStatus));
|
||||
const cliMissing = cliStatus !== null && !cliStatus.installed;
|
||||
const isDisabled = disabled || cliMissing;
|
||||
const { cliStatus, cliStatusLoading } = useStore(
|
||||
useShallow((s) => ({
|
||||
cliStatus: s.cliStatus,
|
||||
cliStatusLoading: s.cliStatusLoading,
|
||||
}))
|
||||
);
|
||||
const cliUnknown = cliStatus === null;
|
||||
const cliMissing = cliStatus?.installed === false;
|
||||
const authMissing = cliStatus?.installed === true && !cliStatus.authLoggedIn;
|
||||
const disableReason = cliStatusLoading
|
||||
? 'Checking Claude CLI status...'
|
||||
: cliUnknown
|
||||
? 'Checking Claude CLI availability...'
|
||||
: cliMissing
|
||||
? 'Claude CLI required. Install it from the Dashboard.'
|
||||
: authMissing
|
||||
? 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.'
|
||||
: null;
|
||||
const isDisabled = disabled || Boolean(disableReason);
|
||||
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -92,23 +108,30 @@ export const InstallButton = ({
|
|||
</Button>
|
||||
);
|
||||
|
||||
if (errorMessage) {
|
||||
const tooltipMessage = disableReason ?? errorMessage;
|
||||
|
||||
if (tooltipMessage) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0}>{retryButton}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-64 text-red-300">{errorMessage}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex max-w-64 flex-col items-end gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0}>{retryButton}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-64 text-red-300">{tooltipMessage}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{errorMessage && !disableReason ? (
|
||||
<p className="text-right text-[11px] leading-4 text-red-300">{errorMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return retryButton;
|
||||
}
|
||||
|
||||
// idle — wrap in tooltip when CLI missing
|
||||
// idle — wrap in tooltip when install is unavailable
|
||||
const button = isInstalled ? (
|
||||
<Button
|
||||
size={size}
|
||||
|
|
@ -139,14 +162,14 @@ export const InstallButton = ({
|
|||
</Button>
|
||||
);
|
||||
|
||||
if (cliMissing) {
|
||||
if (disableReason) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0}>{button}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Claude CLI required</TooltipContent>
|
||||
<TooltipContent className="max-w-64">{disableReason}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
|||
</p>
|
||||
|
||||
{/* Footer: author + version + install button */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-3 text-xs text-text-muted">
|
||||
<span className="truncate">{plugin.author?.name ?? 'Unknown author'}</span>
|
||||
{plugin.version && (
|
||||
|
|
|
|||
|
|
@ -777,17 +777,69 @@ export const TeamDetailView = ({
|
|||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Live git branch polling for the team's project path
|
||||
// Live git branch tracking for the lead project and member worktrees
|
||||
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
||||
const branchSyncPaths = useMemo(
|
||||
() => (teamProjectPath ? [teamProjectPath] : []),
|
||||
[teamProjectPath]
|
||||
);
|
||||
// Live branch sync now uses main-side background tracking instead of renderer polling.
|
||||
const leadProjectPath = useMemo(() => {
|
||||
const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim();
|
||||
return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
|
||||
}, [data?.members, teamProjectPath]);
|
||||
const branchSyncPaths = useMemo(() => {
|
||||
const uniquePaths = new Map<string, string>();
|
||||
const addPath = (candidate: string | null | undefined): void => {
|
||||
const trimmed = candidate?.trim();
|
||||
if (!trimmed) return;
|
||||
const key = normalizePath(trimmed);
|
||||
if (!key || uniquePaths.has(key)) return;
|
||||
uniquePaths.set(key, trimmed);
|
||||
};
|
||||
|
||||
addPath(leadProjectPath);
|
||||
for (const member of data?.members ?? []) {
|
||||
addPath(member.cwd);
|
||||
}
|
||||
|
||||
return Array.from(uniquePaths.values());
|
||||
}, [data?.members, leadProjectPath]);
|
||||
useBranchSync(branchSyncPaths, { live: true });
|
||||
const leadBranch = useStore((s) =>
|
||||
teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null
|
||||
const trackedBranches = useStore(
|
||||
useShallow((s) =>
|
||||
Object.fromEntries(
|
||||
branchSyncPaths.map((projectPath) => {
|
||||
const normalizedPath = normalizePath(projectPath);
|
||||
return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const;
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
const leadBranch = leadProjectPath
|
||||
? (trackedBranches[normalizePath(leadProjectPath)] ?? null)
|
||||
: null;
|
||||
const membersWithLiveBranches = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return data.members.map((member) => {
|
||||
const memberPath = member.cwd?.trim();
|
||||
const nextGitBranch =
|
||||
memberPath && !isLeadMember(member) && leadBranch !== null
|
||||
? (() => {
|
||||
const branch = trackedBranches[normalizePath(memberPath)] ?? null;
|
||||
return branch && branch !== leadBranch ? branch : undefined;
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
if (member.gitBranch === nextGitBranch) {
|
||||
return member;
|
||||
}
|
||||
|
||||
const nextMember: ResolvedTeamMember = { ...member };
|
||||
if (nextGitBranch) {
|
||||
nextMember.gitBranch = nextGitBranch;
|
||||
} else {
|
||||
delete nextMember.gitBranch;
|
||||
}
|
||||
return nextMember;
|
||||
});
|
||||
}, [data, leadBranch, trackedBranches]);
|
||||
|
||||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||||
const teamSessionIds = useMemo(() => {
|
||||
|
|
@ -858,7 +910,7 @@ export const TeamDetailView = ({
|
|||
return result;
|
||||
}, [data, timeWindow, kanbanFilter.selectedOwners]);
|
||||
|
||||
const activeMembers = useStableActiveMembers(data?.members);
|
||||
const activeMembers = useStableActiveMembers(membersWithLiveBranches);
|
||||
|
||||
const kanbanDisplayTasks = useMemo(() => {
|
||||
const query = kanbanSearch.trim();
|
||||
|
|
@ -987,12 +1039,12 @@ export const TeamDetailView = ({
|
|||
const pendingMemberProfile = useStore((s) => s.pendingMemberProfile);
|
||||
useEffect(() => {
|
||||
if (!pendingMemberProfile || !data) return;
|
||||
const member = data.members.find((m) => m.name === pendingMemberProfile);
|
||||
const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile);
|
||||
if (member) {
|
||||
setSelectedMember(member);
|
||||
}
|
||||
useStore.getState().closeMemberProfile();
|
||||
}, [pendingMemberProfile, data]);
|
||||
}, [pendingMemberProfile, membersWithLiveBranches]);
|
||||
|
||||
const handleDeleteTask = useCallback(
|
||||
(taskId: string) => {
|
||||
|
|
@ -1610,7 +1662,7 @@ export const TeamDetailView = ({
|
|||
}
|
||||
>
|
||||
<MemberList
|
||||
members={data.members}
|
||||
members={membersWithLiveBranches}
|
||||
memberTaskCounts={memberTaskCounts}
|
||||
taskMap={taskMap}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
|
|
@ -1977,7 +2029,7 @@ export const TeamDetailView = ({
|
|||
currentName={data.config.name}
|
||||
currentDescription={data.config.description ?? ''}
|
||||
currentColor={data.config.color ?? ''}
|
||||
currentMembers={data.members.filter((m) => !isLeadMember(m))}
|
||||
currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))}
|
||||
projectPath={data.config.projectPath}
|
||||
onClose={() => setEditDialogOpen(false)}
|
||||
onSaved={() => void selectTeam(teamName)}
|
||||
|
|
@ -1986,8 +2038,8 @@ export const TeamDetailView = ({
|
|||
<AddMemberDialog
|
||||
open={addMemberDialogOpen}
|
||||
teamName={teamName}
|
||||
existingNames={data.members.map((m) => m.name)}
|
||||
existingMembers={data.members}
|
||||
existingNames={membersWithLiveBranches.map((m) => m.name)}
|
||||
existingMembers={membersWithLiveBranches}
|
||||
projectPath={data.config.projectPath}
|
||||
adding={addingMemberLoading}
|
||||
onClose={() => setAddMemberDialogOpen(false)}
|
||||
|
|
@ -2070,7 +2122,7 @@ export const TeamDetailView = ({
|
|||
mode="launch"
|
||||
open={launchDialogOpen}
|
||||
teamName={teamName}
|
||||
members={data?.members ?? []}
|
||||
members={membersWithLiveBranches}
|
||||
defaultProjectPath={data.config.projectPath}
|
||||
provisioningError={provisioningError}
|
||||
clearProvisioningError={clearProvisioningError}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type {
|
||||
|
|
@ -143,6 +144,10 @@ function getSkillsCatalogKey(projectPath?: string): string {
|
|||
|
||||
/** Duration to show "success" state before returning to idle */
|
||||
const SUCCESS_DISPLAY_MS = 2_000;
|
||||
const CLI_AUTH_REQUIRED_MESSAGE =
|
||||
'Claude CLI is installed but not signed in. Go to the Dashboard and sign in to enable plugin installs.';
|
||||
const CLI_STATUS_UNKNOWN_MESSAGE =
|
||||
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.';
|
||||
|
||||
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
|
||||
set,
|
||||
|
|
@ -552,8 +557,36 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
installPlugin: async (request: PluginInstallRequest) => {
|
||||
if (!api.plugins) return;
|
||||
|
||||
const preflightState = get();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
try {
|
||||
await preflightState.fetchCliStatus();
|
||||
} catch {
|
||||
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
|
||||
}
|
||||
}
|
||||
|
||||
const cliStatus = get().cliStatus;
|
||||
const preflightError =
|
||||
cliStatus === null
|
||||
? CLI_STATUS_UNKNOWN_MESSAGE
|
||||
: !cliStatus.installed
|
||||
? CLI_NOT_FOUND_MESSAGE
|
||||
: !cliStatus.authLoggedIn
|
||||
? CLI_AUTH_REQUIRED_MESSAGE
|
||||
: null;
|
||||
|
||||
if (preflightError) {
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
|
||||
installErrors: { ...prev.installErrors, [request.pluginId]: '' },
|
||||
}));
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -262,6 +262,17 @@ describe('extensionsSlice', () => {
|
|||
|
||||
describe('installPlugin', () => {
|
||||
it('sets progress to pending then success', async () => {
|
||||
store.setState({
|
||||
cliStatus: {
|
||||
installed: true,
|
||||
installedVersion: '1.0.0',
|
||||
binaryPath: '/usr/local/bin/claude',
|
||||
latestVersion: '1.0.0',
|
||||
updateAvailable: false,
|
||||
authLoggedIn: true,
|
||||
authMethod: 'oauth_token',
|
||||
},
|
||||
});
|
||||
const plugins = [makePlugin({ pluginId: 'a@m' })];
|
||||
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
|
||||
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||
|
|
@ -276,6 +287,17 @@ describe('extensionsSlice', () => {
|
|||
});
|
||||
|
||||
it('sets progress to error on failure', async () => {
|
||||
store.setState({
|
||||
cliStatus: {
|
||||
installed: true,
|
||||
installedVersion: '1.0.0',
|
||||
binaryPath: '/usr/local/bin/claude',
|
||||
latestVersion: '1.0.0',
|
||||
updateAvailable: false,
|
||||
authLoggedIn: true,
|
||||
authMethod: 'oauth_token',
|
||||
},
|
||||
});
|
||||
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
state: 'error',
|
||||
error: 'Not found',
|
||||
|
|
|
|||
Loading…
Reference in a new issue