Compare commits

..

9 Commits

Author SHA1 Message Date
Simos Mikelatos
9bcd82834c Merge branch 'main' into feat/claude-codex-effort 2026-07-03 17:30:19 +02:00
Haileyesus
10a0e3ff8d fix: use portal for showing effort dropdown 2026-07-01 23:41:02 +03:00
Haileyesus
78968fe33c chore: update gpt fallback models 2026-07-01 23:40:22 +03:00
Haileyesus
7785763c14 feat: add effort support for opencode 2026-07-01 23:32:13 +03:00
Simos Mikelatos
85e8facfbb Merge branch 'main' into feat/claude-codex-effort 2026-07-01 16:09:10 +02:00
Simos Mikelatos
ff4f8d6ac7 fix: pass effort through external agent api 2026-07-01 04:44:27 +00:00
Simos Mikelatos
eb08aadaa0 fix: reconcile provider effort after model changes 2026-07-01 00:08:32 +00:00
Simos Mikelatos
0206a1f6aa refactor: generic provider effort handling 2026-06-30 23:56:01 +00:00
Simos Mikelatos
d618abb075 feat: add Claude and Codex effort controls 2026-06-30 23:29:57 +00:00
16 changed files with 322 additions and 626 deletions

View File

@@ -3,12 +3,6 @@
All notable changes to CloudCLI UI will be documented in this file. All notable changes to CloudCLI UI will be documented in this file.
## [](https://github.com/siteboon/claudecodeui/compare/v1.35.1...vnull) (2026-07-03)
### New Features
* add Claude and Codex effort controls ([#943](https://github.com/siteboon/claudecodeui/issues/943)) ([d272922](https://github.com/siteboon/claudecodeui/commit/d272922d87e4ee74bf8b0fdeac83b2c1e77973f3))
## [1.35.1](https://github.com/siteboon/claudecodeui/compare/v1.35.0...v1.35.1) (2026-07-01) ## [1.35.1](https://github.com/siteboon/claudecodeui/compare/v1.35.0...v1.35.1) (2026-07-01)
### Bug Fixes ### Bug Fixes

View File

@@ -126,7 +126,7 @@ CloudCLI UI ist die Open-Source-UI-Schicht, die CloudCLI Cloud antreibt. Du kann
| **REST API** | Ja | Ja | | **REST API** | Ja | Ja |
| **n8n-Node** | Nein | Ja | | **n8n-Node** | Nein | Ja |
| **Team-Sharing** | Nein | Ja | | **Team-Sharing** | Nein | Ja |
| **Plattformkosten** | Kostenlos, Open Source | Ab 7/Monat | | **Plattformkosten** | Kostenlos, Open Source | Ab $7/Monat |
> Beide Optionen verwenden deine eigenen KI-Abonnements (Claude, Cursor usw.) CloudCLI stellt die Umgebung bereit, nicht die KI. > Beide Optionen verwenden deine eigenen KI-Abonnements (Claude, Cursor usw.) CloudCLI stellt die Umgebung bereit, nicht die KI.
@@ -202,7 +202,7 @@ Das bedeutet in der Praxis:
<details> <details>
<summary>Muss ich ein KI-Abonnement separat bezahlen?</summary> <summary>Muss ich ein KI-Abonnement separat bezahlen?</summary>
Ja. CloudCLI stellt die Umgebung bereit, nicht die KI. Du bringst dein eigenes Claude-, Cursor-, Codex- oder Gemini-Abonnement mit. CloudCLI Cloud beginnt bei 7/Monat für die gehostete Umgebung zusätzlich dazu. Ja. CloudCLI stellt die Umgebung bereit, nicht die KI. Du bringst dein eigenes Claude-, Cursor-, Codex- oder Gemini-Abonnement mit. CloudCLI Cloud beginnt bei $7/Monat für die gehostete Umgebung zusätzlich dazu.
</details> </details>

View File

@@ -122,7 +122,7 @@ CloudCLI UI は、CloudCLI Cloud を支えるオープンソースの UI レイ
| **REST API** | はい | はい | | **REST API** | はい | はい |
| **n8n ノード** | いいえ | はい | | **n8n ノード** | いいえ | はい |
| **チーム共有** | いいえ | はい | | **チーム共有** | いいえ | はい |
| **料金プラン** | 無料(オープンソース) | 月 7〜 | | **料金プラン** | 無料(オープンソース) | 月 $7〜 |
> どちらの選択肢でも、AI のサブスクリプションClaude、Cursor など)はご自身のものを使用します — CloudCLI が提供するのは環境であり、AI そのものではありません。 > どちらの選択肢でも、AI のサブスクリプションClaude、Cursor など)はご自身のものを使用します — CloudCLI が提供するのは環境であり、AI そのものではありません。
@@ -194,7 +194,7 @@ CloudCLI UI と CloudCLI Cloud は、Claude Code の横に別物として存在
<details> <details>
<summary>AI のサブスクリプションは別途支払いが必要ですか?</summary> <summary>AI のサブスクリプションは別途支払いが必要ですか?</summary>
はい。CloudCLI は環境を提供するものであり、AI は含まれません。Claude、Cursor、Codex、または Gemini のサブスクリプションはご自身でご用意ください。CloudCLI Cloud のホスティング環境はそれに加えて月額 7 から提供されます。 はい。CloudCLI は環境を提供するものであり、AI は含まれません。Claude、Cursor、Codex、または Gemini のサブスクリプションはご自身でご用意ください。CloudCLI Cloud のホスティング環境はそれに加えて月額 $7 から提供されます。
</details> </details>

View File

@@ -122,7 +122,7 @@ CloudCLI UI는 CloudCLI Cloud를 구동하는 오픈 소스 UI 계층입니다.
| **REST API** | 예 | 예 | | **REST API** | 예 | 예 |
| **n8n 노드** | 아니오 | 예 | | **n8n 노드** | 아니오 | 예 |
| **팀 공유** | 아니오 | 예 | | **팀 공유** | 아니오 | 예 |
| **플랫폼 비용** | 무료, 오픈 소스 | 월 7부터 | | **플랫폼 비용** | 무료, 오픈 소스 | 월 $7부터 |
> 둘 다 자체 AI 구독(Claude, Cursor 등)을 그대로 사용합니다 — CloudCLI는 환경만 제공합니다. > 둘 다 자체 AI 구독(Claude, Cursor 등)을 그대로 사용합니다 — CloudCLI는 환경만 제공합니다.
@@ -195,7 +195,7 @@ CloudCLI UI와 CloudCLI Cloud는 Claude Code를 확장하며 별도로 존재하
<details> <details>
<summary>AI 구독을 별도로 결제해야 하나요?</summary> <summary>AI 구독을 별도로 결제해야 하나요?</summary>
네. CloudCLI는 환경만 제공합니다. Claude, Cursor, Codex, Gemini 구독 비용은 별도로 부과됩니다. CloudCLI Cloud는 관리형 환경을 월 7부터 제공합니다. 네. CloudCLI는 환경만 제공합니다. Claude, Cursor, Codex, Gemini 구독 비용은 별도로 부과됩니다. CloudCLI Cloud는 관리형 환경을 월 $7부터 제공합니다.
</details> </details>

View File

@@ -136,7 +136,7 @@ CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI | | **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
| **REST API** | Yes | Yes | Yes | | **REST API** | Yes | Yes | Yes |
| **Team sharing** | No | No | Yes | | **Team sharing** | No | No | Yes |
| **Platform cost** | Free, open source | Free, open source | Starts at 7/month | | **Platform cost** | Free, open source | Free, open source | Starts at $7/month |
> All options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI. > All options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI.
@@ -212,7 +212,7 @@ Here's what that means in practice:
<details> <details>
<summary>Do I need to pay for an AI subscription separately?</summary> <summary>Do I need to pay for an AI subscription separately?</summary>
Yes. CloudCLI provides the environment, not the AI. You bring your own Claude, Cursor, Codex, or Gemini subscription. CloudCLI Cloud starts at 7/month for the hosted environment on top of that. Yes. CloudCLI provides the environment, not the AI. You bring your own Claude, Cursor, Codex, or Gemini subscription. CloudCLI Cloud starts at $7/month for the hosted environment on top of that.
</details> </details>

View File

@@ -126,7 +126,7 @@ CloudCLI UI — это open source UI-слой, на котором постро
| **REST API** | Да | Да | | **REST API** | Да | Да |
| **n8n node** | Нет | Да | | **n8n node** | Нет | Да |
| **Совместная работа** | Нет | Да | | **Совместная работа** | Нет | Да |
| **Стоимость платформы** | Бесплатно, open source | От 7/месяц | | **Стоимость платформы** | Бесплатно, open source | От $7/месяц |
> В обоих вариантах используются ваши собственные AI-подписки (Claude, Cursor и т.д.) — CloudCLI предоставляет среду, а не сам AI. > В обоих вариантах используются ваши собственные AI-подписки (Claude, Cursor и т.д.) — CloudCLI предоставляет среду, а не сам AI.
@@ -202,7 +202,7 @@ CloudCLI UI и CloudCLI Cloud расширяют Claude Code, а не работ
<details> <details>
<summary>Нужно ли отдельно платить за AI-подписку?</summary> <summary>Нужно ли отдельно платить за AI-подписку?</summary>
Да. CloudCLI предоставляет среду, а не сам AI. Вы приносите свою подписку Claude, Cursor, Codex или Gemini. CloudCLI Cloud начинается от 7/месяц за хостируемую среду поверх этого. Да. CloudCLI предоставляет среду, а не сам AI. Вы приносите свою подписку Claude, Cursor, Codex или Gemini. CloudCLI Cloud начинается от $7/месяц за хостируемую среду поверх этого.
</details> </details>

View File

@@ -125,7 +125,7 @@ CloudCLI UI, CloudCLI Cloud'u güçlendiren açık kaynak arayüz katmanıdır.
| **MCP yapılandırması** | `~/.claude` ile senkron | UI üzerinden yönetilir | UI üzerinden yönetilir | | **MCP yapılandırması** | `~/.claude` ile senkron | UI üzerinden yönetilir | UI üzerinden yönetilir |
| **REST API** | Evet | Evet | Evet | | **REST API** | Evet | Evet | Evet |
| **Ekip paylaşımı** | Hayır | Hayır | Evet | | **Ekip paylaşımı** | Hayır | Hayır | Evet |
| **Platform maliyeti** | Ücretsiz, açık kaynak | Ücretsiz, açık kaynak | Aylık 7 'dan başlar | | **Platform maliyeti** | Ücretsiz, açık kaynak | Ücretsiz, açık kaynak | Aylık 7 $'dan başlar |
> Tüm seçenekler kendi AI aboneliklerini (Claude, Cursor, vb.) kullanır — CloudCLI AI'ı değil, ortamı sağlar. > Tüm seçenekler kendi AI aboneliklerini (Claude, Cursor, vb.) kullanır — CloudCLI AI'ı değil, ortamı sağlar.
@@ -201,7 +201,7 @@ Pratikte bu ne demek:
<details> <details>
<summary>AI aboneliği için ayrıca ödeme yapmam gerekiyor mu?</summary> <summary>AI aboneliği için ayrıca ödeme yapmam gerekiyor mu?</summary>
Evet. CloudCLI AI'yi değil, ortamı sağlar. Kendi Claude, Cursor, Codex veya Gemini aboneliğini getirirsin. CloudCLI Cloud, barındırılan ortam için aylık 7 'dan başlar — bunun üzerine eklenir. Evet. CloudCLI AI'yi değil, ortamı sağlar. Kendi Claude, Cursor, Codex veya Gemini aboneliğini getirirsin. CloudCLI Cloud, barındırılan ortam için aylık 7 $'dan başlar — bunun üzerine eklenir.
</details> </details>

View File

@@ -122,7 +122,7 @@ CloudCLI UI 是 CloudCLI Cloud 的开源 UI 层。你可以在本地机器上自
| **REST API** | 是 | 是 | | **REST API** | 是 | 是 |
| **n8n 节点** | 否 | 是 | | **n8n 节点** | 否 | 是 |
| **团队共享** | 否 | 是 | | **团队共享** | 否 | 是 |
| **平台费用** | 免费开源 | 起价 7/月 | | **平台费用** | 免费开源 | 起价 $7/月 |
> 两种方式都使用你自己的 AI 订阅Claude、Cursor 等)— CloudCLI 提供环境,而非 AI。 > 两种方式都使用你自己的 AI 订阅Claude、Cursor 等)— CloudCLI 提供环境,而非 AI。
@@ -195,7 +195,7 @@ CloudCLI UI 与 CloudCLI Cloud 是对 Claude Code 的扩展,而非旁观 — M
<details> <details>
<summary>需要额外购买 AI 订阅吗?</summary> <summary>需要额外购买 AI 订阅吗?</summary>
需要。CloudCLI 只提供环境。你仍需自行获取 Claude、Cursor、Codex 或 Gemini 订阅。CloudCLI Cloud 从 7/月起提供托管环境。 需要。CloudCLI 只提供环境。你仍需自行获取 Claude、Cursor、Codex 或 Gemini 订阅。CloudCLI Cloud 从 $7/月起提供托管环境。
</details> </details>

View File

@@ -122,7 +122,7 @@ CloudCLI UI 是 CloudCLI Cloud 的開源 UI 層。你可以在本機上自架它
| **REST API** | 是 | 是 | | **REST API** | 是 | 是 |
| **n8n 節點** | 否 | 是 | | **n8n 節點** | 否 | 是 |
| **團隊共享** | 否 | 是 | | **團隊共享** | 否 | 是 |
| **平台費用** | 免費開源 | 起價 7/月 | | **平台費用** | 免費開源 | 起價 $7/月 |
> 兩種方式都使用你自己的 AI 訂閱Claude、Cursor 等)— CloudCLI 提供環境,而非 AI。 > 兩種方式都使用你自己的 AI 訂閱Claude、Cursor 等)— CloudCLI 提供環境,而非 AI。
@@ -195,7 +195,7 @@ CloudCLI UI 與 CloudCLI Cloud 是對 Claude Code 的擴充,而非旁觀 — M
<details> <details>
<summary>需要額外購買 AI 訂閱嗎?</summary> <summary>需要額外購買 AI 訂閱嗎?</summary>
需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor、Codex 或 Gemini 訂閱。CloudCLI Cloud 從 7/月起提供託管環境。 需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor、Codex 或 Gemini 訂閱。CloudCLI Cloud 從 $7/月起提供託管環境。
</details> </details>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.36.0", "version": "1.35.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.36.0", "version": "1.35.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.36.0", "version": "1.35.1",
"productName": "CloudCLI", "productName": "CloudCLI",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import type { McpProject, McpProvider, McpScope, ProviderMcpServer } from '../types'; import type { McpProject, McpProvider, McpScope, ProviderMcpServer } from '../types';
import { IS_PLATFORM } from '../../../constants/config'; import { IS_PLATFORM } from '../../../constants/config';
import { ActionMenu, Badge, Button } from '../../../shared/view/ui'; import { Badge, Button } from '../../../shared/view/ui';
import { import {
MCP_GLOBAL_SUPPORTED_SCOPES, MCP_GLOBAL_SUPPORTED_SCOPES,
MCP_GLOBAL_SUPPORTED_TRANSPORTS, MCP_GLOBAL_SUPPORTED_TRANSPORTS,
@@ -134,40 +134,33 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex items-center gap-3">
<div className="flex min-w-0 items-start gap-3"> <Server className="h-5 w-5 text-primary" />
<Server className="mt-0.5 h-5 w-5 flex-shrink-0 text-purple-500" />
<div className="min-w-0 space-y-1">
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3> <h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{description}</p> <p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>
<ActionMenu
label="Add MCP Server"
icon={Plus}
className="w-full sm:w-auto"
triggerClassName={`w-full sm:w-auto ${MCP_PROVIDER_BUTTON_CLASSES[selectedProvider]}`}
items={[
{
key: 'global',
label: globalButtonLabel,
description: globalAddDescription,
icon: Globe,
onSelect: openGlobalForm,
},
{
key: 'provider',
label: providerButtonLabel,
description: providerAddDescription,
icon: Server,
onSelect: () => openForm(),
},
]}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={openGlobalForm}
className={MCP_PROVIDER_BUTTON_CLASSES[selectedProvider]}
size="sm"
title={globalAddDescription}
>
<Plus className="mr-2 h-4 w-4" />
{globalButtonLabel}
</Button>
<Button
onClick={() => openForm()}
variant="outline"
size="sm"
title={providerAddDescription}
>
<Plus className="mr-2 h-4 w-4" />
{providerButtonLabel}
</Button>
</div>
<div className="min-h-4"> <div className="min-h-4">
{saveStatus === 'success' && ( {saveStatus === 'success' && (
<span className="animate-in fade-in text-xs text-muted-foreground">{t('saveStatus.success')}</span> <span className="animate-in fade-in text-xs text-muted-foreground">{t('saveStatus.success')}</span>

View File

@@ -7,7 +7,6 @@ import {
FileUp, FileUp,
FolderUp, FolderUp,
Loader2, Loader2,
Plus,
RefreshCw, RefreshCw,
Search, Search,
Upload, Upload,
@@ -19,9 +18,11 @@ import { cn } from '../../../lib/utils';
import { import {
Badge, Badge,
Button, Button,
Dialog, Card,
DialogContent, CardContent,
DialogTitle, CardDescription,
CardHeader,
CardTitle,
Input, Input,
} from '../../../shared/view/ui'; } from '../../../shared/view/ui';
import { useProviderSkills } from '../hooks/useProviderSkills'; import { useProviderSkills } from '../hooks/useProviderSkills';
@@ -214,12 +215,9 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
const [queuedFiles, setQueuedFiles] = useState<QueuedSkillFile[]>([]); const [queuedFiles, setQueuedFiles] = useState<QueuedSkillFile[]>([]);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [justInstalled, setJustInstalled] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [showInstallPath, setShowInstallPath] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement | null>(null); const folderInputRef = useRef<HTMLInputElement>(null);
const providerName = PROVIDER_NAMES[selectedProvider]; const providerName = PROVIDER_NAMES[selectedProvider];
const providerPath = selectedProvider === 'opencode' ? null : PROVIDER_SKILL_PATHS[selectedProvider]; const providerPath = selectedProvider === 'opencode' ? null : PROVIDER_SKILL_PATHS[selectedProvider];
@@ -229,19 +227,11 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
setSubmitError(null); setSubmitError(null);
setIsSubmitting(false); setIsSubmitting(false);
setSearchQuery(''); setSearchQuery('');
setIsAddDialogOpen(false);
setShowInstallPath(false);
setJustInstalled(false);
}, [selectedProvider]); }, [selectedProvider]);
const setFolderInputRef = useCallback((node: HTMLInputElement | null) => { useEffect(() => {
folderInputRef.current = node; folderInputRef.current?.setAttribute('webkitdirectory', '');
if (!node) { folderInputRef.current?.setAttribute('directory', '');
return;
}
node.setAttribute('webkitdirectory', '');
node.setAttribute('directory', '');
}, []); }, []);
const filteredSkills = useMemo(() => { const filteredSkills = useMemo(() => {
@@ -364,8 +354,6 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
}))); })));
await addSkills({ entries }); await addSkills({ entries });
setQueuedFiles([]); setQueuedFiles([]);
setJustInstalled(true);
setIsAddDialogOpen(false);
} catch (error) { } catch (error) {
setSubmitError(error instanceof Error ? error.message : 'Failed to import skills'); setSubmitError(error instanceof Error ? error.message : 'Failed to import skills');
} finally { } finally {
@@ -373,28 +361,50 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
} }
}, [addSkills, queuedFiles]); }, [addSkills, queuedFiles]);
const handleAddDialogOpenChange = useCallback((open: boolean) => { return (
if (open) { <div className="min-w-0 space-y-4 overflow-x-hidden">
setSubmitError(null); <div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
setShowInstallPath(false); <div className="flex min-w-0 items-start gap-3">
setJustInstalled(false); <div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground">
setIsAddDialogOpen(true); <FileCode2 className="h-4 w-4" strokeWidth={1.7} />
return; </div>
} <div className="min-w-0 space-y-1">
<h3 className="text-lg font-medium text-foreground">{t('tabs.skills', { defaultValue: 'Skills' })}</h3>
<p className="text-sm text-muted-foreground">
Install global {providerName} skills from `.md` files or complete skill folders.
</p>
</div>
</div>
setQueuedFiles([]); <Button
setSubmitError(null); onClick={() => void refreshSkills({ force: true })}
setShowInstallPath(false); variant="outline"
setJustInstalled(false); size="sm"
setIsAddDialogOpen(false); className="w-full sm:w-auto"
}, []); disabled={isLoading || isLoadingProjectScopes}
>
<RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
Refresh
</Button>
</div>
const uploadPanel = ( <Card className="min-w-0 overflow-hidden border-border/70 bg-background shadow-sm">
<CardHeader className="space-y-3 border-b border-border/60 bg-muted/20">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<div className="text-sm font-medium text-foreground">Upload Skills</div>
<div className="min-w-0 rounded-2xl border border-border/60 bg-background/70 p-3">
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Install Path</div>
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{providerPath}</code>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 p-4">
<div className="space-y-4"> <div className="space-y-4">
<div <div
{...getRootProps()} {...getRootProps()}
className={cn( className={cn(
'rounded-lg border border-dashed p-4 transition-colors sm:p-5', 'rounded-3xl border border-dashed p-4 transition-colors sm:p-5',
isDragActive isDragActive
? 'border-foreground/40 bg-muted/35' ? 'border-foreground/40 bg-muted/35'
: 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25', : 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25',
@@ -412,7 +422,7 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
}} }}
/> />
<input <input
ref={setFolderInputRef} ref={folderInputRef}
type="file" type="file"
multiple multiple
className="hidden" className="hidden"
@@ -421,12 +431,12 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
event.target.value = ''; event.target.value = '';
}} }}
/> />
<div className="flex flex-col items-center justify-center gap-3 py-4 text-center"> <div className="flex flex-col items-center justify-center gap-3 py-4 text-center sm:py-6">
<FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} /> <FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} />
<div className="space-y-1"> <div className="space-y-1">
<div className="text-sm font-medium text-foreground">Drop a skill folder or SKILL.md</div> <div className="text-sm font-medium text-foreground">Drop `.md` files or skill folders here</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Folders can include scripts, references, and assets. Upload standalone definitions or choose a full folder to include its scripts, references, and assets.
</div> </div>
</div> </div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row"> <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
@@ -456,17 +466,14 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
{queuedFiles.length > 0 && ( {queuedFiles.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm font-medium text-foreground">Ready to install</div> <div className="text-sm font-medium text-foreground">Queued Files</div>
<div className="grid gap-2"> <div className="grid gap-2">
{queuedFiles.map((queuedFile) => ( {queuedFiles.map((queuedFile) => (
<div <div
key={queuedFile.id} key={queuedFile.id}
className="flex items-center gap-3 rounded-lg border border-border/70 bg-background/70 px-3 py-2" className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/70 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2"
> >
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground"> <div className="min-w-0">
{queuedFile.kind === 'folder' ? <FolderUp className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div> <div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{queuedFile.kind === 'folder' {queuedFile.kind === 'folder'
@@ -480,13 +487,12 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 w-8 flex-shrink-0 p-0 text-muted-foreground hover:text-foreground" className="w-full sm:w-auto"
aria-label={`Remove ${queuedFile.name}`}
onClick={() => { onClick={() => {
setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id)); setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id));
}} }}
> >
<X className="h-4 w-4" /> Remove
</Button> </Button>
</div> </div>
))} ))}
@@ -494,50 +500,54 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
</div> </div>
)} )}
{providerPath && ( <div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="space-y-2"> <Button
<button
type="button" type="button"
className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground" onClick={() => void handleUploadInstall()}
onClick={() => setShowInstallPath((current) => !current)} disabled={isSubmitting}
className="w-full sm:w-auto"
> >
{showInstallPath ? 'Hide install location' : 'Where will this install?'} {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
</button> Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skills'}
{showInstallPath && ( </Button>
<div className="rounded-lg border border-border/60 bg-muted/15 p-3"> <span className="text-xs text-muted-foreground">
<code className="block whitespace-normal break-all text-xs text-foreground">{providerPath}</code> Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
</span>
</div> </div>
)} </div>
{(submitError || loadError) && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{submitError || loadError}
</div> </div>
)} )}
{saveStatus === 'success' && (
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-4 w-4" />
Skills saved successfully.
</div> </div>
); )}
</CardContent>
</Card>
return ( <Card className="min-w-0 border-border/70 bg-background/80 shadow-sm">
<div className="min-w-0 space-y-4 overflow-x-hidden"> <CardHeader className="border-b border-border/60">
<div className="flex min-w-0 items-start gap-3"> <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground"> <div className="min-w-0">
<FileCode2 className="h-4 w-4" strokeWidth={1.7} /> <CardTitle>Visible Skills</CardTitle>
<CardDescription>
The list below comes from the provider skill discovery API and includes global and project-aware locations.
</CardDescription>
</div> </div>
<div className="min-w-0 space-y-1"> <div className="relative w-full lg:w-72">
<h3 className="text-lg font-medium text-foreground">{t('tabs.skills', { defaultValue: 'Skills' })}</h3>
<p className="text-sm text-muted-foreground">
Manage {providerName} skills from local files, complete folders, and project-aware locations.
</p>
</div>
</div>
<div className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative min-w-0 flex-1 sm:max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)} onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search skills..." placeholder="Search skills..."
aria-label="Search skills" aria-label="Search visible skills"
className="h-9 w-full pl-9 pr-9" className="h-9 w-full pl-9 pr-9"
/> />
{searchQuery && ( {searchQuery && (
@@ -551,126 +561,16 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
</button> </button>
)} )}
</div> </div>
<Button
type="button"
size="sm"
className="w-full sm:w-auto"
onClick={() => handleAddDialogOpenChange(true)}
>
<Plus className="h-4 w-4" />
Add Skill
</Button>
<Button
onClick={() => void refreshSkills({ force: true })}
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={isLoading || isLoadingProjectScopes}
>
<RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
Refresh
</Button>
</div>
{isLoadingProjectScopes && ( {isLoadingProjectScopes && (
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground"> <div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
Scanning project skills... Scanning project skills
</div> </div>
)} )}
</div> </div>
</CardHeader>
<Dialog open={isAddDialogOpen} onOpenChange={handleAddDialogOpenChange}> <CardContent className="space-y-5 p-4">
<DialogContent
wrapperClassName="z-[10000]"
className="flex h-[calc(100vh-2rem)] max-h-[760px] w-[calc(100vw-2rem)] max-w-4xl flex-col overflow-hidden p-0 sm:h-[720px]"
>
<DialogTitle>Add {providerName} Skill</DialogTitle>
<div className="flex-shrink-0 border-b border-border/60 px-4 py-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground">
<FileUp className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-base font-medium text-foreground">Add {providerName} Skill</div>
<div className="mt-1 text-sm text-muted-foreground">
Upload a SKILL.md file or a complete skill folder.
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
aria-label="Close add skill dialog"
disabled={isSubmitting}
onClick={() => handleAddDialogOpenChange(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{uploadPanel}
</div>
<div className="flex flex-shrink-0 flex-col gap-3 border-t border-border/60 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
{(submitError || loadError || (justInstalled && saveStatus === 'success')) ? (
<div className={cn(
'max-h-24 overflow-y-auto whitespace-pre-wrap rounded-lg border px-3 py-2 text-sm',
submitError || loadError
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
)}>
{submitError || loadError || 'Skills saved successfully.'}
</div>
) : (
<span className="text-xs text-muted-foreground">
Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
</span>
)}
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center">
<Button
type="button"
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={isSubmitting}
onClick={() => handleAddDialogOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
size="sm"
className="w-full sm:w-auto"
onClick={() => void handleUploadInstall()}
disabled={isSubmitting || queuedFiles.length === 0}
>
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skill'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{!isAddDialogOpen && (submitError || loadError) && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{submitError || loadError}
</div>
)}
{justInstalled && saveStatus === 'success' && !isAddDialogOpen && (
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-4 w-4" />
Skills saved successfully.
</div>
)}
<div className="space-y-5">
{isLoading && skills.length === 0 && ( {isLoading && skills.length === 0 && (
<div className="flex min-h-[180px] items-center justify-center text-sm text-muted-foreground"> <div className="flex min-h-[180px] items-center justify-center text-sm text-muted-foreground">
Loading {providerName} skills Loading {providerName} skills
@@ -678,8 +578,8 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
)} )}
{!isLoading && skills.length === 0 && ( {!isLoading && skills.length === 0 && (
<div className="rounded-lg border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center"> <div className="rounded-3xl border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-lg border border-border/60 bg-background/80 text-muted-foreground"> <div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl border border-border/60 bg-background/80 text-muted-foreground">
<FileText className="h-6 w-6" /> <FileText className="h-6 w-6" />
</div> </div>
<div className="mt-4 text-sm font-medium text-foreground">No skills discovered yet</div> <div className="mt-4 text-sm font-medium text-foreground">No skills discovered yet</div>
@@ -690,7 +590,7 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
)} )}
{!isLoading && skills.length > 0 && filteredSkills.length === 0 && ( {!isLoading && skills.length > 0 && filteredSkills.length === 0 && (
<div className="rounded-lg border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center"> <div className="rounded-3xl border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
<Search className="mx-auto h-6 w-6 text-muted-foreground" /> <Search className="mx-auto h-6 w-6 text-muted-foreground" />
<div className="mt-3 text-sm font-medium text-foreground">No matching skills</div> <div className="mt-3 text-sm font-medium text-foreground">No matching skills</div>
<div className="mt-1 text-sm text-muted-foreground"> <div className="mt-1 text-sm text-muted-foreground">
@@ -714,7 +614,7 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
{group.skills.map((skill) => ( {group.skills.map((skill) => (
<div <div
key={`${skill.command}:${skill.sourcePath}:${skill.projectPath || 'global'}`} key={`${skill.command}:${skill.sourcePath}:${skill.projectPath || 'global'}`}
className="min-w-0 rounded-lg border border-border bg-card/50 p-4" className="min-w-0 rounded-3xl border border-border/70 bg-gradient-to-br from-background via-background to-muted/25 p-4 shadow-sm"
> >
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<div className="break-all font-mono text-sm font-semibold text-foreground">{skill.command}</div> <div className="break-all font-mono text-sm font-semibold text-foreground">{skill.command}</div>
@@ -738,7 +638,7 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
)} )}
</div> </div>
<div className="mt-4 min-w-0 rounded-lg border border-border/60 bg-muted/20 px-3 py-2"> <div className="mt-4 min-w-0 rounded-2xl border border-border/60 bg-muted/20 px-3 py-2">
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Source</div> <div className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Source</div>
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{skill.sourcePath}</code> <code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{skill.sourcePath}</code>
</div> </div>
@@ -747,7 +647,8 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
</div> </div>
</section> </section>
))} ))}
</div> </CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -1,189 +0,0 @@
import * as React from 'react';
import { ChevronDown, Loader2, type LucideIcon } from 'lucide-react';
import { cn } from '../../../lib/utils';
import { Button } from './Button';
type ButtonVariant = 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
type ButtonSize = 'default' | 'sm' | 'lg' | 'icon';
export type ActionMenuItem = {
key: string;
label: string;
description?: string;
icon?: LucideIcon;
onSelect: () => void;
disabled?: boolean;
loading?: boolean;
isDanger?: boolean;
showDividerBefore?: boolean;
};
type ActionMenuProps = {
label: string;
items: ActionMenuItem[];
icon?: LucideIcon;
ariaLabel?: string;
align?: 'left' | 'right';
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
triggerClassName?: string;
disabled?: boolean;
};
export default function ActionMenu({
label,
items,
icon: TriggerIcon,
ariaLabel,
align = 'right',
variant = 'outline',
size = 'sm',
className,
triggerClassName,
disabled,
}: ActionMenuProps) {
const [isOpen, setIsOpen] = React.useState(false);
const rootRef = React.useRef<HTMLDivElement | null>(null);
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
const menuRef = React.useRef<HTMLDivElement | null>(null);
// Whether closing should move focus back to the trigger. Set for keyboard
// (Escape) and item selection, but left false for outside pointer clicks so
// focus is not stolen from wherever the user clicked.
const restoreFocusRef = React.useRef(false);
const wasOpenRef = React.useRef(false);
const menuId = React.useId();
React.useEffect(() => {
if (!isOpen) {
return;
}
const closeOnOutsideClick = (event: MouseEvent) => {
const target = event.target as Node;
if (rootRef.current && !rootRef.current.contains(target)) {
setIsOpen(false);
}
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
restoreFocusRef.current = true;
setIsOpen(false);
}
};
document.addEventListener('mousedown', closeOnOutsideClick);
document.addEventListener('keydown', closeOnEscape);
return () => {
document.removeEventListener('mousedown', closeOnOutsideClick);
document.removeEventListener('keydown', closeOnEscape);
};
}, [isOpen]);
// Move focus into the menu on open and back to the trigger on a keyboard or
// selection close, so keyboard and screen-reader navigation match the menu role.
React.useEffect(() => {
if (isOpen) {
wasOpenRef.current = true;
const menu = menuRef.current;
const firstItem = menu?.querySelector<HTMLButtonElement>('[role="menuitem"]:not([disabled])');
(firstItem ?? menu)?.focus();
return;
}
if (wasOpenRef.current) {
wasOpenRef.current = false;
if (restoreFocusRef.current) {
triggerRef.current?.focus();
}
restoreFocusRef.current = false;
}
}, [isOpen]);
const runItem = (item: ActionMenuItem) => {
if (item.disabled || item.loading) {
return;
}
restoreFocusRef.current = true;
setIsOpen(false);
item.onSelect();
};
return (
<div ref={rootRef} className={cn('relative inline-flex', className)}>
<Button
ref={triggerRef}
type="button"
variant={variant}
size={size}
className={triggerClassName}
disabled={disabled}
aria-label={ariaLabel || label}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls={isOpen ? menuId : undefined}
onClick={() => setIsOpen((current) => !current)}
>
{TriggerIcon && <TriggerIcon className="h-4 w-4" />}
<span>{label}</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', isOpen && 'rotate-180')} />
</Button>
{isOpen && (
<div
ref={menuRef}
id={menuId}
role="menu"
tabIndex={-1}
className={cn(
'absolute top-full z-50 mt-2 min-w-[220px] rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg',
'animate-in fade-in-0 zoom-in-95',
align === 'right' ? 'right-0' : 'left-0',
)}
>
{items.map((item) => {
const Icon = item.icon;
return (
<React.Fragment key={item.key}>
{item.showDividerBefore && <div className="mx-2 my-1 h-px bg-border" />}
<button
type="button"
role="menuitem"
disabled={item.disabled || item.loading}
onClick={() => runItem(item)}
className={cn(
'flex w-full items-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors',
'focus:bg-accent focus:outline-none',
item.disabled || item.loading
? 'cursor-not-allowed opacity-50'
: item.isDanger
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950'
: 'hover:bg-accent',
)}
>
{item.loading ? (
<Loader2 className="mt-0.5 h-4 w-4 flex-shrink-0 animate-spin" />
) : (
Icon && <Icon className="mt-0.5 h-4 w-4 flex-shrink-0" />
)}
<span className="min-w-0 flex-1">
<span className="block font-medium leading-5">{item.label}</span>
{item.description && (
<span className="mt-0.5 block text-xs leading-4 text-muted-foreground">
{item.description}
</span>
)}
</span>
</button>
</React.Fragment>
);
})}
</div>
)}
</div>
);
}

View File

@@ -92,13 +92,12 @@ DialogTrigger.displayName = 'DialogTrigger';
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> { interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
onEscapeKeyDown?: () => void; onEscapeKeyDown?: () => void;
onPointerDownOutside?: () => void; onPointerDownOutside?: () => void;
wrapperClassName?: string;
} }
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>( const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
({ className, children, onEscapeKeyDown, onPointerDownOutside, wrapperClassName, ...props }, ref) => { ({ className, children, onEscapeKeyDown, onPointerDownOutside, ...props }, ref) => {
const { open, onOpenChange, triggerRef } = useDialog(); const { open, onOpenChange, triggerRef } = useDialog();
const contentRef = React.useRef<HTMLDivElement | null>(null); const contentRef = React.useRef<HTMLDivElement | null>(null);
const previousFocusRef = React.useRef<HTMLElement | null>(null); const previousFocusRef = React.useRef<HTMLElement | null>(null);
@@ -172,7 +171,7 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
if (!open) return null; if (!open) return null;
return createPortal( return createPortal(
<div className={cn('fixed inset-0 z-50', wrapperClassName)}> <div className="fixed inset-0 z-50">
{/* Overlay */} {/* Overlay */}
<div <div
className="fixed inset-0 animate-dialog-overlay-show bg-black/50 backdrop-blur-sm" className="fixed inset-0 animate-dialog-overlay-show bg-black/50 backdrop-blur-sm"

View File

@@ -1,6 +1,4 @@
export { Alert, AlertTitle, AlertDescription, alertVariants } from './Alert'; export { Alert, AlertTitle, AlertDescription, alertVariants } from './Alert';
export { default as ActionMenu } from './ActionMenu';
export type { ActionMenuItem } from './ActionMenu';
export { Badge, badgeVariants } from './Badge'; export { Badge, badgeVariants } from './Badge';
export { Button, buttonVariants } from './Button'; export { Button, buttonVariants } from './Button';
export { Confirmation, ConfirmationTitle, ConfirmationRequest, ConfirmationAccepted, ConfirmationRejected, ConfirmationActions, ConfirmationAction } from './Confirmation'; export { Confirmation, ConfirmationTitle, ConfirmationRequest, ConfirmationAccepted, ConfirmationRejected, ConfirmationActions, ConfirmationAction } from './Confirmation';