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:
Artem Rootman 2026-04-05 17:19:41 +00:00
commit f07eb914ae
No known key found for this signature in database
GPG key ID: B7C30676209A822C
8 changed files with 243 additions and 104 deletions

View file

@ -27,8 +27,6 @@ import { randomUUID } from 'crypto';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
import { atomicWriteAsync } from './atomicWrite'; import { atomicWriteAsync } from './atomicWrite';
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
@ -691,10 +689,6 @@ export class TeamDataService {
); );
mark('resolveMembers'); mark('resolveMembers');
// Enrich members with git branch when it differs from lead's branch
await this.enrichMemberBranches(members, config);
mark('enrichBranches');
mark('syncComments'); mark('syncComments');
let processes: TeamProcess[] = []; let processes: TeamProcess[] = [];
@ -714,9 +708,9 @@ export class TeamDataService {
'sentMessages' 'sentMessages'
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince( )} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
'kanbanGc' 'kanbanGc'
)} resolveMembers=${msSince('resolveMembers')} enrichBranches=${msSince( )} resolveMembers=${msSince('resolveMembers')} syncComments=${msSince('syncComments')} processes=${msSince(
'enrichBranches' 'processes'
)} 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. * Ensures a member exists in members.meta.json.
* Members can appear in the UI from three sources (see TeamMemberResolver): * Members can appear in the UI from three sources (see TeamMemberResolver):

View file

@ -44,7 +44,8 @@ export class TeamInboxReader {
return entries return entries
.filter((name) => name.endsWith('.json') && !name.startsWith('.')) .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[]> { async getMessagesFor(teamName: string, member: string): Promise<InboxMessage[]> {

View file

@ -32,6 +32,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
const tabId = useTabIdOptional(); const tabId = useTabIdOptional();
const { const {
fetchPluginCatalog, fetchPluginCatalog,
fetchCliStatus,
fetchApiKeys, fetchApiKeys,
fetchSkillsCatalog, fetchSkillsCatalog,
mcpBrowse, mcpBrowse,
@ -40,11 +41,14 @@ export const ExtensionStoreView = (): React.JSX.Element => {
mcpBrowseLoading, mcpBrowseLoading,
skillsLoading, skillsLoading,
cliStatus, cliStatus,
cliStatusLoading,
openDashboard,
sessions, sessions,
projects, projects,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
fetchPluginCatalog: s.fetchPluginCatalog, fetchPluginCatalog: s.fetchPluginCatalog,
fetchCliStatus: s.fetchCliStatus,
fetchApiKeys: s.fetchApiKeys, fetchApiKeys: s.fetchApiKeys,
fetchSkillsCatalog: s.fetchSkillsCatalog, fetchSkillsCatalog: s.fetchSkillsCatalog,
mcpBrowse: s.mcpBrowse, mcpBrowse: s.mcpBrowse,
@ -53,6 +57,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
mcpBrowseLoading: s.mcpBrowseLoading, mcpBrowseLoading: s.mcpBrowseLoading,
skillsLoading: s.skillsLoading, skillsLoading: s.skillsLoading,
cliStatus: s.cliStatus, cliStatus: s.cliStatus,
cliStatusLoading: s.cliStatusLoading,
openDashboard: s.openDashboard,
sessions: s.sessions, sessions: s.sessions,
projects: s.projects, projects: s.projects,
})) }))
@ -115,6 +121,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
void fetchPluginCatalog(projectPath ?? undefined); void fetchPluginCatalog(projectPath ?? undefined);
}, [fetchPluginCatalog, projectPath]); }, [fetchPluginCatalog, projectPath]);
useEffect(() => {
void fetchCliStatus();
}, [fetchCliStatus]);
// Fetch MCP installed state on mount // Fetch MCP installed state on mount
useEffect(() => { useEffect(() => {
void mcpFetchInstalled(projectPath ?? undefined); void mcpFetchInstalled(projectPath ?? undefined);
@ -139,6 +149,71 @@ export const ExtensionStoreView = (): React.JSX.Element => {
}, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]); }, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]);
const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading; 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 // Browser mode guard
if (!api.plugins && !api.mcpRegistry && !api.skills) { if (!api.plugins && !api.mcpRegistry && !api.skills) {
@ -156,6 +231,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{cliStatusBanner}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4"> <div className="flex items-center justify-between px-6 py-4">

View file

@ -37,9 +37,25 @@ export const InstallButton = ({
size = 'sm', size = 'sm',
errorMessage, errorMessage,
}: InstallButtonProps) => { }: InstallButtonProps) => {
const cliStatus = useStore(useShallow((s) => s.cliStatus)); const { cliStatus, cliStatusLoading } = useStore(
const cliMissing = cliStatus !== null && !cliStatus.installed; useShallow((s) => ({
const isDisabled = disabled || cliMissing; 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); const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
useEffect(() => { useEffect(() => {
@ -92,23 +108,30 @@ export const InstallButton = ({
</Button> </Button>
); );
if (errorMessage) { const tooltipMessage = disableReason ?? errorMessage;
if (tooltipMessage) {
return ( return (
<TooltipProvider> <div className="flex max-w-64 flex-col items-end gap-1">
<Tooltip> <TooltipProvider>
<TooltipTrigger asChild> <Tooltip>
<span tabIndex={0}>{retryButton}</span> <TooltipTrigger asChild>
</TooltipTrigger> <span tabIndex={0}>{retryButton}</span>
<TooltipContent className="max-w-64 text-red-300">{errorMessage}</TooltipContent> </TooltipTrigger>
</Tooltip> <TooltipContent className="max-w-64 text-red-300">{tooltipMessage}</TooltipContent>
</TooltipProvider> </Tooltip>
</TooltipProvider>
{errorMessage && !disableReason ? (
<p className="text-right text-[11px] leading-4 text-red-300">{errorMessage}</p>
) : null}
</div>
); );
} }
return retryButton; return retryButton;
} }
// idle — wrap in tooltip when CLI missing // idle — wrap in tooltip when install is unavailable
const button = isInstalled ? ( const button = isInstalled ? (
<Button <Button
size={size} size={size}
@ -139,14 +162,14 @@ export const InstallButton = ({
</Button> </Button>
); );
if (cliMissing) { if (disableReason) {
return ( return (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span tabIndex={0}>{button}</span> <span tabIndex={0}>{button}</span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Claude CLI required</TooltipContent> <TooltipContent className="max-w-64">{disableReason}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );

View file

@ -98,7 +98,7 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
</p> </p>
{/* Footer: author + version + install button */} {/* 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"> <div className="flex min-w-0 items-center gap-3 text-xs text-text-muted">
<span className="truncate">{plugin.author?.name ?? 'Unknown author'}</span> <span className="truncate">{plugin.author?.name ?? 'Unknown author'}</span>
{plugin.version && ( {plugin.version && (

View file

@ -777,17 +777,69 @@ export const TeamDetailView = ({
}; };
}, [projectId]); }, [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 teamProjectPath = data?.config.projectPath?.trim() ?? null;
const branchSyncPaths = useMemo( const leadProjectPath = useMemo(() => {
() => (teamProjectPath ? [teamProjectPath] : []), const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim();
[teamProjectPath] return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
); }, [data?.members, teamProjectPath]);
// Live branch sync now uses main-side background tracking instead of renderer polling. 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 }); useBranchSync(branchSyncPaths, { live: true });
const leadBranch = useStore((s) => const trackedBranches = useStore(
teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null 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 // Filter sessions to team-only using sessionHistory + leadSessionId
const teamSessionIds = useMemo(() => { const teamSessionIds = useMemo(() => {
@ -858,7 +910,7 @@ export const TeamDetailView = ({
return result; return result;
}, [data, timeWindow, kanbanFilter.selectedOwners]); }, [data, timeWindow, kanbanFilter.selectedOwners]);
const activeMembers = useStableActiveMembers(data?.members); const activeMembers = useStableActiveMembers(membersWithLiveBranches);
const kanbanDisplayTasks = useMemo(() => { const kanbanDisplayTasks = useMemo(() => {
const query = kanbanSearch.trim(); const query = kanbanSearch.trim();
@ -987,12 +1039,12 @@ export const TeamDetailView = ({
const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); const pendingMemberProfile = useStore((s) => s.pendingMemberProfile);
useEffect(() => { useEffect(() => {
if (!pendingMemberProfile || !data) return; if (!pendingMemberProfile || !data) return;
const member = data.members.find((m) => m.name === pendingMemberProfile); const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile);
if (member) { if (member) {
setSelectedMember(member); setSelectedMember(member);
} }
useStore.getState().closeMemberProfile(); useStore.getState().closeMemberProfile();
}, [pendingMemberProfile, data]); }, [pendingMemberProfile, membersWithLiveBranches]);
const handleDeleteTask = useCallback( const handleDeleteTask = useCallback(
(taskId: string) => { (taskId: string) => {
@ -1610,7 +1662,7 @@ export const TeamDetailView = ({
} }
> >
<MemberList <MemberList
members={data.members} members={membersWithLiveBranches}
memberTaskCounts={memberTaskCounts} memberTaskCounts={memberTaskCounts}
taskMap={taskMap} taskMap={taskMap}
pendingRepliesByMember={pendingRepliesByMember} pendingRepliesByMember={pendingRepliesByMember}
@ -1977,7 +2029,7 @@ export const TeamDetailView = ({
currentName={data.config.name} currentName={data.config.name}
currentDescription={data.config.description ?? ''} currentDescription={data.config.description ?? ''}
currentColor={data.config.color ?? ''} currentColor={data.config.color ?? ''}
currentMembers={data.members.filter((m) => !isLeadMember(m))} currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))}
projectPath={data.config.projectPath} projectPath={data.config.projectPath}
onClose={() => setEditDialogOpen(false)} onClose={() => setEditDialogOpen(false)}
onSaved={() => void selectTeam(teamName)} onSaved={() => void selectTeam(teamName)}
@ -1986,8 +2038,8 @@ export const TeamDetailView = ({
<AddMemberDialog <AddMemberDialog
open={addMemberDialogOpen} open={addMemberDialogOpen}
teamName={teamName} teamName={teamName}
existingNames={data.members.map((m) => m.name)} existingNames={membersWithLiveBranches.map((m) => m.name)}
existingMembers={data.members} existingMembers={membersWithLiveBranches}
projectPath={data.config.projectPath} projectPath={data.config.projectPath}
adding={addingMemberLoading} adding={addingMemberLoading}
onClose={() => setAddMemberDialogOpen(false)} onClose={() => setAddMemberDialogOpen(false)}
@ -2070,7 +2122,7 @@ export const TeamDetailView = ({
mode="launch" mode="launch"
open={launchDialogOpen} open={launchDialogOpen}
teamName={teamName} teamName={teamName}
members={data?.members ?? []} members={membersWithLiveBranches}
defaultProjectPath={data.config.projectPath} defaultProjectPath={data.config.projectPath}
provisioningError={provisioningError} provisioningError={provisioningError}
clearProvisioningError={clearProvisioningError} clearProvisioningError={clearProvisioningError}

View file

@ -4,6 +4,7 @@
*/ */
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import type { AppState } from '../types'; import type { AppState } from '../types';
import type { import type {
@ -143,6 +144,10 @@ function getSkillsCatalogKey(projectPath?: string): string {
/** Duration to show "success" state before returning to idle */ /** Duration to show "success" state before returning to idle */
const SUCCESS_DISPLAY_MS = 2_000; 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> = ( export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
set, set,
@ -552,8 +557,36 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
installPlugin: async (request: PluginInstallRequest) => { installPlugin: async (request: PluginInstallRequest) => {
if (!api.plugins) return; 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) => ({ set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' }, pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
installErrors: { ...prev.installErrors, [request.pluginId]: '' },
})); }));
try { try {

View file

@ -262,6 +262,17 @@ describe('extensionsSlice', () => {
describe('installPlugin', () => { describe('installPlugin', () => {
it('sets progress to pending then success', async () => { 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' })]; const plugins = [makePlugin({ pluginId: 'a@m' })];
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins); (api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' }); (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 () => { 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({ (api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({
state: 'error', state: 'error',
error: 'Not found', error: 'Not found',