From 171cedc6ef8f6271929440b2ba2b82e8b8e689e4 Mon Sep 17 00:00:00 2001
From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com>
Date: Thu, 25 Jun 2026 16:29:52 +0530
Subject: [PATCH] =?UTF-8?q?feat(ui):=20consolidate=20buttons=20=E2=80=94?=
=?UTF-8?q?=203=20variants=20(primary/secondary/tertiary)=20+=20danger=20t?=
=?UTF-8?q?one=20(#4057)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/src/agentworld/pages/FeedSection.tsx | 21 +--
.../pages/IdentitiesSection.test.tsx | 10 +-
.../agentworld/pages/IdentitiesSection.tsx | 82 +++++-----
.../agentworld/pages/MarketplaceSection.tsx | 10 +-
.../pages/MessagingSection.test.tsx | 58 ++++----
app/src/agentworld/pages/MessagingSection.tsx | 117 +++++++--------
app/src/agentworld/pages/ProfilesSection.tsx | 9 +-
app/src/components/AppUpdatePrompt.tsx | 42 +++---
.../BootCheckGate/BootCheckGate.tsx | 124 +++++-----------
.../components/LocalAIDownloadSnackbar.tsx | 17 ++-
app/src/components/OpenhumanLinkModal.tsx | 62 +++-----
.../components/PersistRehydrationScreen.tsx | 9 +-
app/src/components/PillTabBar.tsx | 53 -------
.../components/accounts/AddAccountModal.tsx | 10 +-
.../components/accounts/RespondQueuePanel.tsx | 11 +-
app/src/components/accounts/WebviewHost.tsx | 11 +-
.../components/channels/ChannelSetupModal.tsx | 11 +-
app/src/components/channels/DiscordConfig.tsx | 8 +-
app/src/components/channels/YuanbaoConfig.tsx | 63 ++++----
.../channels/channelConfigPrimitives.tsx | 19 +--
.../channels/mcp/ConfigAssistantPanel.tsx | 10 +-
.../channels/mcp/ConfigHelpModal.tsx | 10 +-
.../channels/mcp/ConnectAuthModal.tsx | 43 +++---
.../components/channels/mcp/InstallDialog.tsx | 73 +++++----
.../channels/mcp/InstalledServerDetail.tsx | 88 ++++++-----
.../channels/mcp/InstalledServerList.tsx | 17 +--
.../channels/mcp/McpCatalogBrowser.tsx | 9 +-
.../mcp/McpConnectionHealthToolbar.tsx | 19 ++-
.../channels/mcp/McpInventoryExportTab.tsx | 21 +--
.../channels/mcp/McpInventoryImportTab.tsx | 27 ++--
.../channels/mcp/McpInventoryPanel.tsx | 11 +-
.../channels/mcp/McpServersTab.test.tsx | 10 +-
.../components/channels/mcp/McpServersTab.tsx | 106 ++++++-------
.../channels/mcp/McpToolPlayground.tsx | 19 ++-
app/src/components/chat/ChatFilesPanel.tsx | 12 +-
.../composio/ComposioConnectModal.tsx | 101 +++++++------
.../components/feedback/FeedbackComments.tsx | 10 +-
.../feedback/FeedbackSubmitForm.tsx | 8 +-
.../intelligence/AddMemorySourceDialog.tsx | 39 ++---
.../intelligence/AgentsLibraryPanel.tsx | 31 ++--
.../intelligence/ConfirmationModal.tsx | 24 ++-
.../intelligence/ConnectionPathPanel.tsx | 8 +-
.../intelligence/DiagramViewerTab.tsx | 11 +-
.../intelligence/EntityAssociationsPanel.tsx | 8 +-
.../components/intelligence/GoalsPanel.tsx | 9 +-
.../intelligence/GraphCentralityPanel.tsx | 8 +-
.../intelligence/GraphCohesionPanel.tsx | 8 +-
.../intelligence/IntelligenceMemoryTab.tsx | 10 +-
.../IntelligenceOrchestrationTab.tsx | 39 ++---
.../IntelligenceSubconsciousTab.tsx | 10 +-
.../intelligence/IntelligenceTasksTab.tsx | 19 ++-
.../intelligence/IntelligenceTeamsTab.tsx | 23 +--
.../intelligence/MemoryControls.tsx | 42 ++----
.../intelligence/MemoryFreshnessPanel.tsx | 8 +-
.../components/intelligence/MemoryGraph.tsx | 19 ++-
.../intelligence/MemorySourcesRegistry.tsx | 39 +++--
.../intelligence/MemoryTimelinePanel.tsx | 8 +-
.../intelligence/MemoryTreeStatusPanel.tsx | 12 +-
.../intelligence/ModelCouncilTab.tsx | 97 ++++++------
.../intelligence/NamespaceOverviewPanel.tsx | 8 +-
.../intelligence/ObsidianVaultSection.tsx | 41 ++---
.../intelligence/SourceSettingsPanel.tsx | 12 +-
.../SubconsciousTriggersPanel.tsx | 25 ++--
.../intelligence/SyncBudgetDialog.tsx | 14 +-
.../intelligence/SyncConfirmDialog.tsx | 14 +-
.../intelligence/UserTaskComposer.tsx | 16 +-
.../intelligence/VaultHealthChecklist.tsx | 25 ++--
.../intelligence/WhatsAppMemorySection.tsx | 16 +-
.../intelligence/WorkflowRunDetail.tsx | 12 +-
.../components/intelligence/WorkflowsTab.tsx | 34 +++--
app/src/components/layout/TwoPaneNav.tsx | 2 +-
.../notifications/CoreNotificationCard.tsx | 15 +-
.../notifications/NotificationCard.tsx | 10 +-
.../notifications/NotificationCenter.tsx | 9 +-
.../rewards/ReferralRewardsSection.tsx | 20 +--
.../rewards/RewardsCommunityTab.tsx | 10 +-
.../rewards/RewardsCouponSection.tsx | 29 ++--
.../settings/LogoutAndClearActions.tsx | 8 +-
.../settings/controls/SettingsListItem.tsx | 2 +-
.../settings/modal/SettingsModalFrame.tsx | 11 +-
.../components/settings/panels/AIPanel.tsx | 32 ++--
.../settings/panels/AgentEditorPage.tsx | 10 +-
.../settings/panels/AgentsPanel.tsx | 10 +-
.../settings/panels/AnalysisViewsPanel.tsx | 10 +-
.../settings/panels/ApprovalHistoryPanel.tsx | 2 +-
.../panels/AutocompleteDebugPanel.tsx | 6 +-
.../settings/panels/AutocompletePanel.tsx | 18 ++-
.../settings/panels/AutonomyPanel.tsx | 2 +-
.../settings/panels/CompanionPanel.tsx | 3 +-
.../settings/panels/DesktopAgentPanel.tsx | 21 +--
.../settings/panels/DevWorkflowPanel.tsx | 7 +-
.../settings/panels/DevicesPanel.tsx | 3 +-
.../settings/panels/EmbeddingsPanel.tsx | 18 ++-
.../settings/panels/EventLogPanel.tsx | 2 +-
.../settings/panels/McpServerPanel.tsx | 2 +-
.../settings/panels/MemoryDebugPanel.tsx | 5 +-
.../settings/panels/MigrationPanel.tsx | 2 +-
.../settings/panels/ModelHealthPanel.tsx | 2 +-
.../settings/panels/PermissionsPanel.tsx | 9 +-
.../settings/panels/ProfilesPanel.tsx | 2 +-
.../settings/panels/RecoveryPhrasePanel.tsx | 35 ++---
.../panels/ScreenIntelligencePanel.tsx | 3 +-
.../settings/panels/SearchPanel.tsx | 2 +-
.../settings/panels/TaskSourcesPanel.tsx | 5 +-
.../settings/panels/TeamInvitesPanel.tsx | 9 +-
.../settings/panels/TeamManagementPanel.tsx | 5 +-
.../settings/panels/TeamMembersPanel.tsx | 7 +-
.../components/settings/panels/TeamPanel.tsx | 4 +-
.../settings/panels/VoiceDebugPanel.tsx | 2 +-
.../settings/panels/WalletBalancesPanel.tsx | 18 ++-
.../panels/ai/ClaudeCodeStatusCard.tsx | 53 +++----
.../panels/autocomplete/AppFilterSection.tsx | 34 ++---
.../autocomplete/CompletionStyleSection.tsx | 20 ++-
.../panels/billing/AutoRechargeSection.tsx | 11 +-
.../panels/billing/BillingHistoryTab.tsx | 9 +-
.../settings/panels/cron/CoreJobList.tsx | 46 +++---
.../settings/panels/cron/CronJobFormModal.tsx | 26 ++--
.../panels/devices/PairPhoneModal.tsx | 8 +-
.../local-model/DeviceCapabilitySection.tsx | 12 +-
.../panels/local-model/ModelStatusSection.tsx | 30 ++--
.../settings/panels/wallet/ReceiveModal.tsx | 10 +-
.../panels/wallet/SendCryptoModal.tsx | 27 ++--
.../skills/AutocompleteSetupModal.tsx | 10 +-
.../components/skills/CreateSkillModal.tsx | 52 +++----
.../components/skills/CreateWorkflowForm.tsx | 98 +++++-------
.../components/skills/InstallSkillDialog.tsx | 67 +++++----
app/src/components/skills/MeetingBotsCard.tsx | 29 ++--
.../components/skills/RecentCallsSection.tsx | 5 +-
.../skills/ScheduledCronCard.test.tsx | 39 ++---
.../components/skills/ScheduledCronCard.tsx | 48 ++----
.../skills/ScreenIntelligenceSetupModal.tsx | 34 ++---
app/src/components/skills/SkillCard.test.tsx | 4 +-
app/src/components/skills/SkillCard.tsx | 38 +++--
.../components/skills/SkillCategoryFilter.tsx | 48 +++---
.../components/skills/SkillResourceTree.tsx | 12 +-
app/src/components/skills/SkillSearchBar.tsx | 6 +-
.../components/skills/SkillsExplorerTab.tsx | 140 ++++++++++--------
.../skills/SmartIssuePicker.test.tsx | 46 +++++-
.../components/skills/SmartIssuePicker.tsx | 43 ++----
.../skills/UninstallSkillConfirmDialog.tsx | 32 ++--
app/src/components/skills/VoiceSetupModal.tsx | 24 ++-
.../components/skills/WorkflowRunnerBody.tsx | 70 ++++-----
.../__tests__/CreateSkillModal.test.tsx | 10 +-
.../__tests__/CreateWorkflowForm.test.tsx | 30 ++--
.../__tests__/InstallSkillDialog.test.tsx | 8 +-
.../skills/__tests__/MeetingBotsCard.test.tsx | 26 +---
.../ScreenIntelligenceSetupModal.test.tsx | 8 +-
.../__tests__/SkillsExplorerTab.test.tsx | 73 ++++-----
.../UninstallSkillConfirmDialog.test.tsx | 40 ++---
.../__tests__/WorkflowRunnerBody.test.tsx | 41 +++--
.../components/skills/inputs/BranchPicker.tsx | 12 +-
.../components/skills/inputs/RepoPicker.tsx | 21 +--
app/src/components/skills/skillIcons.tsx | 59 ++++----
app/src/components/ui/Button.test.tsx | 47 +++++-
app/src/components/ui/Button.tsx | 79 +++++++---
app/src/components/ui/ModalShell.tsx | 12 +-
.../components/webhooks/TunnelList.test.tsx | 48 ++++++
app/src/components/webhooks/TunnelList.tsx | 56 +++----
app/src/pages/Activity.tsx | 32 ++--
app/src/pages/Feedback.tsx | 10 +-
app/src/pages/Home.tsx | 18 ++-
app/src/pages/Intelligence.test.tsx | 2 +-
app/src/pages/Intelligence.tsx | 32 ++--
app/src/pages/Invites.tsx | 17 ++-
app/src/pages/Notifications.tsx | 21 +--
app/src/pages/Rewards.tsx | 14 +-
app/src/pages/Routines.tsx | 17 +--
app/src/pages/Welcome.tsx | 19 ++-
app/src/pages/WorkflowNew.tsx | 18 +--
app/src/pages/WorkflowsRun.tsx | 11 +-
app/src/pages/__tests__/Brain.test.tsx | 2 +-
app/src/pages/__tests__/Intelligence.test.tsx | 10 +-
.../__tests__/Skills.mcp-coming-soon.test.tsx | 12 +-
.../components/AgentProcessSourcePanel.tsx | 11 +-
.../components/BackgroundProcessesPanel.tsx | 12 +-
.../components/SubagentDrawer.tsx | 21 ++-
.../components/TaskKanbanBoard.tsx | 40 ++---
app/src/pages/dev/AgentInsightsPreview.tsx | 8 +-
app/src/pages/ios/MascotScreen.tsx | 9 +-
.../pages/onboarding/steps/ApiKeysStep.tsx | 10 +-
.../onboarding/steps/CustomWizardStep.tsx | 8 +-
.../pages/onboarding/steps/LocalAIStep.tsx | 15 +-
.../onboarding/steps/ReferralApplyStep.tsx | 17 +--
.../playwright/specs/mcp-tab-flow.spec.ts | 16 +-
184 files changed, 2236 insertions(+), 2269 deletions(-)
delete mode 100644 app/src/components/PillTabBar.tsx
create mode 100644 app/src/components/webhooks/TunnelList.test.tsx
diff --git a/app/src/agentworld/pages/FeedSection.tsx b/app/src/agentworld/pages/FeedSection.tsx
index 6353023d4..e975fb036 100644
--- a/app/src/agentworld/pages/FeedSection.tsx
+++ b/app/src/agentworld/pages/FeedSection.tsx
@@ -21,6 +21,7 @@ import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import PanelScaffold from '../../components/layout/PanelScaffold';
+import Button from '../../components/ui/Button';
import {
type GqlComment,
type GqlHomeFeedItem,
@@ -213,14 +214,13 @@ function CommentComposer({
dark:border-neutral-700 dark:bg-neutral-800 dark:placeholder:text-neutral-500
dark:focus:border-primary-600 disabled:opacity-50"
/>
-
+
);
}
@@ -323,13 +323,14 @@ function FeedComposer({ myAgentId, onPostCreated }: FeedComposerProps) {
{remaining}
)}
-
+
diff --git a/app/src/agentworld/pages/IdentitiesSection.test.tsx b/app/src/agentworld/pages/IdentitiesSection.test.tsx
index c980c4579..8a2272867 100644
--- a/app/src/agentworld/pages/IdentitiesSection.test.tsx
+++ b/app/src/agentworld/pages/IdentitiesSection.test.tsx
@@ -88,7 +88,7 @@ afterEach(() => {
// ── Helpers ─────────────────────────────────────────────────────────────────
function gotoTab(name: 'Register' | 'Registry' | 'Trading') {
- return userEvent.click(screen.getByRole('button', { name }));
+ return userEvent.click(screen.getByRole('tab', { name }));
}
// ── Tab navigation ────────────────────────────────────────────────────────────
@@ -96,7 +96,7 @@ function gotoTab(name: 'Register' | 'Registry' | 'Trading') {
describe('tab navigation', () => {
test('defaults to Register tab', () => {
render(
{error}
: null} {loading ? ( @@ -603,13 +601,13 @@ function GroupInvitesPanel({ ))}{error}
: null} {result ? ( @@ -693,20 +688,20 @@ function RedeemInvitePanel({ onClose }: { onClose: () => void }) { />@@ -1407,23 +1399,14 @@ export default function MessagingSection({ tabs = VISIBLE_TABS }: MessagingSecti
{exportError}
)} diff --git a/app/src/components/AppUpdatePrompt.tsx b/app/src/components/AppUpdatePrompt.tsx index 2af06cdfd..5f61e9559 100644 --- a/app/src/components/AppUpdatePrompt.tsx +++ b/app/src/components/AppUpdatePrompt.tsx @@ -19,6 +19,7 @@ import { createPortal } from 'react-dom'; import { useAppUpdate } from '../hooks/useAppUpdate'; import { useT } from '../lib/i18n/I18nContext'; import { formatBytes } from '../utils/localAiHelpers'; +import Button from './ui/Button'; interface AppUpdatePromptProps { /** Override auto-check defaults (mostly for tests). */ @@ -105,12 +106,14 @@ const AppUpdatePrompt = (props: AppUpdatePromptProps) => { {headerLabel(phase, t)}{actionError}
}{actionError}
}{actionError}
}{actionError}
}{actionError}
}{t('app.openhumanLink.telegramUnavailable')}
{t('app.openhumanLink.notifications.sent')}
)} @@ -316,14 +311,13 @@ const BillingBody = ({ close }: { close: () => void }) => { {t('app.openhumanLink.billing.trialDesc')} -{t('app.openhumanLink.discordReport.intro')}
-{t('app.persistRehydration.body')}
-
{linkToken}
- {t('channels.discord.linkTokenInstruction').replace('{token}', linkToken)} diff --git a/app/src/components/channels/YuanbaoConfig.tsx b/app/src/components/channels/YuanbaoConfig.tsx index a27a2a4a9..bb3311fb0 100644 --- a/app/src/components/channels/YuanbaoConfig.tsx +++ b/app/src/components/channels/YuanbaoConfig.tsx @@ -12,6 +12,7 @@ import { import { useAppDispatch, useAppSelector } from '../../store/hooks'; import type { ChannelConnectionStatus, ChannelDefinition } from '../../types/channels'; import { restartCoreProcess } from '../../utils/tauriCommands/core'; +import Button from '../ui/Button'; import ChannelFieldInput from './ChannelFieldInput'; import ChannelStatusBadge from './ChannelStatusBadge'; @@ -253,45 +254,47 @@ const YuanbaoConfig = ({ definition }: YuanbaoConfigProps) => { )}
{t('mcp.connectAuth.oauthOrToken')}
@@ -324,13 +321,14 @@ const ConnectAuthModal = ({ server, onClose, onConnected }: ConnectAuthModalProp data-form-type="other" className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs text-stone-800 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500/40 disabled:opacity-50" /> -{t('mcp.installed.empty')}
-
-
+ disabled={rawInput.trim().length === 0}>
{t('mcp.inventory.import.preview')}
-
+
{(manifest || parseError || rawInput.length > 0) && (
-
+
{t('mcp.inventory.import.clear')}
-
+
)}
- {t('skills.install.fetchingPrefix')}{' '}
- SKILL.md… {t('skills.install.fetchingSuffix')}
+ {t('skills.install.fetchingPrefix')} SKILL.md…{' '}
+ {t('skills.install.fetchingSuffix')}
) : null}
@@ -400,7 +406,9 @@ export default function InstallSkillDialog({ onClose, onInstalled }: Props) {
{result.stdout ? (
- {t('skills.install.fetchLog')}
+
+ {t('skills.install.fetchLog')}
+
{result.stdout}
@@ -408,7 +416,9 @@ export default function InstallSkillDialog({ onClose, onInstalled }: Props) {
) : null}
{result.stderr ? (
- {t('skills.install.parseWarnings')}
+
+ {t('skills.install.parseWarnings')}
+
{result.stderr}
@@ -429,7 +439,9 @@ export default function InstallSkillDialog({ onClose, onInstalled }: Props) {
{cat.title}
{cat.hint}
- {t('skills.install.rawError')}
+
+ {t('skills.install.rawError')}
+
{error}
@@ -443,20 +455,13 @@ export default function InstallSkillDialog({ onClose, onInstalled }: Props) {
{/* Footer */}
-
+
{result ? t('common.finish') : t('common.cancel')}
-
+
{result ? null : (
-
+
{submitting ? t('skills.install.installing') : t('skills.install.installBtn')}
-
+
)}
diff --git a/app/src/components/skills/MeetingBotsCard.tsx b/app/src/components/skills/MeetingBotsCard.tsx
index ca579baaf..77669db1c 100644
--- a/app/src/components/skills/MeetingBotsCard.tsx
+++ b/app/src/components/skills/MeetingBotsCard.tsx
@@ -29,6 +29,7 @@ import {
selectSelectedMascotId,
} from '../../store/mascotSlice';
import { selectPersonaDescription, selectPersonaDisplayName } from '../../store/personaSlice';
+import Button from '../ui/Button';
import { RecentCallsSection } from './RecentCallsSection';
type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string };
@@ -157,21 +158,14 @@ function ActiveMeetingView({ onToast }: Props) {
{t('skills.meetingBots.liveBadge')}
{canLeave && (
-
+
{t('skills.meetingBots.leaveButton')}
-
+
)}
{isDone && (
- dispatch(resetBackendMeet())}
- className="rounded-xl px-3 py-1.5 text-xs font-medium bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-700">
+ dispatch(resetBackendMeet())}>
{t('common.close')}
-
+
)}
@@ -245,9 +239,7 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps)
// so the mount-time fetch can race ahead of that write and miss the just-
// ended call. A couple of short delayed re-fetches reliably reflect it
// without the user having to reopen the tab. Cheap (a ~2ms RPC each).
- const retries = [1200, 3000].map(delay =>
- setTimeout(() => void refreshRecentCalls(), delay)
- );
+ const retries = [1200, 3000].map(delay => setTimeout(() => void refreshRecentCalls(), delay));
return () => retries.forEach(clearTimeout);
}, [refreshRecentCalls]);
@@ -381,14 +373,14 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps)
)}
-
+ variant="primary"
+ disabled={submitting || !meetUrl.trim() || !respondTo.trim()}>
{submitting
? t('skills.meetingBots.starting')
: t('skills.meetingBots.sendTo').replace('{label}', selectedLabel)}
-
+
@@ -396,4 +388,3 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps)
);
}
-
diff --git a/app/src/components/skills/RecentCallsSection.tsx b/app/src/components/skills/RecentCallsSection.tsx
index 19a3915c0..22e85bffe 100644
--- a/app/src/components/skills/RecentCallsSection.tsx
+++ b/app/src/components/skills/RecentCallsSection.tsx
@@ -90,7 +90,10 @@ function RecentCallRow({ call }: { call: MeetCallRecord }) {
{participants.length > 0 && (
- {t('skills.meetingBots.recentCallParticipants').replace('{names}', participants.join(', '))}
+ {t('skills.meetingBots.recentCallParticipants').replace(
+ '{names}',
+ participants.join(', ')
+ )}
)}
diff --git a/app/src/components/skills/ScheduledCronCard.test.tsx b/app/src/components/skills/ScheduledCronCard.test.tsx
index eb008ba65..8bf872c12 100644
--- a/app/src/components/skills/ScheduledCronCard.test.tsx
+++ b/app/src/components/skills/ScheduledCronCard.test.tsx
@@ -19,13 +19,12 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
+import type { CoreCronJob } from '../../utils/tauriCommands/cron';
+import ScheduledCronCard from './ScheduledCronCard';
+
const stableT = (key: string) => key;
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: stableT }) }));
-import type { CoreCronJob } from '../../utils/tauriCommands/cron';
-
-import ScheduledCronCard from './ScheduledCronCard';
-
function makeJob(overrides: Partial = {}): CoreCronJob {
return {
id: 'job-1',
@@ -50,13 +49,7 @@ function makeJob(overrides: Partial = {}): CoreCronJob {
describe('ScheduledCronCard', () => {
it('renders the title, cronToHuman schedule, and meta row', () => {
- render(
- undefined}
- />
- );
+ render( undefined} />);
expect(screen.getByTestId('scheduled-cron-job-1-title')).toHaveTextContent('dev-workflow');
// `*/30 * * * *` → "Every 30 minutes" per cronToHuman.
expect(screen.getByTestId('scheduled-cron-job-1-schedule')).toHaveTextContent(
@@ -132,31 +125,19 @@ describe('ScheduledCronCard', () => {
});
it('renders the ×N count badge when badgeCount > 1', () => {
- render(
- undefined}
- />
- );
+ render( undefined} />);
expect(screen.getByTestId('scheduled-cron-job-1-count-badge')).toHaveTextContent('×3');
});
it('omits the count badge when badgeCount is 1 or absent', () => {
- const { rerender } = render(
- undefined} />
- );
+ const { rerender } = render( undefined} />);
expect(screen.queryByTestId('scheduled-cron-job-1-count-badge')).not.toBeInTheDocument();
- rerender(
- undefined} />
- );
+ rerender( undefined} />);
expect(screen.queryByTestId('scheduled-cron-job-1-count-badge')).not.toBeInTheDocument();
});
it('renders the ★ Active badge when activeBadge is true', () => {
- render(
- undefined} />
- );
+ render( undefined} />);
expect(screen.getByTestId('scheduled-cron-job-1-active-badge')).toBeInTheDocument();
});
@@ -250,9 +231,7 @@ describe('ScheduledCronCard', () => {
undefined} />
);
expect(screen.getByTestId('scheduled-cron-job-1')).toHaveAttribute('data-active', 'true');
- rerender(
- undefined} />
- );
+ rerender( undefined} />);
expect(screen.getByTestId('scheduled-cron-job-1')).toHaveAttribute('data-active', 'false');
});
});
diff --git a/app/src/components/skills/ScheduledCronCard.tsx b/app/src/components/skills/ScheduledCronCard.tsx
index b63954fea..78c064c2f 100644
--- a/app/src/components/skills/ScheduledCronCard.tsx
+++ b/app/src/components/skills/ScheduledCronCard.tsx
@@ -39,7 +39,6 @@
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { CoreCronJob } from '../../utils/tauriCommands/cron';
-
import { formatSchedule } from './scheduledCronFormat';
export interface ScheduledCronCardProps {
@@ -117,34 +116,28 @@ export default function ScheduledCronCard({
{activeBadge && (
+ className="px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-sage-200 dark:bg-sage-500/30 text-sage-800 dark:text-sage-200 shrink-0">
{`★ ${t('settings.skillsRunner.schedule.active')}`}
)}
+ isActive ? 'text-sage-900 dark:text-sage-100' : 'text-stone-700 dark:text-neutral-200'
+ }`}>
{heading}
{badgeCount && badgeCount > 1 ? (
+ className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-stone-200 dark:bg-neutral-700 text-stone-700 dark:text-neutral-300 shrink-0">
×{badgeCount}
) : null}
+ className="mt-0.5 text-xs text-stone-600 dark:text-neutral-400">
{formatSchedule(job)}
@@ -158,8 +151,7 @@ export default function ScheduledCronCard({
job.last_status === 'ok'
? 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300'
: 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300'
- }`}
- >
+ }`}>
{job.last_status}
)}
@@ -179,24 +171,18 @@ export default function ScheduledCronCard({
// We wrap it in a stopPropagation span so toggling never bubbles up to
// a parent card-click handler.
const toggleBlock = (
- e.stopPropagation()}
- >
+ e.stopPropagation()}>
onToggle(!job.enabled)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors disabled:opacity-50 ${
job.enabled ? 'bg-sage-500' : 'bg-neutral-300 dark:bg-neutral-600'
- }`}
- >
+ }`}>
{actions && (
- e.stopPropagation()}
- >
+ e.stopPropagation()}>
{actions}
)}
@@ -239,22 +222,20 @@ export default function ScheduledCronCard({
data-testid={`${rootId}-open`}
aria-label={t('skills.dashboard.cardOpenRunner')}
onClick={onClick}
- onKeyDown={(e) => {
+ onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === ' ') e.preventDefault();
onClick();
}
}}
- className="w-full text-left px-4 py-3 flex items-center justify-between gap-3 cursor-pointer rounded-2xl transition-colors hover:bg-stone-100/80 dark:hover:bg-neutral-800/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
- >
+ className="w-full text-left px-4 py-3 flex items-center justify-between gap-3 cursor-pointer rounded-2xl transition-colors hover:bg-stone-100/80 dark:hover:bg-neutral-800/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40">
{headingRow}
{rightCluster}
) : (
+ className="w-full px-4 py-3 flex items-center justify-between gap-3">
{headingRow}
{rightCluster}
@@ -265,8 +246,7 @@ export default function ScheduledCronCard({
key={job.id}
data-testid={rootId}
data-active={isActive ? 'true' : 'false'}
- className={containerClass}
- >
+ className={containerClass}>
{upperRow}
{children}
diff --git a/app/src/components/skills/ScreenIntelligenceSetupModal.tsx b/app/src/components/skills/ScreenIntelligenceSetupModal.tsx
index f1d7077f9..0207a0312 100644
--- a/app/src/components/skills/ScreenIntelligenceSetupModal.tsx
+++ b/app/src/components/skills/ScreenIntelligenceSetupModal.tsx
@@ -13,6 +13,7 @@ import { useT } from '../../lib/i18n/I18nContext';
import { openhumanUpdateScreenIntelligenceSettings } from '../../utils/tauriCommands';
import { settingsNavState } from '../settings/modal/settingsOverlay';
import { CheckIcon } from '../ui';
+import Button from '../ui/Button';
import {
SetupNotice,
SetupSettingRow,
@@ -73,15 +74,11 @@ const PermissionRow = ({
{t('skills.setup.screenIntel.granted')}
) : (
-
+
{isRequesting
? t('skills.setup.screenIntel.opening')
: t('skills.setup.screenIntel.grant')}
-
+
)}
);
@@ -176,12 +173,9 @@ export default function ScreenIntelligenceSetupModal({ onClose, initialStep }: P
{t('skills.setup.screenIntel.macosOnly')}
-
+
{t('common.close')}
-
+
);
@@ -267,13 +261,14 @@ export default function ScreenIntelligenceSetupModal({ onClose, initialStep }: P
: t('skills.setup.screenIntel.restartRefresh')}
) : (
- void refreshStatus()}
disabled={isRestartingCore}
- className="flex-1 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50 transition-colors">
+ className="flex-1">
{t('skills.setup.screenIntel.refreshStatus')}
-
+
)}
@@ -308,15 +303,16 @@ export default function ScreenIntelligenceSetupModal({ onClose, initialStep }: P
{enableError && {enableError} }
- void handleEnable()}
disabled={isEnabling}
- className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
+ className="w-full">
{isEnabling
? t('skills.setup.screenIntel.enabling')
: t('skills.setup.screenIntel.enableBtn')}
-
+
)}
diff --git a/app/src/components/skills/SkillCard.test.tsx b/app/src/components/skills/SkillCard.test.tsx
index 6bf4963cf..51bc6c944 100644
--- a/app/src/components/skills/SkillCard.test.tsx
+++ b/app/src/components/skills/SkillCard.test.tsx
@@ -3,9 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import UnifiedSkillCard from './SkillCard';
-vi.mock('../../lib/i18n/I18nContext', () => ({
- useT: () => ({ t: (key: string) => key }),
-}));
+vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
describe('UnifiedSkillCard stable test hooks', () => {
it('renders row and primary action test ids', () => {
diff --git a/app/src/components/skills/SkillCard.tsx b/app/src/components/skills/SkillCard.tsx
index 6679e33eb..ba92dbe74 100644
--- a/app/src/components/skills/SkillCard.tsx
+++ b/app/src/components/skills/SkillCard.tsx
@@ -1,6 +1,7 @@
-import { useEffect, useRef, useState, type ReactNode } from 'react';
+import { type ReactNode, useEffect, useRef, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
+import Button from '../ui/Button';
export interface UnifiedSkillCardProps {
icon: ReactNode;
@@ -22,12 +23,7 @@ export interface UnifiedSkillCardProps {
disabled?: boolean;
testId?: string;
}>;
- syncProgress?: {
- active: boolean;
- percent?: number;
- message?: string;
- metricsText?: string;
- };
+ syncProgress?: { active: boolean; percent?: number; message?: string; metricsText?: string };
syncSummaryText?: string;
ctaDisabled?: boolean;
}
@@ -82,19 +78,26 @@ export function UnifiedSkillCard({
- {title}
+
+ {title}
+
{statusDot && }
{statusLabel && (
-
+
{statusLabel}
)}
{description && (
- {description}
+
+ {description}
+
)}
{syncSummaryText && !syncProgress?.active && (
- {syncSummaryText}
+
+ {syncSummaryText}
+
)}
{syncProgress?.active && (
@@ -123,20 +126,23 @@ export function UnifiedSkillCard({
{secondaryActions && secondaryActions.length > 0 && (
- {
e.stopPropagation();
setMenuOpen(prev => !prev);
}}
- className="flex h-7 w-7 items-center justify-center rounded-lg text-stone-400 dark:text-neutral-500 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200"
- title={t('skills.card.moreActions')}>
+ title={t('skills.card.moreActions')}
+ aria-label={t('skills.card.moreActions')}
+ className="h-7 w-7 text-stone-400 dark:text-neutral-500">
-
+
{menuOpen && (
{secondaryActions.map(action => (
diff --git a/app/src/components/skills/SkillCategoryFilter.tsx b/app/src/components/skills/SkillCategoryFilter.tsx
index 72772e521..70c8e34c0 100644
--- a/app/src/components/skills/SkillCategoryFilter.tsx
+++ b/app/src/components/skills/SkillCategoryFilter.tsx
@@ -1,4 +1,4 @@
-import PillTabBar from '../PillTabBar';
+import ChipTabs from '../layout/ChipTabs';
import type { SkillCategory } from './skillCategories';
import {
skillCategoryChipClassName,
@@ -14,24 +14,36 @@ interface SkillCategoryFilterProps {
const SkillCategoryFilter = ({ categories, selected, onChange }: SkillCategoryFilterProps) => {
return (
- ({ label: category, value: category }))}
- selected={selected}
+
+ items={categories.map(category => {
+ const active = category === selected;
+ return {
+ id: category,
+ label: (
+
+
+
+
+ {category}
+
+ ),
+ };
+ })}
+ value={selected}
onChange={onChange}
- renderItem={(item, active) => (
-
-
-
-
- {item.label}
-
- )}
+ className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide"
/>
);
};
diff --git a/app/src/components/skills/SkillResourceTree.tsx b/app/src/components/skills/SkillResourceTree.tsx
index a8be61344..d79b29afc 100644
--- a/app/src/components/skills/SkillResourceTree.tsx
+++ b/app/src/components/skills/SkillResourceTree.tsx
@@ -7,8 +7,8 @@
* anything unexpected still renders. Items are rendered as clickable rows in
* JetBrains Mono for path clarity. Selected item uses primary-50 background.
*/
-import { useMemo } from 'react';
import debug from 'debug';
+import { useMemo } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
@@ -88,7 +88,11 @@ export default function SkillResourceTree({ resources, selectedPath, onSelect }:
const groups = useMemo(() => groupResources(resources), [resources]);
if (groups.length === 0) {
- return {t('skills.resource.tree.empty')}
;
+ return (
+
+ {t('skills.resource.tree.empty')}
+
+ );
}
return (
@@ -101,7 +105,9 @@ export default function SkillResourceTree({ resources, selectedPath, onSelect }:
{t(GROUP_LABEL_KEYS[group.key] ?? group.key)}
- {group.items.length}
+
+ {group.items.length}
+
{group.items.map(path => {
diff --git a/app/src/components/skills/SkillSearchBar.tsx b/app/src/components/skills/SkillSearchBar.tsx
index c0c2ed15b..b4af2cd7a 100644
--- a/app/src/components/skills/SkillSearchBar.tsx
+++ b/app/src/components/skills/SkillSearchBar.tsx
@@ -6,11 +6,7 @@ interface SkillSearchBarProps {
placeholder?: string;
}
-export default function SkillSearchBar({
- value,
- onChange,
- placeholder,
-}: SkillSearchBarProps) {
+export default function SkillSearchBar({ value, onChange, placeholder }: SkillSearchBarProps) {
const { t } = useT();
const effectivePlaceholder = placeholder ?? t('skills.search.placeholder');
return (
diff --git a/app/src/components/skills/SkillsExplorerTab.tsx b/app/src/components/skills/SkillsExplorerTab.tsx
index bae852206..2b15d68a6 100644
--- a/app/src/components/skills/SkillsExplorerTab.tsx
+++ b/app/src/components/skills/SkillsExplorerTab.tsx
@@ -9,6 +9,8 @@ import {
type WorkflowSummary,
} from '../../services/api/workflowsApi';
import EmptyStateCard from '../EmptyStateCard';
+import ChipTabs from '../layout/ChipTabs';
+import Button from '../ui/Button';
import InstallSkillDialog from './InstallSkillDialog';
import UninstallSkillConfirmDialog from './UninstallSkillConfirmDialog';
@@ -45,7 +47,7 @@ function parentPathSegment(value: string | null | undefined): string | null {
const raw = value?.trim();
if (!raw) return null;
const parts = raw.split(/[\\/]+/).filter(Boolean);
- return parts.length >= 2 ? parts.at(-2) ?? null : null;
+ return parts.length >= 2 ? (parts.at(-2) ?? null) : null;
}
function catalogInstallKeys(entry: CatalogEntry): string[] {
@@ -58,10 +60,9 @@ function catalogInstallKeys(entry: CatalogEntry): string[] {
}
function workflowInstallKeys(skill: WorkflowSummary): string[] {
- return [
- slugifyInstallKey(skill.id),
- slugifyInstallKey(parentPathSegment(skill.location)),
- ].filter((key): key is string => Boolean(key));
+ return [slugifyInstallKey(skill.id), slugifyInstallKey(parentPathSegment(skill.location))].filter(
+ (key): key is string => Boolean(key)
+ );
}
function isCatalogEntryInstalled(entry: CatalogEntry, installedKeys: Set): boolean {
@@ -230,16 +231,18 @@ function SkillTile({ skill, onUninstall, onClick }: SkillTileProps) {
)}
{!skill.version && }
{canUninstall && (
- {
e.stopPropagation();
onUninstall();
}}
- className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-2 py-1 text-[10px] font-medium text-coral-700 dark:text-coral-300 opacity-0 transition-all group-hover:opacity-100 hover:bg-coral-100 dark:hover:bg-coral-500/20">
+ className="opacity-0 group-hover:opacity-100">
{t('skills.disconnect')}
-
+
)}
@@ -339,17 +342,17 @@ function CatalogTile({ entry, installed, installing, onInstall, onClick }: Catal
{t('skills.explorer.installed')}
) : (
- {
e.stopPropagation();
onInstall();
- }}
- className="rounded-lg border border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 px-2 py-1 text-[10px] font-medium text-primary-700 dark:text-primary-300 transition-colors hover:bg-primary-100 dark:hover:bg-primary-500/20 disabled:opacity-50">
+ }}>
{installing ? t('skills.explorer.installing') : t('skills.explorer.install')}
-
+
)}
@@ -412,10 +415,13 @@ function SkillDetailDialog({
)}
-
+ className="flex-shrink-0 text-stone-400 dark:text-neutral-500">
-
+
@@ -497,13 +503,9 @@ function SkillDetailDialog({
{!installed && onInstall && (
-
+
{installing ? t('skills.explorer.installing') : t('skills.explorer.install')}
-
+
)}
@@ -735,45 +737,50 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
{t('skills.explorer.subtitle')}
- setInstallDialogOpen(true)}
- className="flex-shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 shadow-sm transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1">
+ className="flex-shrink-0">
{t('skills.explorer.installFromUrl')}
-
+
{/* View toggle */}
-
- setView('registry')}
- className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
- view === 'registry'
- ? 'border-primary-200 dark:border-primary-500/40 bg-primary-50 dark:bg-primary-500/15 text-primary-700 dark:text-primary-300'
- : 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/60'
- }`}>
- {t('skills.explorer.registryTab')}
- {catalogTotal > 0 && (
- {catalogTotal.toLocaleString()}
- )}
-
- setView('installed')}
- className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
- view === 'installed'
- ? 'border-primary-200 dark:border-primary-500/40 bg-primary-50 dark:bg-primary-500/15 text-primary-700 dark:text-primary-300'
- : 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/60'
- }`}>
- {t('skills.explorer.installedTab')}
- {skills.length > 0 && (
- {skills.length}
- )}
-
-
+
+ className="flex gap-2 px-1 pb-3"
+ ariaLabel={t('skills.explorer.title')}
+ value={view}
+ onChange={setView}
+ items={[
+ {
+ id: 'registry',
+ label: (
+ <>
+ {t('skills.explorer.registryTab')}
+ {catalogTotal > 0 && (
+
+ {catalogTotal.toLocaleString()}
+
+ )}
+ >
+ ),
+ },
+ {
+ id: 'installed',
+ label: (
+ <>
+ {t('skills.explorer.installedTab')}
+ {skills.length > 0 && (
+ {skills.length}
+ )}
+ >
+ ),
+ },
+ ]}
+ />
{/* Source toggles */}
{view === 'registry' && sources.length > 0 && (
@@ -829,12 +836,15 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
/>
{view === 'registry' && (
- void fetchCatalog(debouncedQuery, activeSourceFilter, true)}
disabled={catalogLoading}
title={t('skills.explorer.refreshRegistry')}
- className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-500 dark:text-neutral-400 shadow-sm transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800 disabled:opacity-50">
+ aria-label={t('skills.explorer.refreshRegistry')}
+ className="flex-shrink-0 text-stone-500 dark:text-neutral-400 shadow-sm">
-
+
)}
@@ -862,16 +872,18 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
{!loading && error && (
{error}
-
void (view === 'installed'
? fetchSkills()
: fetchCatalog(debouncedQuery, activeSourceFilter, true))
}
- className="mt-2 rounded-lg border border-coral-200 dark:border-coral-500/30 px-3 py-1 text-[11px] font-medium text-coral-700 dark:text-coral-300 hover:bg-coral-100 dark:hover:bg-coral-500/20">
+ className="mt-2">
{t('common.retry')}
-
+
)}
diff --git a/app/src/components/skills/SmartIssuePicker.test.tsx b/app/src/components/skills/SmartIssuePicker.test.tsx
index 01ff17aaf..317b8dc76 100644
--- a/app/src/components/skills/SmartIssuePicker.test.tsx
+++ b/app/src/components/skills/SmartIssuePicker.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import SmartIssuePicker from './SmartIssuePicker';
@@ -80,4 +80,48 @@ describe('SmartIssuePicker', () => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});
+
+ it('loads branches and patches target_branch after selecting a repo', async () => {
+ mockListConnections.mockResolvedValue({
+ connections: [{ toolkit: 'github', status: 'ACTIVE', username: 'testuser' }],
+ });
+ // Key responses by the composio tool name so extra/initial calls don't
+ // desync a strict once-sequence.
+ mockExecute.mockImplementation((tool: string) => {
+ if (tool === 'GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER') {
+ return Promise.resolve({
+ successful: true,
+ data: [
+ {
+ full_name: 'testuser/myrepo',
+ name: 'myrepo',
+ owner: { login: 'testuser' },
+ private: false,
+ default_branch: 'main',
+ },
+ ],
+ });
+ }
+ if (tool === 'GITHUB_GET_A_REPOSITORY') {
+ return Promise.resolve({ successful: true, data: { fork: false, default_branch: 'main' } });
+ }
+ if (tool === 'GITHUB_LIST_BRANCHES') {
+ return Promise.resolve({ successful: true, data: [{ name: 'main' }, { name: 'dev' }] });
+ }
+ return Promise.resolve({ successful: true, data: [] });
+ });
+
+ const onPatchInputs = vi.fn();
+ render( );
+
+ const repoSelect = await screen.findByRole('combobox');
+ await waitFor(() => expect(mockExecute).toHaveBeenCalled());
+
+ fireEvent.change(repoSelect, { target: { value: 'testuser/myrepo' } });
+
+ // Branch load resolves and patches the default branch.
+ await waitFor(() =>
+ expect(onPatchInputs).toHaveBeenCalledWith(expect.objectContaining({ target_branch: 'main' }))
+ );
+ });
});
diff --git a/app/src/components/skills/SmartIssuePicker.tsx b/app/src/components/skills/SmartIssuePicker.tsx
index 06389c184..fd1c33351 100644
--- a/app/src/components/skills/SmartIssuePicker.tsx
+++ b/app/src/components/skills/SmartIssuePicker.tsx
@@ -22,7 +22,6 @@
// path is to extend `skill.toml`'s `[[inputs]]` with an optional
// `picker = "github-issue"` discriminator and route here from that;
// see docs/skills-runner-unification.md open question 1.
-
import createDebug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -55,12 +54,7 @@ export interface SmartIssuePickerProps {
values: { repo?: string; upstream?: string; target_branch?: string; fork_owner?: string };
/** Patch the parent's form-values map with the picker's resolutions. */
onPatchInputs: (
- patch: Partial<{
- repo: string;
- upstream: string;
- target_branch: string;
- fork_owner: string;
- }>
+ patch: Partial<{ repo: string; upstream: string; target_branch: string; fork_owner: string }>
) => void;
}
@@ -90,7 +84,7 @@ const SmartIssuePicker = ({ values, onPatchInputs }: SmartIssuePickerProps) => {
try {
const connections = await listConnections();
const ghConn = connections.connections?.find(
- (c) =>
+ c =>
c.toolkit.toLowerCase().includes('github') &&
(c.status === 'ACTIVE' || c.status === 'CONNECTED')
);
@@ -105,7 +99,7 @@ const SmartIssuePicker = ({ values, onPatchInputs }: SmartIssuePickerProps) => {
? raw
: ((raw as Record)?.repositories ?? []);
if (Array.isArray(items)) {
- repoList = (items as Record[]).map((r) => ({
+ repoList = (items as Record[]).map(r => ({
owner: String((r.owner as Record)?.login ?? r.owner ?? ''),
repo: String(r.name ?? ''),
fullName: String(
@@ -185,7 +179,7 @@ const SmartIssuePicker = ({ values, onPatchInputs }: SmartIssuePickerProps) => {
}
defaultBranch = data.default_branch ?? 'main';
} else {
- const fromList = repos.find((r) => r.fullName === repoFullName);
+ const fromList = repos.find(r => r.fullName === repoFullName);
defaultBranch = fromList?.defaultBranch ?? 'main';
}
@@ -216,16 +210,16 @@ const SmartIssuePicker = ({ values, onPatchInputs }: SmartIssuePickerProps) => {
}
if (list.length > 0) {
setBranches(list);
- const hasDefault = list.some((b) => b.name === defaultBranch);
+ const hasDefault = list.some(b => b.name === defaultBranch);
onPatchInputs({ target_branch: hasDefault ? defaultBranch : list[0].name });
} else {
const fallback = [...new Set([defaultBranch, 'main', 'master'])];
- setBranches(fallback.map((name) => ({ name })));
+ setBranches(fallback.map(name => ({ name })));
onPatchInputs({ target_branch: defaultBranch });
}
} else {
const fallback = [...new Set([defaultBranch, 'main', 'master'])];
- setBranches(fallback.map((name) => ({ name })));
+ setBranches(fallback.map(name => ({ name })));
onPatchInputs({ target_branch: defaultBranch });
}
} catch (err) {
@@ -249,8 +243,7 @@ const SmartIssuePicker = ({ values, onPatchInputs }: SmartIssuePickerProps) => {
+ className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-1">
{t('settings.devWorkflow.githubRepository')}
{reposError && (
@@ -261,16 +254,15 @@ const SmartIssuePicker = ({ values, onPatchInputs }: SmartIssuePickerProps) => {
)}
@@ -149,13 +144,14 @@ export default function VoiceSetupModal({ onClose, skillStatus }: Props) {
{enableError && {enableError} }
- void handleEnable()}
disabled={isEnabling}
- className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
+ className="w-full">
{isEnabling ? t('skills.setup.voice.starting') : t('skills.setup.voice.startBtn')}
-
+
)}
diff --git a/app/src/components/skills/WorkflowRunnerBody.tsx b/app/src/components/skills/WorkflowRunnerBody.tsx
index 63757c3a1..8611d1bc6 100644
--- a/app/src/components/skills/WorkflowRunnerBody.tsx
+++ b/app/src/components/skills/WorkflowRunnerBody.tsx
@@ -34,6 +34,7 @@ import {
openhumanCronRuns,
openhumanCronUpdate,
} from '../../utils/tauriCommands/cron';
+import Button from '../ui/Button';
import CreateSkillModal from './CreateSkillModal';
import BranchPicker from './inputs/BranchPicker';
import RepoPicker from './inputs/RepoPicker';
@@ -1171,27 +1172,26 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
{/* Run now + Edit — act on this workflow with the inputs above. */}
- void handleRun()}
disabled={
run.status === 'submitting' || runBusy || missingRequired.length > 0
- }
- className="rounded bg-primary-600 hover:bg-primary-700 disabled:opacity-50 px-4 py-2 text-sm font-medium text-white">
+ }>
{run.status === 'submitting'
? t('settings.skillsRunner.starting')
: t('settings.skillsRunner.runNow')}
-
+
{selectedWorkflow &&
selectedWorkflow.scope === 'user' &&
!selectedWorkflow.legacy && (
- setEditOpen(true)}
- className="inline-flex items-center gap-1 rounded border border-stone-300 dark:border-stone-600 bg-white dark:bg-stone-800 px-3 py-2 text-sm font-medium text-stone-700 dark:text-stone-200 hover:bg-stone-50 dark:hover:bg-stone-700">
- {t('common.edit')}
-
+ leadingIcon={}>
+ {t('common.edit')}
+
)}
@@ -1266,15 +1266,15 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
))}
- void handleSaveSchedule()}
disabled={savingSchedule || missingRequired.length > 0}
- className="rounded-xl border border-primary-700/30 bg-primary-600 hover:bg-primary-700 active:bg-primary-800 disabled:bg-stone-300 disabled:border-stone-300 dark:disabled:bg-stone-700 dark:disabled:border-stone-700 disabled:text-stone-600 dark:disabled:text-stone-300 px-4 py-2 text-sm font-semibold text-white shadow-soft transition-colors">
+ className="shadow-soft">
{savingSchedule
? t('settings.skillsRunner.schedule.saving')
: t('settings.skillsRunner.schedule.save')}
-
+
{scheduleSaved && (
@@ -1333,19 +1333,20 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
testIdRoot={`scheduled-job-${job.id}`}
actions={
<>
- void handleRunJobNow(job)}
- className="rounded-lg border border-primary-700/30 bg-primary-600 hover:bg-primary-700 active:bg-primary-800 disabled:opacity-50 px-2.5 py-1 text-xs font-semibold text-white transition-colors">
+ onClick={() => void handleRunJobNow(job)}>
{t('settings.skillsRunner.schedule.runNow')}
-
- void handleRemoveJob(job.id)}
- className="rounded-lg border border-red-700/40 bg-red-600 hover:bg-red-700 active:bg-red-800 px-2.5 py-1 text-xs font-semibold text-white transition-colors">
+
+ void handleRemoveJob(job.id)}>
{t('settings.skillsRunner.schedule.remove')}
-
+
>
}>
{/* The inputs this schedule was created with —
@@ -1470,12 +1471,13 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
? t('settings.skillsRunner.recentRuns.headingForSkill')
: t('settings.skillsRunner.recentRuns.headingAll')}
- setRecentRunsRefreshNonce(n => n + 1)}
- className="text-xs text-stone-600 dark:text-stone-400 hover:underline">
+ className="px-0 text-stone-600 hover:bg-transparent hover:underline dark:text-stone-400">
{t('settings.skillsRunner.recentRuns.refresh')}
-
+
{recentRunsLoading ? (
@@ -1532,13 +1534,15 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
{r.status === 'RUNNING' && (
- void handleStopRun(r.run_id)}
- className="inline-flex items-center gap-1 rounded border border-coral-300 bg-coral-50 px-2 py-0.5 text-[11px] font-medium text-coral-700 hover:bg-coral-100">
- {t('autocomplete.stop')}
-
+ leadingIcon={}>
+ {t('autocomplete.stop')}
+
)}
diff --git a/app/src/components/skills/__tests__/CreateSkillModal.test.tsx b/app/src/components/skills/__tests__/CreateSkillModal.test.tsx
index e8caf1f64..8dd1bce04 100644
--- a/app/src/components/skills/__tests__/CreateSkillModal.test.tsx
+++ b/app/src/components/skills/__tests__/CreateSkillModal.test.tsx
@@ -21,7 +21,9 @@ vi.mock('../../../services/api/workflowsApi', () => ({
workflowsApi: {
createWorkflow: vi.fn(),
updateWorkflow: vi.fn(),
- describeWorkflow: vi.fn().mockResolvedValue({ id: 'e', name: 'e', when_to_use: '', inputs: [] }),
+ describeWorkflow: vi
+ .fn()
+ .mockResolvedValue({ id: 'e', name: 'e', when_to_use: '', inputs: [] }),
},
}));
@@ -115,11 +117,7 @@ describe('CreateSkillModal', () => {
});
expect(vi.mocked(workflowsApi.createWorkflow)).toHaveBeenCalledWith(
- expect.objectContaining({
- name: 'My Skill',
- description: 'does stuff',
- scope: 'user',
- })
+ expect.objectContaining({ name: 'My Skill', description: 'does stuff', scope: 'user' })
);
expect(onCreated).toHaveBeenCalledWith(created);
});
diff --git a/app/src/components/skills/__tests__/CreateWorkflowForm.test.tsx b/app/src/components/skills/__tests__/CreateWorkflowForm.test.tsx
index 9d195de1b..a8d882c3d 100644
--- a/app/src/components/skills/__tests__/CreateWorkflowForm.test.tsx
+++ b/app/src/components/skills/__tests__/CreateWorkflowForm.test.tsx
@@ -16,6 +16,8 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import CreateWorkflowForm, { previewSlug } from '../CreateWorkflowForm';
+
const stableT = (key: string) => key;
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: stableT }) }));
@@ -33,8 +35,6 @@ vi.mock('../../../services/api/workflowsApi', () => ({
},
}));
-import CreateWorkflowForm, { previewSlug } from '../CreateWorkflowForm';
-
const FORM_ID = 'create-skill-test-form';
describe('previewSlug', () => {
@@ -61,9 +61,7 @@ describe('CreateWorkflowForm', () => {
});
it('renders required fields and the slug preview updates as the name changes', () => {
- render(
-
- );
+ render( );
const nameInput = screen.getByLabelText(/skills.create.name/i) as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: 'My Cool Skill' } });
@@ -126,12 +124,15 @@ describe('CreateWorkflowForm', () => {
});
it('includes whenToUse in the payload when the trigger field is filled', async () => {
- hoisted.createWorkflow.mockResolvedValue({ id: 'wf', name: 'wf', scope: 'user', legacy: false });
+ hoisted.createWorkflow.mockResolvedValue({
+ id: 'wf',
+ name: 'wf',
+ scope: 'user',
+ legacy: false,
+ });
render( );
- fireEvent.change(screen.getByLabelText(/skills.create.name/i), {
- target: { value: 'Triage' },
- });
+ fireEvent.change(screen.getByLabelText(/skills.create.name/i), { target: { value: 'Triage' } });
fireEvent.change(screen.getByLabelText(/skills.create.description/i), {
target: { value: 'Summarise the inbox.' },
});
@@ -154,9 +155,7 @@ describe('CreateWorkflowForm', () => {
hoisted.createWorkflow.mockRejectedValue(new Error('slug already exists'));
render( );
- fireEvent.change(screen.getByLabelText(/skills.create.name/i), {
- target: { value: 'Dupe' },
- });
+ fireEvent.change(screen.getByLabelText(/skills.create.name/i), { target: { value: 'Dupe' } });
fireEvent.change(screen.getByLabelText(/skills.create.description/i), {
target: { value: 'whatever' },
});
@@ -350,7 +349,12 @@ describe('CreateWorkflowForm', () => {
expect(hoisted.createWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
inputs: [
- { name: 'issue', required: false, type: 'integer', description: 'Issue number to work on' },
+ {
+ name: 'issue',
+ required: false,
+ type: 'integer',
+ description: 'Issue number to work on',
+ },
],
})
);
diff --git a/app/src/components/skills/__tests__/InstallSkillDialog.test.tsx b/app/src/components/skills/__tests__/InstallSkillDialog.test.tsx
index a66e2c5df..2d1807235 100644
--- a/app/src/components/skills/__tests__/InstallSkillDialog.test.tsx
+++ b/app/src/components/skills/__tests__/InstallSkillDialog.test.tsx
@@ -17,9 +17,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import InstallSkillDialog from '../InstallSkillDialog';
vi.mock('../../../services/api/workflowsApi', () => ({
- workflowsApi: {
- installWorkflowFromUrl: vi.fn(),
- },
+ workflowsApi: { installWorkflowFromUrl: vi.fn() },
}));
describe('InstallSkillDialog', () => {
@@ -148,9 +146,7 @@ describe('InstallSkillDialog', () => {
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Could not install skill');
});
- expect(screen.getByRole('alert')).toHaveTextContent(
- 'unexpected: something weird happened'
- );
+ expect(screen.getByRole('alert')).toHaveTextContent('unexpected: something weird happened');
expect(submit.disabled).toBe(false);
});
diff --git a/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx b/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx
index cf9a1dde8..2d12f0575 100644
--- a/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx
+++ b/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx
@@ -2,10 +2,7 @@ import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MeetCallRecord } from '../../../services/meetCallService';
-import {
- setBackendMeetError,
- setBackendMeetJoined,
-} from '../../../store/backendMeetSlice';
+import { setBackendMeetError, setBackendMeetJoined } from '../../../store/backendMeetSlice';
import { renderWithProviders } from '../../../test/test-utils';
import MeetingBotsCard from '../MeetingBotsCard';
@@ -73,9 +70,7 @@ describe('MeetingBotsCard', () => {
// MeetingBotsInline to ActiveMeetingView. The inline component is unmounted at
// that point, so its useEffect success-toast branch does not fire. Verify the
// active view is now shown instead.
- store.dispatch(
- setBackendMeetJoined({ meetUrl: 'https://meet.google.com/abc-defg-hij' })
- );
+ store.dispatch(setBackendMeetJoined({ meetUrl: 'https://meet.google.com/abc-defg-hij' }));
await vi.waitFor(() => {
expect(screen.getAllByText(/live/i).length).toBeGreaterThan(0);
});
@@ -154,20 +149,13 @@ describe('MeetingBotsCard', () => {
fireEvent.submit(document.querySelector('form')!);
await vi.waitFor(() => expect(joinMock).toHaveBeenCalled());
- store.dispatch(
- setBackendMeetError({ error: 'Meeting bot is a paid-plan feature.' })
- );
+ store.dispatch(setBackendMeetError({ error: 'Meeting bot is a paid-plan feature.' }));
await vi.waitFor(() => {
- expect(screen.getByRole('alert')).toHaveTextContent(
- 'Meeting bot is a paid-plan feature.'
- );
+ expect(screen.getByRole('alert')).toHaveTextContent('Meeting bot is a paid-plan feature.');
});
expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({
- type: 'error',
- title: expect.stringMatching(/not start/i),
- })
+ expect.objectContaining({ type: 'error', title: expect.stringMatching(/not start/i) })
);
});
@@ -363,9 +351,7 @@ describe('MeetingBotsCard — recent calls section', () => {
});
it('omits the participants line when the record has none', async () => {
- listMock.mockResolvedValueOnce([
- makeCallRecord({ owner_display_name: '', participants: [] }),
- ]);
+ listMock.mockResolvedValueOnce([makeCallRecord({ owner_display_name: '', participants: [] })]);
renderWithProviders( );
await waitFor(() => {
expect(screen.getByText(/3 turns/i)).toBeInTheDocument();
diff --git a/app/src/components/skills/__tests__/ScreenIntelligenceSetupModal.test.tsx b/app/src/components/skills/__tests__/ScreenIntelligenceSetupModal.test.tsx
index 33c2ae4df..fc890819f 100644
--- a/app/src/components/skills/__tests__/ScreenIntelligenceSetupModal.test.tsx
+++ b/app/src/components/skills/__tests__/ScreenIntelligenceSetupModal.test.tsx
@@ -16,7 +16,13 @@ vi.mock('react-router-dom', async () => {
return {
...actual,
useNavigate: vi.fn(() => vi.fn()),
- useLocation: () => ({ pathname: '/connections', search: '', hash: '', state: null, key: 'test' }),
+ useLocation: () => ({
+ pathname: '/connections',
+ search: '',
+ hash: '',
+ state: null,
+ key: 'test',
+ }),
};
});
diff --git a/app/src/components/skills/__tests__/SkillsExplorerTab.test.tsx b/app/src/components/skills/__tests__/SkillsExplorerTab.test.tsx
index d9cf432ed..380e117d3 100644
--- a/app/src/components/skills/__tests__/SkillsExplorerTab.test.tsx
+++ b/app/src/components/skills/__tests__/SkillsExplorerTab.test.tsx
@@ -108,9 +108,7 @@ const MOCK_CATALOG_ENTRY_WITH_META: CatalogEntry = {
describe('SkillsExplorerTab', () => {
beforeEach(async () => {
const { workflowsApi } = await import('../../../services/api/workflowsApi');
- const { skillRegistryApi } = await import(
- '../../../services/api/skillRegistryApi'
- );
+ const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
vi.mocked(workflowsApi.listWorkflows).mockReset();
vi.mocked(workflowsApi.uninstallWorkflow).mockReset();
vi.mocked(skillRegistryApi.browse).mockReset();
@@ -124,9 +122,7 @@ describe('SkillsExplorerTab', () => {
it('defaults to registry view and shows catalog entries', async () => {
const { workflowsApi } = await import('../../../services/api/workflowsApi');
- const { skillRegistryApi } = await import(
- '../../../services/api/skillRegistryApi'
- );
+ const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
@@ -140,9 +136,7 @@ describe('SkillsExplorerTab', () => {
it('searches catalog via RPC when typing in search box', async () => {
const { workflowsApi } = await import('../../../services/api/workflowsApi');
- const { skillRegistryApi } = await import(
- '../../../services/api/skillRegistryApi'
- );
+ const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
vi.mocked(skillRegistryApi.search).mockResolvedValue([MOCK_DOCKER_ENTRY]);
@@ -159,9 +153,12 @@ describe('SkillsExplorerTab', () => {
});
// Wait for the debounce to fire and the RPC search to be called
- await waitFor(() => {
- expect(skillRegistryApi.search).toHaveBeenCalledWith('docker', undefined);
- }, { timeout: 2000 });
+ await waitFor(
+ () => {
+ expect(skillRegistryApi.search).toHaveBeenCalledWith('docker', undefined);
+ },
+ { timeout: 2000 }
+ );
await waitFor(() => {
expect(screen.getByText('Docker Manager')).toBeInTheDocument();
@@ -199,9 +196,7 @@ describe('SkillsExplorerTab', () => {
});
it('shows error state on registry fetch failure', async () => {
- const { skillRegistryApi } = await import(
- '../../../services/api/skillRegistryApi'
- );
+ const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
const { workflowsApi } = await import('../../../services/api/workflowsApi');
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
vi.mocked(skillRegistryApi.browse).mockRejectedValue(new Error('Network error'));
@@ -288,10 +283,7 @@ describe('SkillsExplorerTab', () => {
it('shows skill warnings when present', async () => {
const { workflowsApi } = await import('../../../services/api/workflowsApi');
- const skillWithWarning = {
- ...MOCK_SKILL,
- warnings: ['Missing required field: author'],
- };
+ const skillWithWarning = { ...MOCK_SKILL, warnings: ['Missing required field: author'] };
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([skillWithWarning]);
render( );
@@ -304,9 +296,7 @@ describe('SkillsExplorerTab', () => {
it('shows "Installed" badge for already-installed catalog entries', async () => {
const { workflowsApi } = await import('../../../services/api/workflowsApi');
- const { skillRegistryApi } = await import(
- '../../../services/api/skillRegistryApi'
- );
+ const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
const catalogEntry = {
...MOCK_CATALOG_ENTRY,
id: 'built-in/apple-notes',
@@ -345,9 +335,7 @@ describe('SkillsExplorerTab', () => {
it('does not mark catalog entries installed by display name alone', async () => {
const { workflowsApi } = await import('../../../services/api/workflowsApi');
- const { skillRegistryApi } = await import(
- '../../../services/api/skillRegistryApi'
- );
+ const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
const catalogEntry = {
...MOCK_CATALOG_ENTRY,
id: 'built-in/apple-notes',
@@ -367,9 +355,7 @@ describe('SkillsExplorerTab', () => {
const tile = await screen.findByTestId('registry-tile-built-in/apple-notes');
expect(within(tile).queryByText('Installed')).not.toBeInTheDocument();
- expect(
- within(tile).getByTestId('registry-install-built-in/apple-notes')
- ).toBeInTheDocument();
+ expect(within(tile).getByTestId('registry-install-built-in/apple-notes')).toBeInTheDocument();
});
it('has an install from URL button', async () => {
@@ -381,9 +367,7 @@ describe('SkillsExplorerTab', () => {
await waitFor(() => {
expect(screen.getByTestId('skill-install-from-url-btn')).toBeInTheDocument();
});
- expect(screen.getByTestId('skill-install-from-url-btn')).toHaveTextContent(
- 'Install from URL'
- );
+ expect(screen.getByTestId('skill-install-from-url-btn')).toHaveTextContent('Install from URL');
});
it('shows "no results" when installed skills exist but search has no matches', async () => {
@@ -487,7 +471,7 @@ describe('SkillsExplorerTab', () => {
// Dialog open — find the backdrop and click it
await waitFor(() => {
// The close button (×) should be visible
- expect(screen.getByRole('button', { name: '' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
});
// Click the ×-close button inside the dialog header
@@ -553,9 +537,7 @@ describe('SkillsExplorerTab', () => {
expect(skillRegistryApi.install).toHaveBeenCalledWith('registry-skill-1');
});
await waitFor(() => {
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'success' })
- );
+ expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
});
});
@@ -578,9 +560,7 @@ describe('SkillsExplorerTab', () => {
});
await waitFor(() => {
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'error' })
- );
+ expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
});
});
@@ -597,7 +577,9 @@ describe('SkillsExplorerTab', () => {
// Source toggles are rendered as buttons with their source name text
await waitFor(() => {
const buttons = screen.getAllByRole('button');
- const sourceButtons = buttons.filter(b => b.textContent === 'built-in' || b.textContent === 'ClawHub');
+ const sourceButtons = buttons.filter(
+ b => b.textContent === 'built-in' || b.textContent === 'ClawHub'
+ );
expect(sourceButtons.length).toBe(2);
});
});
@@ -671,7 +653,12 @@ describe('SkillsExplorerTab', () => {
it('displays SkillFormatBadge with fallback label for unknown format', async () => {
const { workflowsApi } = await import('../../../services/api/workflowsApi');
- const unknownFormatSkill = { ...MOCK_SKILL, id: 'unk-skill', name: 'Unknown Format Skill', sourceFormat: 'unknown-format' };
+ const unknownFormatSkill = {
+ ...MOCK_SKILL,
+ id: 'unk-skill',
+ name: 'Unknown Format Skill',
+ sourceFormat: 'unknown-format',
+ };
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([unknownFormatSkill]);
render( );
@@ -794,9 +781,9 @@ describe('SkillsExplorerTab', () => {
expect(screen.getByText('Hermes Skill')).toBeInTheDocument();
});
- const allTiles = screen.getAllByRole('button').filter(
- el => el.getAttribute('data-testid')?.startsWith('skill-explorer-tile')
- );
+ const allTiles = screen
+ .getAllByRole('button')
+ .filter(el => el.getAttribute('data-testid')?.startsWith('skill-explorer-tile'));
// Hermes should come first
expect(allTiles[0]).toHaveAttribute('data-testid', 'skill-explorer-tile-hermes');
expect(allTiles[1]).toHaveAttribute('data-testid', 'skill-explorer-tile-alpha');
diff --git a/app/src/components/skills/__tests__/UninstallSkillConfirmDialog.test.tsx b/app/src/components/skills/__tests__/UninstallSkillConfirmDialog.test.tsx
index 96be81dce..36dcb9178 100644
--- a/app/src/components/skills/__tests__/UninstallSkillConfirmDialog.test.tsx
+++ b/app/src/components/skills/__tests__/UninstallSkillConfirmDialog.test.tsx
@@ -13,13 +13,11 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import UninstallSkillConfirmDialog from '../UninstallSkillConfirmDialog';
import type { WorkflowSummary } from '../../../services/api/workflowsApi';
+import UninstallSkillConfirmDialog from '../UninstallSkillConfirmDialog';
vi.mock('../../../services/api/workflowsApi', () => ({
- workflowsApi: {
- uninstallWorkflow: vi.fn(),
- },
+ workflowsApi: { uninstallWorkflow: vi.fn() },
}));
const fixture: WorkflowSummary = {
@@ -49,11 +47,7 @@ describe('UninstallSkillConfirmDialog', () => {
it('renders skill name, path (stripped of /SKILL.md), and confirm copy', () => {
render(
-
+
);
expect(screen.getByText(/Delete weather-helper\?/)).toBeInTheDocument();
expect(screen.getByText(/permanently deletes/i)).toBeInTheDocument();
@@ -92,18 +86,16 @@ describe('UninstallSkillConfirmDialog', () => {
await waitFor(() => {
expect(vi.mocked(workflowsApi.uninstallWorkflow)).toHaveBeenCalledWith('weather-helper');
});
- expect(vi.mocked(workflowsApi.uninstallWorkflow)).not.toHaveBeenCalledWith('Weather Helper (Pro)');
+ expect(vi.mocked(workflowsApi.uninstallWorkflow)).not.toHaveBeenCalledWith(
+ 'Weather Helper (Pro)'
+ );
});
it('Cancel fires onClose without calling the RPC', async () => {
const onClose = vi.fn();
const { workflowsApi } = await import('../../../services/api/workflowsApi');
render(
-
+
);
fireEvent.click(screen.getByRole('button', { name: /Cancel/ }));
expect(onClose).toHaveBeenCalledTimes(1);
@@ -185,21 +177,15 @@ describe('UninstallSkillConfirmDialog', () => {
}) => void;
const deferred: { resolve?: UninstallResolve } = {};
vi.mocked(workflowsApi.uninstallWorkflow).mockReturnValueOnce(
- new Promise<{
- name: string;
- removedPath: string;
- scope: WorkflowSummary['scope'];
- }>(resolve => {
- deferred.resolve = resolve;
- })
+ new Promise<{ name: string; removedPath: string; scope: WorkflowSummary['scope'] }>(
+ resolve => {
+ deferred.resolve = resolve;
+ }
+ )
);
render(
-
+
);
fireEvent.click(screen.getByTestId('uninstall-skill-confirm'));
diff --git a/app/src/components/skills/__tests__/WorkflowRunnerBody.test.tsx b/app/src/components/skills/__tests__/WorkflowRunnerBody.test.tsx
index 45d6d8f3c..fd0da2485 100644
--- a/app/src/components/skills/__tests__/WorkflowRunnerBody.test.tsx
+++ b/app/src/components/skills/__tests__/WorkflowRunnerBody.test.tsx
@@ -971,9 +971,7 @@ describe('WorkflowRunnerBody — recent runs status badges', () => {
renderBody(Body);
await waitFor(() => {
- expect(
- screen.getByText('settings.skillsRunner.recentRuns.headingAll')
- ).toBeInTheDocument();
+ expect(screen.getByText('settings.skillsRunner.recentRuns.headingAll')).toBeInTheDocument();
});
});
@@ -1124,7 +1122,19 @@ describe('WorkflowRunnerBody — history run with no output', () => {
it('shows historyNoOutput placeholder when a run row has null output', async () => {
hoisted.cronRuns.mockResolvedValue({
- result: { runs: [{ id: 1, job_id: 'job-1', started_at: '2026-05-30T09:00:00Z', finished_at: '2026-05-30T09:00:05Z', status: 'ok', output: null, duration_ms: 5000 }] },
+ result: {
+ runs: [
+ {
+ id: 1,
+ job_id: 'job-1',
+ started_at: '2026-05-30T09:00:00Z',
+ finished_at: '2026-05-30T09:00:05Z',
+ status: 'ok',
+ output: null,
+ duration_ms: 5000,
+ },
+ ],
+ },
});
const Body = await importBody();
@@ -1210,9 +1220,7 @@ describe('WorkflowRunnerBody — buildCronJobName with non-empty inputs', () =>
id: 'name-skill',
name: 'Name Skill',
when_to_use: 'test.',
- inputs: [
- { name: 'owner', type: 'string', required: true, description: 'Owner' },
- ],
+ inputs: [{ name: 'owner', type: 'string', required: true, description: 'Owner' }],
});
});
@@ -1248,11 +1256,9 @@ describe('WorkflowRunnerBody — ensureRuntimeAvailability failure', () => {
hoisted.cronList.mockResolvedValue({ result: [] });
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
hoisted.runWorkflow.mockResolvedValue({ run_id: 'r', skill_id: 'x', log: '/tmp/l' });
- hoisted.listWorkflows.mockResolvedValue([{
- id: 'py-skill',
- name: 'Python Skill',
- resources: ['scripts/run.py'],
- }]);
+ hoisted.listWorkflows.mockResolvedValue([
+ { id: 'py-skill', name: 'Python Skill', resources: ['scripts/run.py'] },
+ ]);
hoisted.describeWorkflow.mockResolvedValue({
id: 'py-skill',
name: 'Python Skill',
@@ -1264,7 +1270,16 @@ describe('WorkflowRunnerBody — ensureRuntimeAvailability failure', () => {
it('surfaces runtime unavailable error on run when python runtime is missing', async () => {
hoisted.resolveRuntimes.mockResolvedValue({
runtimes: [
- { runtime: 'python', enabled: true, available: false, source: 'managed', version: null, binary: null, binDir: null, error: 'not installed' },
+ {
+ runtime: 'python',
+ enabled: true,
+ available: false,
+ source: 'managed',
+ version: null,
+ binary: null,
+ binDir: null,
+ error: 'not installed',
+ },
],
});
diff --git a/app/src/components/skills/inputs/BranchPicker.tsx b/app/src/components/skills/inputs/BranchPicker.tsx
index bb7677510..869864527 100644
--- a/app/src/components/skills/inputs/BranchPicker.tsx
+++ b/app/src/components/skills/inputs/BranchPicker.tsx
@@ -10,7 +10,6 @@
// Refetches whenever `repo` changes. Like RepoPicker, this is a
// parallel component to the inline impl in DevWorkflowPanel — the
// original panel stays untouched.
-
import createDebug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -121,10 +120,9 @@ const BranchPicker = ({ value, onChange, repo, id, placeholder, disabled }: Bran
onChange(e.target.value)}
+ onChange={e => onChange(e.target.value)}
disabled={disabled || loading || !repo}
- className={selectClass}
- >
+ className={selectClass}>
- {branches.map((b) => (
+ {branches.map(b => (
))}
- {error && (
- {error}
- )}
+ {error && {error}
}
);
};
diff --git a/app/src/components/skills/inputs/RepoPicker.tsx b/app/src/components/skills/inputs/RepoPicker.tsx
index 70ca40313..f98667644 100644
--- a/app/src/components/skills/inputs/RepoPicker.tsx
+++ b/app/src/components/skills/inputs/RepoPicker.tsx
@@ -13,7 +13,6 @@
// identically to the Settings → Dev Workflow panel. The original panel
// stays in place with its own inline implementation; this is a parallel
// component for the generic Skills Runner surface.
-
import createDebug from 'debug';
import { useCallback, useEffect, useState } from 'react';
@@ -59,17 +58,14 @@ const RepoPicker = ({ value, onChange, id, placeholder, disabled }: RepoPickerPr
// Step 1: Is GitHub connected via Composio?
const conns = await listConnections();
const ghConn = conns.connections?.find(
- (c) =>
+ c =>
c.toolkit.toLowerCase().includes('github') &&
(c.status === 'ACTIVE' || c.status === 'CONNECTED')
);
if (!ghConn) throw new Error('NOT_CONNECTED');
// Step 2: Fetch repos.
- const res = await composioExecute(
- 'GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER',
- {}
- );
+ const res = await composioExecute('GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER', {});
if (!res.successful) throw new Error(res.error ?? 'Failed to fetch repositories');
// Step 3: Parse — GitHub API returns an array of repo objects;
@@ -79,7 +75,7 @@ const RepoPicker = ({ value, onChange, id, placeholder, disabled }: RepoPickerPr
? raw
: ((raw as Record)?.repositories ?? []);
const list: ComposioGhRepo[] = Array.isArray(items)
- ? (items as Record[]).map((r) => ({
+ ? (items as Record[]).map(r => ({
owner: String((r.owner as Record)?.login ?? r.owner ?? ''),
repo: String(r.name ?? ''),
fullName: String(
@@ -122,25 +118,22 @@ const RepoPicker = ({ value, onChange, id, placeholder, disabled }: RepoPickerPr
onChange(e.target.value)}
+ onChange={e => onChange(e.target.value)}
disabled={disabled || loading || error !== null}
- className={selectClass}
- >
+ className={selectClass}>
- {repos.map((r) => (
+ {repos.map(r => (
))}
- {error && (
- {error}
- )}
+ {error && {error}
}
);
};
diff --git a/app/src/components/skills/skillIcons.tsx b/app/src/components/skills/skillIcons.tsx
index 8791fe7e9..00e72b270 100644
--- a/app/src/components/skills/skillIcons.tsx
+++ b/app/src/components/skills/skillIcons.tsx
@@ -2,8 +2,6 @@ import type { ReactNode } from 'react';
import type { IconType } from 'react-icons';
import { FaDiscord, FaGlobe, FaTelegramPlane } from 'react-icons/fa';
import { IoChatbubble } from 'react-icons/io5';
-
-import YuanbaoIcon from '../channels/YuanbaoIcon';
import {
LuBlocks,
LuBot,
@@ -17,6 +15,7 @@ import {
LuWrench,
} from 'react-icons/lu';
+import YuanbaoIcon from '../channels/YuanbaoIcon';
import type { SkillCategory } from './skillCategories';
function iconClasses(...parts: Array): string {
@@ -55,37 +54,37 @@ export function getChannelIcons(
): Record {
return {
telegram: (
-
- ),
+
+ ),
discord: (
-
- ),
+
+ ),
web: (
-
- ),
+
+ ),
imessage: (
-
- ),
+
+ ),
yuanbao: (
{
expect(btn.className).toMatch(/bg-neutral-0/);
});
- it('applies ghost variant classes', () => {
- render(Skip );
+ it('applies tertiary variant classes', () => {
+ render(Skip );
const btn = screen.getByRole('button', { name: 'Skip' });
expect(btn.className).toMatch(/bg-transparent/);
expect(btn.className).toMatch(/text-neutral-700/);
});
- it('applies danger variant classes', () => {
- render(Delete );
+ it('renders a filled coral button for primary + tone="danger"', () => {
+ render(
+
+ Delete
+
+ );
const btn = screen.getByRole('button', { name: 'Delete' });
+ expect(btn.className).toMatch(/bg-coral-500/);
+ expect(btn.className).toMatch(/text-white/);
+ });
+
+ it('renders a coral outline for secondary + tone="danger"', () => {
+ render(
+
+ Remove
+
+ );
+ const btn = screen.getByRole('button', { name: 'Remove' });
expect(btn.className).toMatch(/text-coral-600/);
+ expect(btn.className).toMatch(/border-coral-300\/50/);
expect(btn.className).toMatch(/hover:bg-coral-50/);
});
+ it('renders coral text for tertiary + tone="danger"', () => {
+ render(
+
+ Discard
+
+ );
+ const btn = screen.getByRole('button', { name: 'Discard' });
+ expect(btn.className).toMatch(/text-coral-600/);
+ expect(btn.className).not.toMatch(/border-coral/);
+ });
+
+ it('squares the footprint for iconOnly (no horizontal padding)', () => {
+ render(
+
+
+
+ );
+ const btn = screen.getByRole('button', { name: 'Close' });
+ expect(btn.className).toMatch(/h-9/);
+ expect(btn.className).toMatch(/w-9/);
+ expect(btn.className).not.toMatch(/px-4/);
+ });
+
it('honors size=xl classes', () => {
render(
diff --git a/app/src/components/ui/Button.tsx b/app/src/components/ui/Button.tsx
index c4689304a..d27ad8579 100644
--- a/app/src/components/ui/Button.tsx
+++ b/app/src/components/ui/Button.tsx
@@ -1,33 +1,64 @@
import { type ButtonHTMLAttributes, forwardRef, type ReactNode } from 'react';
-export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
+/**
+ * The one button in the app. Three hierarchy variants plus an orthogonal tone:
+ *
+ * - **variant** — visual weight / importance:
+ * - `primary` the main call-to-action on a surface (Save, Continue, Create)
+ * - `secondary` an alternative of similar weight (Cancel, Back, Import)
+ * - `tertiary` low-emphasis / text-style action (Skip, links, inline actions)
+ * - **tone** — semantic intent layered on any variant:
+ * - `default` the normal palette
+ * - `danger` destructive actions (Delete, Remove, Logout) — coral
+ *
+ * Use `iconOnly` for icon-only affordances (close / refresh / add); it squares
+ * the padding — always pass an `aria-label` in that case.
+ */
+export type ButtonVariant = 'primary' | 'secondary' | 'tertiary';
+export type ButtonTone = 'default' | 'danger';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export interface ButtonProps extends ButtonHTMLAttributes {
variant?: ButtonVariant;
+ tone?: ButtonTone;
size?: ButtonSize;
+ /** Square the button for a single centered icon. Requires an `aria-label`. */
+ iconOnly?: boolean;
leadingIcon?: ReactNode;
trailingIcon?: ReactNode;
}
const BASE =
'inline-flex items-center justify-center gap-2 font-medium transition-colors duration-150 ' +
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/25 ' +
- 'focus-visible:ring-offset-2 disabled:opacity-40 disabled:pointer-events-none';
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ' +
+ 'disabled:opacity-40 disabled:pointer-events-none';
-const VARIANTS: Record = {
- primary:
- 'bg-primary-500 text-white hover:bg-primary-600 active:bg-primary-700 ' +
- 'dark:bg-primary-500 dark:hover:bg-primary-400 dark:active:bg-primary-600',
- secondary:
- 'bg-neutral-0 text-neutral-900 border border-neutral-300 hover:bg-neutral-50 ' +
- 'dark:bg-neutral-900 dark:text-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-800',
- ghost:
- 'bg-transparent text-neutral-700 hover:bg-neutral-100 ' +
- 'dark:text-neutral-200 dark:hover:bg-neutral-800',
- danger:
- 'bg-transparent text-coral-600 border border-coral-300/50 hover:bg-coral-50 ' +
- 'dark:text-coral-400 dark:border-coral-500/40 dark:hover:bg-coral-500/10',
+// variant × tone → surface classes. The focus ring colour follows the tone.
+const VARIANTS: Record> = {
+ primary: {
+ default:
+ 'bg-primary-500 text-white hover:bg-primary-600 active:bg-primary-700 focus-visible:ring-primary-500/25 ' +
+ 'dark:bg-primary-500 dark:hover:bg-primary-400 dark:active:bg-primary-600',
+ danger:
+ 'bg-coral-500 text-white hover:bg-coral-600 active:bg-coral-700 focus-visible:ring-coral-500/25 ' +
+ 'dark:bg-coral-500 dark:hover:bg-coral-400 dark:active:bg-coral-600',
+ },
+ secondary: {
+ default:
+ 'bg-neutral-0 text-neutral-900 border border-neutral-300 hover:bg-neutral-50 focus-visible:ring-primary-500/25 ' +
+ 'dark:bg-neutral-900 dark:text-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-800',
+ danger:
+ 'bg-transparent text-coral-600 border border-coral-300/50 hover:bg-coral-50 focus-visible:ring-coral-500/25 ' +
+ 'dark:text-coral-400 dark:border-coral-500/40 dark:hover:bg-coral-500/10',
+ },
+ tertiary: {
+ default:
+ 'bg-transparent text-neutral-700 hover:bg-neutral-100 focus-visible:ring-primary-500/25 ' +
+ 'dark:text-neutral-200 dark:hover:bg-neutral-800',
+ danger:
+ 'bg-transparent text-coral-600 hover:bg-coral-50 focus-visible:ring-coral-500/25 ' +
+ 'dark:text-coral-400 dark:hover:bg-coral-500/10',
+ },
};
const SIZES: Record = {
@@ -38,10 +69,21 @@ const SIZES: Record = {
xl: 'h-14 px-7 text-base rounded-xl font-medium',
};
+// Square footprints for icon-only buttons (no horizontal padding).
+const ICON_SIZES: Record = {
+ xs: 'h-6 w-6 text-xs rounded-sm',
+ sm: 'h-[30px] w-[30px] text-sm rounded-md',
+ md: 'h-9 w-9 text-sm rounded-lg',
+ lg: 'h-11 w-11 text-base rounded-lg',
+ xl: 'h-14 w-14 text-base rounded-xl',
+};
+
const Button = forwardRef((props, ref) => {
const {
variant = 'primary',
+ tone = 'default',
size = 'md',
+ iconOnly = false,
leadingIcon,
trailingIcon,
className,
@@ -50,7 +92,10 @@ const Button = forwardRef((props, ref) => {
...rest
} = props;
- const classes = [BASE, VARIANTS[variant], SIZES[size], className ?? ''].filter(Boolean).join(' ');
+ const sizeClass = (iconOnly ? ICON_SIZES : SIZES)[size];
+ const classes = [BASE, VARIANTS[variant][tone], sizeClass, className ?? '']
+ .filter(Boolean)
+ .join(' ');
return (
diff --git a/app/src/components/ui/ModalShell.tsx b/app/src/components/ui/ModalShell.tsx
index 196d62b8c..500284e57 100644
--- a/app/src/components/ui/ModalShell.tsx
+++ b/app/src/components/ui/ModalShell.tsx
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useT } from '../../lib/i18n/I18nContext';
+import Button from './Button';
import { CloseIcon } from './icons';
interface ModalShellProps {
@@ -71,13 +72,14 @@ export function ModalShell({
) : null}
-
+ onClick={onClose}>
-
+
{children}
diff --git a/app/src/components/webhooks/TunnelList.test.tsx b/app/src/components/webhooks/TunnelList.test.tsx
new file mode 100644
index 000000000..6cf3b558f
--- /dev/null
+++ b/app/src/components/webhooks/TunnelList.test.tsx
@@ -0,0 +1,48 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import TunnelList from './TunnelList';
+
+// Identity translator so we can assert on stable i18n keys.
+vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
+vi.mock('../../hooks/useBackendUrl', () => ({ useBackendUrl: () => 'http://localhost:8787' }));
+vi.mock('../../services/api/tunnelsApi', () => ({
+ tunnelsApi: { ingressUrl: () => 'http://localhost:8787/ingress/x' },
+}));
+
+const baseProps = {
+ tunnels: [],
+ registrations: [],
+ loading: false,
+ onCreateTunnel: vi.fn().mockResolvedValue({}),
+ onDeleteTunnel: vi.fn().mockResolvedValue(undefined),
+ onRefresh: vi.fn().mockResolvedValue(undefined),
+ onRegisterEcho: vi.fn().mockResolvedValue(undefined),
+ onUnregisterEcho: vi.fn().mockResolvedValue(undefined),
+};
+
+describe('TunnelList', () => {
+ it('calls onRefresh when the refresh button is clicked', () => {
+ const onRefresh = vi.fn().mockResolvedValue(undefined);
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'common.refresh' }));
+ expect(onRefresh).toHaveBeenCalledTimes(1);
+ });
+
+ it('opens the create form, cancels it, then creates a tunnel', async () => {
+ const onCreateTunnel = vi.fn().mockResolvedValue({});
+ render( );
+
+ // Open the create form.
+ fireEvent.click(screen.getByRole('button', { name: 'webhooks.tunnels.newTunnel' }));
+ // Cancel hides it again.
+ fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }));
+ // Reopen and create.
+ fireEvent.click(screen.getByRole('button', { name: 'webhooks.tunnels.newTunnel' }));
+ const nameInput = screen.getAllByRole('textbox')[0];
+ fireEvent.change(nameInput, { target: { value: 'my-tunnel' } });
+ fireEvent.click(screen.getByRole('button', { name: 'common.create' }));
+
+ await waitFor(() => expect(onCreateTunnel).toHaveBeenCalledWith('my-tunnel', undefined));
+ });
+});
diff --git a/app/src/components/webhooks/TunnelList.tsx b/app/src/components/webhooks/TunnelList.tsx
index 58591ee1d..e08302609 100644
--- a/app/src/components/webhooks/TunnelList.tsx
+++ b/app/src/components/webhooks/TunnelList.tsx
@@ -4,6 +4,7 @@ import type { TunnelRegistration } from '../../features/webhooks/types';
import { useBackendUrl } from '../../hooks/useBackendUrl';
import { useT } from '../../lib/i18n/I18nContext';
import { type Tunnel, tunnelsApi } from '../../services/api/tunnelsApi';
+import Button from '../ui/Button';
interface TunnelListProps {
tunnels: Tunnel[];
@@ -64,17 +65,12 @@ export default function TunnelList({
{t('webhooks.tunnels.title')}
-
+
{loading ? t('common.loading') : t('common.refresh')}
-
- setShowCreate(true)}
- className="px-3 py-1.5 text-sm font-medium text-white bg-primary-500 rounded-lg hover:bg-primary-600 transition-colors">
+
+ setShowCreate(true)}>
{t('webhooks.tunnels.newTunnel')}
-
+
@@ -97,17 +93,16 @@ export default function TunnelList({
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500"
/>
- setShowCreate(false)}
- className="px-3 py-1.5 text-sm text-stone-600 hover:text-stone-900 rounded-lg">
+ setShowCreate(false)}>
{t('common.cancel')}
-
-
+
+ disabled={!newName.trim() || creating}>
{creating ? t('webhooks.tunnels.creating') : t('common.create')}
-
+
)}
@@ -116,11 +111,14 @@ export default function TunnelList({
{actionError && (
{actionError}
- setActionError(null)}
- className="text-coral-500 hover:text-coral-700 text-xs ml-2">
+ className="ml-2">
{t('common.dismiss')}
-
+
)}
@@ -245,12 +243,14 @@ function TunnelCard({
{webhookUrl || 'Resolving backend URL…'}
-
+ className="whitespace-nowrap">
{copied ? t('common.copied') : t('common.copy')}
-
+
@@ -271,12 +271,14 @@ function TunnelCard({
: t('webhooks.tunnels.enableEcho')}
)}
-
+ disabled={deleting}>
{deleting ? '...' : t('common.delete')}
-
+
diff --git a/app/src/pages/Activity.tsx b/app/src/pages/Activity.tsx
index 459f8cf05..0c3faab42 100644
--- a/app/src/pages/Activity.tsx
+++ b/app/src/pages/Activity.tsx
@@ -5,7 +5,7 @@ import { ConfirmationModal } from '../components/intelligence/ConfirmationModal'
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
import { ToastContainer } from '../components/intelligence/Toast';
import WorkflowsTab from '../components/intelligence/WorkflowsTab';
-import PillTabBar from '../components/PillTabBar';
+import ChipTabs from '../components/layout/ChipTabs';
import {
useIntelligenceSocket,
useIntelligenceSocketManager,
@@ -107,29 +107,23 @@ export default function Activity() {
return (
- ({ label: tab.label, value: tab.id }))}
- selected={activeTab}
- onChange={setActiveTab}
- activeClassName="border-primary-600 bg-primary-600 text-white"
- renderItem={(item, active) => {
- const tab = tabs.find(entry => entry.id === item.value);
- return (
+
+ items={tabs.map(tab => ({
+ id: tab.id,
+ label: (
- {item.label}
- {tab?.comingSoon && (
-
+ {tab.label}
+ {tab.comingSoon && (
+
{t('misc.beta')}
)}
- );
- }}
+ ),
+ }))}
+ value={activeTab}
+ onChange={setActiveTab}
+ className="flex flex-wrap gap-2 pb-1"
/>
{/* Alerts tab renders outside the card so Notifications can use its own
diff --git a/app/src/pages/Feedback.tsx b/app/src/pages/Feedback.tsx
index 63e710426..b3aa90821 100644
--- a/app/src/pages/Feedback.tsx
+++ b/app/src/pages/Feedback.tsx
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import FeedbackFilterSelect from '../components/feedback/FeedbackFilterSelect';
import FeedbackItemRow from '../components/feedback/FeedbackItemRow';
import FeedbackSubmitForm from '../components/feedback/FeedbackSubmitForm';
+import Button from '../components/ui/Button';
import { useUser } from '../hooks/useUser';
import { useT } from '../lib/i18n/I18nContext';
import { feedbackApi } from '../services/api/feedbackApi';
@@ -242,13 +243,12 @@ const Feedback = () => {
{hasMore && (
- void load(pageRef.current + 1, true)}
- disabled={isLoading}
- className="rounded-xl border border-neutral-200 bg-white px-5 py-2 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800">
+ disabled={isLoading}>
{isLoading ? '...' : t('feedback.loadMore')}
-
+
)}
diff --git a/app/src/pages/Home.tsx b/app/src/pages/Home.tsx
index 7525fe88b..8e3ce4db2 100644
--- a/app/src/pages/Home.tsx
+++ b/app/src/pages/Home.tsx
@@ -7,6 +7,7 @@ import {
PromotionalCreditsBanner,
UsageLimitBanner,
} from '../components/home/HomeBanners';
+import Button from '../components/ui/Button';
import { useUsageState } from '../hooks/useUsageState';
import { useUser } from '../hooks/useUser';
import { useT } from '../lib/i18n/I18nContext';
@@ -176,12 +177,13 @@ const Home = () => {
v{APP_VERSION}
-
+ className="rounded-full">
{isDark ? (
)}
-
+
{/* Connection status */}
@@ -244,13 +246,15 @@ const Home = () => {
)}
{/* CTA button — data-walkthrough target for step 2 */}
-
+ className="w-full">
{t('home.askAssistant')}
-
+
diff --git a/app/src/pages/Intelligence.test.tsx b/app/src/pages/Intelligence.test.tsx
index 15c7812be..92526dcfa 100644
--- a/app/src/pages/Intelligence.test.tsx
+++ b/app/src/pages/Intelligence.test.tsx
@@ -95,7 +95,7 @@ describe('Intelligence tab selection', () => {
it('switches panel when a tab pill is clicked', async () => {
renderAt('/intelligence?tab=tasks');
await waitFor(() => expect(screen.getByText('TASKS_PANEL')).toBeInTheDocument());
- // PillTabBar renders the tab labels (i18n keys); click the Workflows pill.
+ // ChipTabs renders the tab labels (i18n keys); click the Workflows pill.
fireEvent.click(screen.getByText('memory.tab.workflows'));
await waitFor(() => expect(screen.getByText('WORKFLOWS_PANEL')).toBeInTheDocument());
});
diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx
index 0993af1f8..fc5a4f6b5 100644
--- a/app/src/pages/Intelligence.tsx
+++ b/app/src/pages/Intelligence.tsx
@@ -14,7 +14,7 @@ import MemorySection from '../components/intelligence/MemorySection';
import ModelCouncilTab from '../components/intelligence/ModelCouncilTab';
import { ToastContainer } from '../components/intelligence/Toast';
import WorkflowsTab from '../components/intelligence/WorkflowsTab';
-import PillTabBar from '../components/PillTabBar';
+import ChipTabs from '../components/layout/ChipTabs';
import SettingsHeader from '../components/settings/components/SettingsHeader';
import { useSettingsNavigation } from '../components/settings/hooks/useSettingsNavigation';
import { useDeveloperMode } from '../hooks/useDeveloperMode';
@@ -210,29 +210,23 @@ export default function Intelligence({ tabParamKey = 'tab' }: IntelligenceProps
/>
- ({ label: tab.label, value: tab.id }))}
- selected={activeTab}
- onChange={setActiveTab}
- activeClassName="border-primary-600 bg-primary-600 text-white"
- renderItem={(item, active) => {
- const tab = tabs.find(entry => entry.id === item.value);
- return (
+
+ items={tabs.map(tab => ({
+ id: tab.id,
+ label: (
- {item.label}
- {tab?.comingSoon && (
-
+ {tab.label}
+ {tab.comingSoon && (
+
{t('misc.beta')}
)}
- );
- }}
+ ),
+ }))}
+ value={activeTab}
+ onChange={setActiveTab}
+ className="flex flex-wrap gap-2 pb-1"
/>
diff --git a/app/src/pages/Invites.tsx b/app/src/pages/Invites.tsx
index b7e3067e9..0297cc4b6 100644
--- a/app/src/pages/Invites.tsx
+++ b/app/src/pages/Invites.tsx
@@ -1,6 +1,7 @@
import debugFactory from 'debug';
import { useEffect, useRef, useState } from 'react';
+import Button from '../components/ui/Button';
import { useUser } from '../hooks/useUser';
import { useT } from '../lib/i18n/I18nContext';
import { inviteApi } from '../services/api/inviteApi';
@@ -46,9 +47,12 @@ const CodeRow = ({ invite }: { invite: InviteCode }) => {
{t('common.enabled')}
)}
-
{copied ? (
)}
-
+
);
@@ -173,12 +177,13 @@ const Invites = () => {
className="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl font-mono text-sm tracking-wider placeholder:text-stone-500 placeholder:tracking-normal placeholder:font-sans focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all"
disabled={redeemStatus === 'loading'}
/>
-
+ className="whitespace-nowrap">
{redeemStatus === 'loading' ? '...' : t('rewards.referrals')}
-
+
{redeemStatus === 'success' && (
{t('common.success')}
diff --git a/app/src/pages/Notifications.tsx b/app/src/pages/Notifications.tsx
index 18515f7ce..5a6b53b54 100644
--- a/app/src/pages/Notifications.tsx
+++ b/app/src/pages/Notifications.tsx
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import NotificationBody from '../components/notifications/NotificationBody';
import NotificationCenter from '../components/notifications/NotificationCenter';
+import Button from '../components/ui/Button';
import { useT } from '../lib/i18n/I18nContext';
import { resolveSystemRoute } from '../lib/notificationRouter';
import { useAppDispatch, useAppSelector } from '../store/hooks';
@@ -122,20 +123,20 @@ const Notifications = () => {
- dispatch(markAllRead())}
- disabled={unread === 0}
- className="text-xs font-medium text-stone-600 dark:text-neutral-400 hover:text-stone-900 dark:hover:text-neutral-100 disabled:opacity-40 disabled:cursor-not-allowed">
+ disabled={unread === 0}>
{t('alerts.markAllRead')}
-
-
+ dispatch(clearAll())}
- disabled={items.length === 0}
- className="text-xs font-medium text-stone-600 dark:text-neutral-400 hover:text-stone-900 dark:hover:text-neutral-100 disabled:opacity-40 disabled:cursor-not-allowed">
+ disabled={items.length === 0}>
{t('common.clear')}
-
+
diff --git a/app/src/pages/Rewards.tsx b/app/src/pages/Rewards.tsx
index a0b0e8c59..de940b026 100644
--- a/app/src/pages/Rewards.tsx
+++ b/app/src/pages/Rewards.tsx
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import EmptyStateCard from '../components/EmptyStateCard';
-import PillTabBar from '../components/PillTabBar';
+import ChipTabs from '../components/layout/ChipTabs';
import RewardsCommunityTab from '../components/rewards/RewardsCommunityTab';
import RewardsRedeemTab from '../components/rewards/RewardsRedeemTab';
import RewardsReferralsTab from '../components/rewards/RewardsReferralsTab';
@@ -136,15 +136,15 @@ const Rewards = () => {
return (
-
items={[
- { label: t('rewards.referrals'), value: 'referrals' },
- { label: t('rewards.title'), value: 'rewards' },
- { label: t('rewards.coupons'), value: 'redeem' },
+ { id: 'referrals', label: t('rewards.referrals') },
+ { id: 'rewards', label: t('rewards.title') },
+ { id: 'redeem', label: t('rewards.coupons') },
]}
- selected={selectedTab}
+ value={selectedTab}
onChange={handleTabChange}
- activeClassName="border-primary-600 bg-primary-600 text-white"
+ className="flex flex-wrap gap-2 pb-1"
/>
{selectedTab === 'referrals' ? (
diff --git a/app/src/pages/Routines.tsx b/app/src/pages/Routines.tsx
index 01e1ace7e..bca3fa7fd 100644
--- a/app/src/pages/Routines.tsx
+++ b/app/src/pages/Routines.tsx
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import RoutineCard from '../components/routines/RoutineCard';
+import Button from '../components/ui/Button';
import { useT } from '../lib/i18n/I18nContext';
import {
type CoreCronJob,
@@ -162,10 +163,11 @@ const Routines = () => {
{/* Header */}
- navigate('/home')}
- className="p-1.5 rounded-lg text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 transition-colors"
aria-label={t('common.back')}>
-
+
{t('routines.title')}
@@ -245,12 +247,9 @@ const Routines = () => {
{/* Refresh */}
{!loading && jobs.length > 0 && (
- void loadJobs()}
- className="text-xs text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 transition-colors">
+ void loadJobs()}>
{t('routines.refresh')}
-
+
)}
diff --git a/app/src/pages/Welcome.tsx b/app/src/pages/Welcome.tsx
index 5103f40c6..cf80909cb 100644
--- a/app/src/pages/Welcome.tsx
+++ b/app/src/pages/Welcome.tsx
@@ -88,12 +88,13 @@ const Welcome = () => {
-
+ className="rounded-full">
{isDark ? (
)}
-
+
{
{errorMessage}
{requiresAppDataReset ? (
-
+ className="w-full">
{isClearingAppData ? (
@@ -161,7 +164,7 @@ const Welcome = () => {
) : (
t('welcome.clearAppDataAndRestart')
)}
-
+
{t('welcome.clearAppDataWarning')}
diff --git a/app/src/pages/WorkflowNew.tsx b/app/src/pages/WorkflowNew.tsx
index 62c791e79..28dedec9a 100644
--- a/app/src/pages/WorkflowNew.tsx
+++ b/app/src/pages/WorkflowNew.tsx
@@ -21,6 +21,7 @@ import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import CreateWorkflowForm from '../components/skills/CreateWorkflowForm';
+import Button from '../components/ui/Button';
import { useT } from '../lib/i18n/I18nContext';
import { type WorkflowSummary } from '../services/api/workflowsApi';
@@ -66,22 +67,21 @@ export default function WorkflowNew() {
- navigate('/connections')}
- disabled={submitting}
- className="rounded-lg px-4 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:opacity-40">
+ disabled={submitting}>
{t('common.cancel')}
-
-
+
+ disabled={!formValid || submitting}>
{submitting ? t('skills.create.creating') : t('skills.create.createBtn')}
-
+
diff --git a/app/src/pages/WorkflowsRun.tsx b/app/src/pages/WorkflowsRun.tsx
index 953783cfd..4ba47decd 100644
--- a/app/src/pages/WorkflowsRun.tsx
+++ b/app/src/pages/WorkflowsRun.tsx
@@ -12,6 +12,7 @@
import { useNavigate } from 'react-router-dom';
import WorkflowRunnerBody from '../components/skills/WorkflowRunnerBody';
+import Button from '../components/ui/Button';
import { useT } from '../lib/i18n/I18nContext';
export default function WorkflowsRun() {
@@ -28,8 +29,9 @@ export default function WorkflowsRun() {
isn't polluted and `-1` lands on the originating page. Falls back
to the Workflows tab on a cold deep-link with no history. */}
-
// Use the router history index, not `history.length`: length > 1
// is true even when the only prior entry is an external referrer,
@@ -40,10 +42,9 @@ export default function WorkflowsRun() {
? navigate(-1)
: navigate('/intelligence?tab=workflows')
}
- aria-label={t('common.back')}
- className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 transition-colors">
+ aria-label={t('common.back')}>
{t('common.back')}
-
+
{t('skills.run.title')}
diff --git a/app/src/pages/__tests__/Brain.test.tsx b/app/src/pages/__tests__/Brain.test.tsx
index 9105d49eb..5120218c5 100644
--- a/app/src/pages/__tests__/Brain.test.tsx
+++ b/app/src/pages/__tests__/Brain.test.tsx
@@ -35,7 +35,7 @@ vi.mock('../../components/intelligence/IntelligenceSubconsciousTab', async () =>
const React = await import('react');
return { default: () => React.createElement('div', { 'data-testid': 'brain-subconscious' }) };
});
-vi.mock('../../components/PillTabBar', async () => {
+vi.mock('../../components/layout/ChipTabs', async () => {
const React = await import('react');
return {
default: ({ children }: { children?: React.ReactNode }) =>
diff --git a/app/src/pages/__tests__/Intelligence.test.tsx b/app/src/pages/__tests__/Intelligence.test.tsx
index c249f97f6..ef2e70ce5 100644
--- a/app/src/pages/__tests__/Intelligence.test.tsx
+++ b/app/src/pages/__tests__/Intelligence.test.tsx
@@ -24,10 +24,14 @@ vi.mock('../../components/intelligence/Toast', () => ({ ToastContainer: () => nu
vi.mock('../../components/intelligence/ConfirmationModal', () => ({
ConfirmationModal: () => null,
}));
-vi.mock('../../components/PillTabBar', () => ({
- default: ({ selected, onChange }: { selected: string; onChange: (tab: string) => void }) => (
+interface MockChipTabsProps {
+ value: string;
+ onChange: (tab: string) => void;
+}
+vi.mock('../../components/layout/ChipTabs', () => ({
+ default: ({ value, onChange }: MockChipTabsProps) => (
- selected:{selected}
+ selected:{value}
{['memory', 'subconscious', 'tasks', 'workflows', 'council'].map(tab => (
onChange(tab)}>
go-{tab}
diff --git a/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx b/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx
index 39cf68de4..835905026 100644
--- a/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx
+++ b/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx
@@ -57,10 +57,10 @@ describe('Skills page — MCP Servers tab (MCP + Meeting bots)', () => {
// The Tools tab shows filter chips (All / Installed / Registry) and a search input
await waitFor(() => {
- expect(screen.getByRole('button', { name: 'All' })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'All' })).toBeInTheDocument();
});
- expect(screen.getByRole('button', { name: /Installed/i })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Registry' })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /Installed/i })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'Registry' })).toBeInTheDocument();
});
it('shows the table header columns on the MCP Servers tab', async () => {
@@ -84,9 +84,9 @@ describe('Skills page — MCP Servers tab (MCP + Meeting bots)', () => {
fireEvent.click(screen.getByTestId('two-pane-nav-mcp'));
await waitFor(() => {
- expect(screen.getByRole('button', { name: /Installed/i })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /Installed/i })).toBeInTheDocument();
});
- fireEvent.click(screen.getByRole('button', { name: /Installed/i }));
+ fireEvent.click(screen.getByRole('tab', { name: /Installed/i }));
await waitFor(() => {
expect(screen.getByText('No MCP servers installed yet.')).toBeInTheDocument();
@@ -98,7 +98,7 @@ describe('Skills page — MCP Servers tab (MCP + Meeting bots)', () => {
expect(screen.getByTestId('two-pane-nav-mcp')).toHaveAttribute('aria-current', 'page');
await waitFor(() => {
- expect(screen.getByRole('button', { name: 'All' })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'All' })).toBeInTheDocument();
});
});
});
diff --git a/app/src/pages/conversations/components/AgentProcessSourcePanel.tsx b/app/src/pages/conversations/components/AgentProcessSourcePanel.tsx
index cc0306da0..2c696cb3f 100644
--- a/app/src/pages/conversations/components/AgentProcessSourcePanel.tsx
+++ b/app/src/pages/conversations/components/AgentProcessSourcePanel.tsx
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
+import Button from '../../../components/ui/Button';
import { useT } from '../../../lib/i18n/I18nContext';
import type { ToolTimelineEntry } from '../../../store/chatRuntimeSlice';
import { type AgentSource, extractAgentSources } from '../../../utils/toolTimelineFormatting';
@@ -104,13 +105,15 @@ export function AgentProcessSourcePanel({
{t('conversations.agentTaskInsights.processSourceTitle')}
-
+ className="shrink-0 rounded-full">
✕
-
+
{/* Body — the full agent timeline, then the visited sources. */}
diff --git a/app/src/pages/conversations/components/BackgroundProcessesPanel.tsx b/app/src/pages/conversations/components/BackgroundProcessesPanel.tsx
index 770ea7d12..e870c72ca 100644
--- a/app/src/pages/conversations/components/BackgroundProcessesPanel.tsx
+++ b/app/src/pages/conversations/components/BackgroundProcessesPanel.tsx
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
+import Button from '../../../components/ui/Button';
import { useT } from '../../../lib/i18n/I18nContext';
import type {
SubagentActivity,
@@ -163,11 +164,12 @@ export function BackgroundProcessesPanel({
{runningLabel} · {totalLabel}
-
+ onClick={onClose}>
-
+
diff --git a/app/src/pages/conversations/components/SubagentDrawer.tsx b/app/src/pages/conversations/components/SubagentDrawer.tsx
index e8aea304e..f5364b3a4 100644
--- a/app/src/pages/conversations/components/SubagentDrawer.tsx
+++ b/app/src/pages/conversations/components/SubagentDrawer.tsx
@@ -1,5 +1,6 @@
import { type ReactNode, useEffect, useState } from 'react';
+import Button from '../../../components/ui/Button';
import { useT } from '../../../lib/i18n/I18nContext';
import { threadApi } from '../../../services/api/threadApi';
import type {
@@ -265,24 +266,28 @@ export function SubagentDrawer({
{canCancel ? (
-
+ className="shrink-0 rounded-full">
{cancelling
? t('conversations.subagent.cancelling')
: t('conversations.subagent.cancel')}
-
+
) : null}
-
+ className="shrink-0 rounded-full">
✕
-
+
{cancelError ? (
-
+ className="flex-none">
-
+
{source && }
@@ -977,29 +980,19 @@ function TaskBriefDialog({
/>
{deletable ? (
-
+
{t('conversations.taskKanban.deleteCard')}
-
+
) : (
)}
-
+
{t('common.cancel')}
-
-
+
+
{t('conversations.taskKanban.saveChanges')}
-
+
@@ -1050,12 +1043,9 @@ function TaskBriefDialog({
/>
{deletable && (
-
+
{t('conversations.taskKanban.deleteCard')}
-
+
)}
diff --git a/app/src/pages/dev/AgentInsightsPreview.tsx b/app/src/pages/dev/AgentInsightsPreview.tsx
index 6ad39630f..122f90b26 100644
--- a/app/src/pages/dev/AgentInsightsPreview.tsx
+++ b/app/src/pages/dev/AgentInsightsPreview.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
+import Button from '../../components/ui/Button';
import type { ToolTimelineEntry } from '../../store/chatRuntimeSlice';
import { AgentProcessSourcePanel } from '../conversations/components/AgentProcessSourcePanel';
import { ToolTimelineBlock } from '../conversations/components/ToolTimelineBlock';
@@ -124,12 +125,9 @@ export default function AgentInsightsPreview() {
- setPanelOpen(true)}
- className="rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-600">
+ setPanelOpen(true)}>
View full agent process Source →
-
+
diff --git a/app/src/pages/ios/MascotScreen.tsx b/app/src/pages/ios/MascotScreen.tsx
index 987643ef7..b88e9a2bc 100644
--- a/app/src/pages/ios/MascotScreen.tsx
+++ b/app/src/pages/ios/MascotScreen.tsx
@@ -34,6 +34,7 @@ import {
stopListening,
} from 'tauri-plugin-ptt-api';
+import Button from '../../components/ui/Button';
import { RiveMascot } from '../../features/human/Mascot';
import { useHumanMascot } from '../../features/human/useHumanMascot';
import { useT } from '../../lib/i18n/I18nContext';
@@ -419,13 +420,9 @@ export const MascotScreen: FC = () => {
{pairedLabel}
-
+
{t('iosMascot.disconnect')}
-
+
{/* Mascot canvas */}
diff --git a/app/src/pages/onboarding/steps/ApiKeysStep.tsx b/app/src/pages/onboarding/steps/ApiKeysStep.tsx
index dd8072379..2f2958941 100644
--- a/app/src/pages/onboarding/steps/ApiKeysStep.tsx
+++ b/app/src/pages/onboarding/steps/ApiKeysStep.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
+import Button from '../../../components/ui/Button';
import { useT } from '../../../lib/i18n/I18nContext';
import { setCloudProviderKey } from '../../../services/api/aiSettingsApi';
import { callCoreRpc } from '../../../services/coreRpcClient';
@@ -258,13 +259,14 @@ const ApiKeysStep = ({ onNext, onSkip }: ApiKeysStepProps) => {
-
+ className="underline">
{t('onboarding.apiKeys.skipForNow')}
-
+
);
diff --git a/app/src/pages/onboarding/steps/CustomWizardStep.tsx b/app/src/pages/onboarding/steps/CustomWizardStep.tsx
index 82946edae..8d928f879 100644
--- a/app/src/pages/onboarding/steps/CustomWizardStep.tsx
+++ b/app/src/pages/onboarding/steps/CustomWizardStep.tsx
@@ -1,5 +1,6 @@
import { type ReactNode, useState } from 'react';
+import Button from '../../../components/ui/Button';
import { useT } from '../../../lib/i18n/I18nContext';
import OnboardingNextButton from '../components/OnboardingNextButton';
import WizardStepper from '../components/WizardStepper';
@@ -169,12 +170,9 @@ const CustomWizardStep = ({
) : null}
-
+
{t('onboarding.custom.back')}
-
+
-
+
{t('onboarding.localAI.useLocalAnyway')}
-
+
);
}
@@ -189,12 +187,9 @@ const LocalAIStep = ({ onNext, onBack: _onBack, onDownloadError }: LocalAIStepPr
onClick={handleSkip}
/>
-
+
{t('onboarding.localAI.useLocalInstead')}
-
+
);
};
diff --git a/app/src/pages/onboarding/steps/ReferralApplyStep.tsx b/app/src/pages/onboarding/steps/ReferralApplyStep.tsx
index a0f6dbe56..2fd8b8006 100644
--- a/app/src/pages/onboarding/steps/ReferralApplyStep.tsx
+++ b/app/src/pages/onboarding/steps/ReferralApplyStep.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
+import Button from '../../../components/ui/Button';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { referralApi } from '../../../services/api/referralApi';
@@ -117,20 +118,16 @@ const ReferralApplyStep = ({ onNext, onApplied }: ReferralApplyStepProps) => {
-
+
{t('onboarding.skip')}
-
-
+ void handleApply()}
disabled={isLoading || !code.trim()}
- className="btn-primary flex-1 py-2.5 text-sm font-medium rounded-xl disabled:opacity-50 disabled:cursor-not-allowed">
+ className="flex-1">
{isLoading ? t('common.loading') : t('common.submit')}
-
+
>
)}
diff --git a/app/test/playwright/specs/mcp-tab-flow.spec.ts b/app/test/playwright/specs/mcp-tab-flow.spec.ts
index 64476247d..18985913a 100644
--- a/app/test/playwright/specs/mcp-tab-flow.spec.ts
+++ b/app/test/playwright/specs/mcp-tab-flow.spec.ts
@@ -266,9 +266,9 @@ test.describe('MCP Tab — Table View & Filtering', () => {
test('renders search bar and filter chips', async ({ page }) => {
await expect(page.locator('input[type="search"]')).toBeVisible();
- await expect(page.getByRole('button', { name: /^All$/ })).toBeVisible();
- await expect(page.getByRole('button', { name: /Installed/ })).toBeVisible();
- await expect(page.getByRole('button', { name: /Registry/ })).toBeVisible();
+ await expect(page.getByRole('tab', { name: /^All$/ })).toBeVisible();
+ await expect(page.getByRole('tab', { name: /Installed/ })).toBeVisible();
+ await expect(page.getByRole('tab', { name: /Registry/ })).toBeVisible();
});
test('displays installed servers with status dot and Manage action', async ({ page }) => {
@@ -286,7 +286,7 @@ test.describe('MCP Tab — Table View & Filtering', () => {
});
test('filter "Installed" hides registry rows', async ({ page }) => {
- await page.getByRole('button', { name: /Installed/ }).click();
+ await page.getByRole('tab', { name: /Installed/ }).click();
const rows = page.locator('table tbody tr');
const count = await rows.count();
expect(count).toBeGreaterThan(0);
@@ -296,7 +296,7 @@ test.describe('MCP Tab — Table View & Filtering', () => {
});
test('filter "Registry" hides installed rows', async ({ page }) => {
- await page.getByRole('button', { name: /Registry/ }).click();
+ await page.getByRole('tab', { name: /Registry/ }).click();
const rows = page.locator('table tbody tr');
const count = await rows.count();
expect(count).toBeGreaterThan(0);
@@ -306,7 +306,7 @@ test.describe('MCP Tab — Table View & Filtering', () => {
});
test('already-installed servers are excluded from registry rows', async ({ page }) => {
- await page.getByRole('button', { name: /Registry/ }).click();
+ await page.getByRole('tab', { name: /Registry/ }).click();
const rows = page.locator('table tbody tr');
const count = await rows.count();
for (let i = 0; i < count; i++) {
@@ -452,7 +452,7 @@ test.describe('MCP Tab — Manage & Uninstall Lifecycle', () => {
await expect(page.locator('table')).toBeVisible({ timeout: 10_000 });
- await page.getByRole('button', { name: /Installed/ }).click();
+ await page.getByRole('tab', { name: /Installed/ }).click();
const removedRow = page.locator('table tbody tr', {
has: page.locator('td:first-child:has-text("Memory Server")'),
});
@@ -477,7 +477,7 @@ test.describe('MCP Tab — Empty & Edge States', () => {
await setupMockRpc(page, state);
await navigateToMcpTab(page);
- await page.getByRole('button', { name: /Installed/ }).click();
+ await page.getByRole('tab', { name: /Installed/ }).click();
// Target the empty-state element directly: a broad `text=/no.*servers/i`
// locator also matches ancestor containers (the root shell wraps the panel),
// tripping Playwright strict mode.