feat(ui): consolidate buttons — 3 variants (primary/secondary/tertiary) + danger tone (#4057)

This commit is contained in:
Cyrus Gray
2026-06-25 16:29:52 +05:30
committed by GitHub
parent af29f92c2d
commit 171cedc6ef
184 changed files with 2236 additions and 2269 deletions
+11 -10
View File
@@ -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"
/>
<button
type="button"
<Button
variant="primary"
size="md"
onClick={() => void handleSubmit()}
disabled={!body.trim() || submitting}
className="rounded-lg bg-primary-500 px-3 py-2 text-sm font-medium text-white
hover:bg-primary-600 disabled:opacity-50 dark:bg-primary-600 dark:hover:bg-primary-500">
disabled={!body.trim() || submitting}>
{submitting ? 'Posting...' : 'Comment'}
</button>
</Button>
</div>
);
}
@@ -323,13 +323,14 @@ function FeedComposer({ myAgentId, onPostCreated }: FeedComposerProps) {
{remaining}
</span>
)}
<button
type="button"
<Button
variant="primary"
size="sm"
onClick={() => void submit()}
disabled={!canPost}
className="rounded-full bg-primary-500 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:opacity-40 dark:bg-primary-600 dark:hover:bg-primary-500">
className="rounded-full">
{submitting ? 'Posting…' : 'Post'}
</button>
</Button>
</div>
</div>
</div>
@@ -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(<IdentitiesSection />);
expect(screen.getByRole('button', { name: 'Register' })).toHaveAttribute('data-active', 'true');
expect(screen.getByRole('tab', { name: 'Register' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('Check handle availability')).toBeInTheDocument();
});
@@ -110,21 +110,21 @@ describe('tab navigation', () => {
test('can switch to Registry tab', async () => {
render(<IdentitiesSection />);
await gotoTab('Registry');
expect(screen.getByRole('button', { name: 'Registry' })).toHaveAttribute('data-active', 'true');
expect(screen.getByRole('tab', { name: 'Registry' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByText('Directory identities')).toBeInTheDocument();
});
test('can switch to Trading tab', async () => {
render(<IdentitiesSection />);
await gotoTab('Trading');
expect(screen.getByRole('button', { name: 'Trading' })).toHaveAttribute('data-active', 'true');
expect(screen.getByRole('tab', { name: 'Trading' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByText('Floor Prices')).toBeInTheDocument();
});
test('clicking the active tab again is a no-op (reducer short-circuit)', async () => {
render(<IdentitiesSection />);
await gotoTab('Register');
expect(screen.getByRole('button', { name: 'Register' })).toHaveAttribute('data-active', 'true');
expect(screen.getByRole('tab', { name: 'Register' })).toHaveAttribute('aria-selected', 'true');
});
test('switching tabs remounts the body (key change clears local state)', async () => {
+38 -44
View File
@@ -15,7 +15,9 @@
*/
import { useCallback, useEffect, useReducer, useRef, useState } from 'react';
import ChipTabs from '../../components/layout/ChipTabs';
import PanelScaffold from '../../components/layout/PanelScaffold';
import Button from '../../components/ui/Button';
import {
type AvailabilityResponse,
type DirectoryIdentityListingsResponse,
@@ -429,12 +431,9 @@ function RegisterTab({ onRegistered }: { onRegistered?: () => void }) {
setInput(sanitize(e.target.value));
}}
/>
<button
type="submit"
disabled={!input.trim()}
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
<Button type="submit" variant="primary" size="md" disabled={!input.trim()}>
Check
</button>
</Button>
</form>
{availState.status === 'loading' && (
@@ -455,17 +454,17 @@ function RegisterTab({ onRegistered }: { onRegistered?: () => void }) {
<span className="text-xs font-medium text-green-500">
@{availableHandle} is available
</span>
<button
type="button"
<Button
variant="primary"
size="sm"
disabled={busy}
onClick={() => {
reg.begin(availableHandle);
}}
className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white disabled:opacity-50">
}}>
{reg.state.phase === 'challenge_loading'
? 'Loading…'
: `Register @${availableHandle}`}
</button>
</Button>
</div>
) : (
<div>
@@ -823,30 +822,33 @@ function TradingTab() {
)}
<div className="mt-2 flex gap-1">
{listing.listingType !== 'auction' && (
<button
type="button"
<Button
variant="primary"
size="xs"
className="flex-1"
disabled={buying !== null}
onClick={() => startBuy(listing)}
className="flex-1 rounded-md bg-primary-600 px-2 py-1 text-xs font-medium text-white disabled:opacity-50">
onClick={() => startBuy(listing)}>
Buy
</button>
</Button>
)}
{listing.listingType === 'auction' && (
<button
type="button"
<Button
variant="primary"
size="xs"
className="flex-1"
disabled={commit !== null}
onClick={() => setCommit({ kind: 'bid', listing })}
className="flex-1 rounded-md bg-primary-600 px-2 py-1 text-xs font-medium text-white disabled:opacity-50">
onClick={() => setCommit({ kind: 'bid', listing })}>
Bid
</button>
</Button>
)}
<button
type="button"
<Button
variant="secondary"
size="xs"
className="flex-1"
disabled={commit !== null}
onClick={() => setCommit({ kind: 'offer', listing })}
className="flex-1 rounded-md border border-stone-300 px-2 py-1 text-xs font-medium text-stone-700 disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-200">
onClick={() => setCommit({ kind: 'offer', listing })}>
Offer
</button>
</Button>
</div>
</div>
))}
@@ -1026,25 +1028,17 @@ export default function IdentitiesSection() {
return (
<PanelScaffold description="Claim handles, manage your registry, and trade identities">
<div className="flex gap-1">
{(Object.keys(TAB_KEYS) as Tab[]).map(tabKey => (
<button
key={tabKey}
type="button"
onClick={() => {
dispatch({ type: 'set', tab: tabKey });
}}
data-active={tab === tabKey}
className={[
'rounded-full px-3 py-1 text-xs font-medium transition-colors',
tab === tabKey
? 'bg-stone-800 text-white dark:bg-neutral-100 dark:text-neutral-900'
: 'border border-stone-200 bg-white text-stone-600 hover:bg-stone-50 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800',
].join(' ')}>
{TAB_KEYS[tabKey]}
</button>
))}
</div>
<ChipTabs<Tab>
as="tab"
ariaLabel="Identity sections"
className="flex gap-1"
items={(Object.keys(TAB_KEYS) as Tab[]).map(tabKey => ({
id: tabKey,
label: TAB_KEYS[tabKey],
}))}
value={tab}
onChange={tabKey => dispatch({ type: 'set', tab: tabKey })}
/>
<div key={key}>
{tab === 'register' && <RegisterTab onRegistered={bumpRegistryKey} />}
@@ -16,6 +16,7 @@ import { useEffect, useState } from 'react';
import ChipTabs from '../../components/layout/ChipTabs';
import PanelScaffold from '../../components/layout/PanelScaffold';
import Button from '../../components/ui/Button';
import {
type ArtifactListResult,
type EscrowListResponse,
@@ -171,13 +172,14 @@ function SearchTab() {
))}
</div>
)}
<button
type="button"
<Button
variant="primary"
size="sm"
disabled={buying !== null}
onClick={() => startBuy(product)}
className="mt-3 w-full rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white disabled:opacity-50">
className="mt-3 w-full">
Buy
</button>
</Button>
</div>
))}
</div>
@@ -549,22 +549,22 @@ describe('DMs panel (E2E enabled)', () => {
describe('tab navigation', () => {
test('defaults to Channels tab', () => {
render(<MessagingSection tabs={TABS} />);
const channelsBtn = screen.getByRole('button', { name: 'Channels' });
expect(channelsBtn).toHaveAttribute('data-active', 'true');
const channelsBtn = screen.getByRole('tab', { name: 'Channels' });
expect(channelsBtn).toHaveAttribute('aria-selected', 'true');
});
test('can switch to Groups tab', async () => {
render(<MessagingSection tabs={TABS} />);
const groupsBtn = screen.getByRole('button', { name: 'Groups' });
const groupsBtn = screen.getByRole('tab', { name: 'Groups' });
await userEvent.click(groupsBtn);
expect(groupsBtn).toHaveAttribute('data-active', 'true');
expect(groupsBtn).toHaveAttribute('aria-selected', 'true');
});
test('can switch to Inbox tab', async () => {
render(<MessagingSection tabs={TABS} />);
const inboxBtn = screen.getByRole('button', { name: 'Inbox' });
const inboxBtn = screen.getByRole('tab', { name: 'Inbox' });
await userEvent.click(inboxBtn);
expect(inboxBtn).toHaveAttribute('data-active', 'true');
expect(inboxBtn).toHaveAttribute('aria-selected', 'true');
});
});
@@ -579,19 +579,19 @@ describe('empty states', () => {
test('shows "No groups found" when groups list is empty', async () => {
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
expect(await screen.findByText(/No groups found/i)).toBeInTheDocument();
});
test('shows "No broadcasts found" when broadcasts list is empty', async () => {
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Broadcasts' }));
await userEvent.click(screen.getByRole('tab', { name: 'Broadcasts' }));
expect(await screen.findByText(/No broadcasts found/i)).toBeInTheDocument();
});
test('shows "Your inbox is empty" when inbox is empty', async () => {
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
await userEvent.click(screen.getByRole('tab', { name: 'Inbox' }));
expect(await screen.findByText(/Your inbox is empty/i)).toBeInTheDocument();
});
});
@@ -625,7 +625,7 @@ describe('inbox actions', () => {
async function openInbox() {
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
await userEvent.click(screen.getByRole('tab', { name: 'Inbox' }));
await screen.findByText('Hello there');
}
@@ -695,7 +695,7 @@ describe('membership actions', () => {
},
]);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Broadcasts' }));
await userEvent.click(screen.getByRole('tab', { name: 'Broadcasts' }));
await screen.findByText('Updates');
await userEvent.click(screen.getByRole('button', { name: 'Subscribe' }));
expect(apiClient.broadcasts.subscribe).toHaveBeenCalledWith('bc-1');
@@ -716,7 +716,7 @@ describe('membership actions', () => {
// Both public and membership queries return the same group so button shows "Leave".
vi.mocked(apiClient.groups.list).mockResolvedValue([group]);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
await screen.findByText('Builders');
await userEvent.click(screen.getByRole('button', { name: 'Leave' }));
expect(apiClient.groups.leave).toHaveBeenCalledWith('g-1');
@@ -747,7 +747,7 @@ describe('group membership-aware button rendering', () => {
async function openGroups() {
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
await screen.findByText('Alpha');
}
@@ -796,7 +796,7 @@ describe('group membership-aware button rendering', () => {
vi.mocked(apiClient.groups.join).mockResolvedValue(undefined);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
await screen.findByText('Alpha');
// Button shows Join before joining.
@@ -824,7 +824,7 @@ describe('group membership-aware button rendering', () => {
vi.mocked(apiClient.groups.leave).mockResolvedValue(undefined);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
await screen.findByText('Alpha');
// Button shows Leave (user is a member).
@@ -843,7 +843,7 @@ describe('group membership-aware button rendering', () => {
vi.mocked(apiClient.groups.list).mockResolvedValue([]);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
await screen.findByText(/No groups found/i);
// The second groups.list call should carry member=<agentId>.
@@ -875,7 +875,7 @@ describe('group invite management', () => {
test('renders "Invites" button on group cards', async () => {
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
await screen.findByText('Invite Test Group');
expect(screen.getByRole('button', { name: 'Invites' })).toBeInTheDocument();
});
@@ -883,7 +883,7 @@ describe('group invite management', () => {
test('clicking "Invites" opens GroupInvitesPanel and calls listInvites', async () => {
vi.mocked(apiClient.groups.listInvites).mockResolvedValue([]);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
await screen.findByText('Invite Test Group');
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
expect(apiClient.groups.listInvites).toHaveBeenCalledWith('g-inv');
@@ -902,7 +902,7 @@ describe('group invite management', () => {
},
]);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
await screen.findByText('Invite Test Group');
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
expect(await screen.findByText('tok-abc')).toBeInTheDocument();
@@ -913,7 +913,7 @@ describe('group invite management', () => {
test('Create Invite button calls groups.createInvite', async () => {
vi.mocked(apiClient.groups.listInvites).mockResolvedValue([]);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
await screen.findByText('Invite Test Group');
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
await screen.findByText(/No active invites/);
@@ -932,7 +932,7 @@ describe('group invite management', () => {
},
]);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
await screen.findByText('Invite Test Group');
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
await screen.findByText('tok-revoke');
@@ -943,7 +943,7 @@ describe('group invite management', () => {
test('Close button returns to the group list', async () => {
vi.mocked(apiClient.groups.listInvites).mockResolvedValue([]);
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
await screen.findByText('Invite Test Group');
await userEvent.click(screen.getByRole('button', { name: 'Invites' }));
await screen.findByText(/Invites for Invite Test Group/);
@@ -956,13 +956,13 @@ describe('group invite management', () => {
describe('redeem invite', () => {
test('renders "Redeem Invite" button in the groups tab', async () => {
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
expect(await screen.findByRole('button', { name: 'Redeem Invite' })).toBeInTheDocument();
});
test('clicking "Redeem Invite" opens the redeem panel with inputs', async () => {
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
await screen.findByRole('button', { name: 'Redeem Invite' });
await userEvent.click(screen.getByRole('button', { name: 'Redeem Invite' }));
expect(screen.getByPlaceholderText('Group ID')).toBeInTheDocument();
@@ -979,7 +979,7 @@ describe('redeem invite', () => {
valid: true,
});
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
await userEvent.click(screen.getByRole('button', { name: 'Redeem Invite' }));
await userEvent.type(screen.getByPlaceholderText('Group ID'), 'g-prev');
await userEvent.type(screen.getByPlaceholderText('Invite token'), 'tok-preview');
@@ -998,7 +998,7 @@ describe('redeem invite', () => {
updatedAt: '2026-01-01T00:00:00Z',
});
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
await userEvent.click(screen.getByRole('tab', { name: 'Groups' }));
await userEvent.click(screen.getByRole('button', { name: 'Redeem Invite' }));
await userEvent.type(screen.getByPlaceholderText('Group ID'), 'g-redeem');
await userEvent.type(screen.getByPlaceholderText('Invite token'), 'tok-redeem');
@@ -1033,7 +1033,7 @@ describe('inbox stream lifecycle', () => {
test('calls streams.start with "inbox" when Inbox tab is opened', async () => {
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
await userEvent.click(screen.getByRole('tab', { name: 'Inbox' }));
// Wait for async effects to settle.
await screen.findByText(/Your inbox is empty/i);
expect(apiClient.streams.start).toHaveBeenCalledWith('inbox');
@@ -1046,7 +1046,7 @@ describe('inbox stream lifecycle', () => {
clearMessages: vi.fn(),
}));
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
await userEvent.click(screen.getByRole('tab', { name: 'Inbox' }));
// Wait for the async inbox fetch to settle and the live indicator to appear.
await screen.findByTestId('inbox-live-indicator');
expect(screen.getByTestId('inbox-live-indicator')).toBeInTheDocument();
@@ -1054,7 +1054,7 @@ describe('inbox stream lifecycle', () => {
test('does NOT render the Live indicator when streamStatus is idle', async () => {
render(<MessagingSection tabs={TABS} />);
await userEvent.click(screen.getByRole('button', { name: 'Inbox' }));
await userEvent.click(screen.getByRole('tab', { name: 'Inbox' }));
await screen.findByText(/Your inbox is empty/i);
expect(screen.queryByTestId('inbox-live-indicator')).not.toBeInTheDocument();
});
+50 -67
View File
@@ -12,6 +12,8 @@
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import ChipTabs from '../../components/layout/ChipTabs';
import Button from '../../components/ui/Button';
import {
type BroadcastChannel,
type BroadcastQueryParams,
@@ -241,22 +243,24 @@ function SignalKeyStatusCard() {
</p>
</div>
{!keysReady && (
<button
type="button"
className="ml-3 flex-shrink-0 rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-700 disabled:opacity-50"
<Button
variant="primary"
size="sm"
className="ml-3 flex-shrink-0"
disabled={provisioning}
onClick={() => void handleProvision()}>
{provisioning ? 'Setting up...' : 'Set up keys'}
</button>
</Button>
)}
{keysReady && !discoverable && (
<button
type="button"
className="ml-3 flex-shrink-0 rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-700 disabled:opacity-50"
<Button
variant="primary"
size="sm"
className="ml-3 flex-shrink-0"
disabled={publishing}
onClick={() => void handlePublish()}>
{publishing ? 'Publishing...' : 'Make discoverable'}
</button>
</Button>
)}
</div>
{actionError && (
@@ -404,12 +408,9 @@ function GroupsPanel() {
return (
<div className="space-y-2">
<div className="flex justify-end">
<button
type="button"
className="rounded bg-stone-200 dark:bg-neutral-700 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200 hover:bg-stone-300 dark:hover:bg-neutral-600"
onClick={() => setShowRedeem(true)}>
<Button variant="secondary" size="xs" onClick={() => setShowRedeem(true)}>
Redeem Invite
</button>
</Button>
</div>
<div className="flex items-center justify-center py-12 text-stone-400 dark:text-neutral-500 text-sm">
No groups found
@@ -560,12 +561,9 @@ function GroupInvitesPanel({
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
Invites for {groupName}
</span>
<button
type="button"
className="text-xs text-stone-400 hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300"
onClick={onClose}>
<Button variant="tertiary" size="xs" onClick={onClose}>
Close
</button>
</Button>
</div>
{error ? <p className="mb-2 text-xs text-red-500">{error}</p> : null}
{loading ? (
@@ -603,13 +601,13 @@ function GroupInvitesPanel({
))}
</div>
)}
<button
type="button"
className="rounded bg-primary-600 px-2 py-1 text-xs text-white hover:bg-primary-700 disabled:opacity-50"
<Button
variant="primary"
size="xs"
disabled={creating}
onClick={() => void handleCreate()}>
{creating ? 'Creating...' : 'Create Invite'}
</button>
</Button>
</>
)}
</div>
@@ -662,12 +660,9 @@ function RedeemInvitePanel({ onClose }: { onClose: () => void }) {
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
Redeem Invite
</span>
<button
type="button"
className="text-xs text-stone-400 hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300"
onClick={onClose}>
<Button variant="tertiary" size="xs" onClick={onClose}>
Close
</button>
</Button>
</div>
{error ? <p className="mb-2 text-xs text-red-500">{error}</p> : null}
{result ? (
@@ -693,20 +688,20 @@ function RedeemInvitePanel({ onClose }: { onClose: () => void }) {
/>
</div>
<div className="flex gap-1 mb-2">
<button
type="button"
className="rounded bg-stone-200 dark:bg-neutral-700 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200 hover:bg-stone-300 dark:hover:bg-neutral-600 disabled:opacity-50"
<Button
variant="secondary"
size="xs"
disabled={previewLoading || !groupId.trim() || !token.trim()}
onClick={() => void handlePreview()}>
{previewLoading ? '...' : 'Preview'}
</button>
<button
type="button"
className="rounded bg-primary-600 px-2 py-1 text-xs text-white hover:bg-primary-700 disabled:opacity-50"
</Button>
<Button
variant="primary"
size="xs"
disabled={redeeming || !groupId.trim() || !token.trim()}
onClick={() => void handleRedeem()}>
{redeeming ? '...' : 'Redeem'}
</button>
</Button>
</div>
{preview ? (
<div className="rounded bg-stone-100 dark:bg-neutral-800 p-2 text-xs text-stone-600 dark:text-neutral-300">
@@ -1174,12 +1169,9 @@ function ActiveDmView({
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-2 border-b border-stone-200 dark:border-neutral-800 px-3 py-2">
<button
type="button"
onClick={onBack}
className="text-xs text-stone-400 hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300">
<Button variant="tertiary" size="xs" onClick={onBack}>
Back
</button>
</Button>
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100 truncate">
{peerId}
</span>
@@ -1246,13 +1238,13 @@ function ActiveDmView({
placeholder="Type a message..."
className="flex-1 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400"
/>
<button
type="button"
<Button
variant="primary"
size="md"
disabled={sending || !composeText.trim()}
onClick={() => void handleSend()}
className="rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50">
onClick={() => void handleSend()}>
{sending ? 'Sending...' : 'Send'}
</button>
</Button>
</div>
</div>
);
@@ -1365,13 +1357,13 @@ function DmsPanel() {
placeholder="Recipient @handle or wallet address"
className="flex-1 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400"
/>
<button
type="button"
<Button
variant="primary"
size="md"
disabled={!peerId.trim() || resolving}
onClick={() => void handleOpenDm()}
className="rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50">
onClick={() => void handleOpenDm()}>
{resolving ? 'Resolving...' : 'Open DM'}
</button>
</Button>
</div>
{resolveError && (
<p data-testid="dm-resolve-error" className="text-xs text-red-500 dark:text-red-400">
@@ -1407,23 +1399,14 @@ export default function MessagingSection({ tabs = VISIBLE_TABS }: MessagingSecti
<div className="flex flex-col h-full">
{/* Tab chips — only shown when more than one tab is enabled. */}
{showTabBar && (
<div className="flex gap-1 px-4 py-3 border-b border-stone-200 dark:border-neutral-800 overflow-x-auto shrink-0">
{tabs.map(tab => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
data-active={activeTab === tab}
className={[
'whitespace-nowrap rounded-full px-3 py-1 text-xs font-medium transition-colors',
activeTab === tab
? 'bg-stone-800 text-white dark:bg-neutral-100 dark:text-neutral-900'
: 'border border-stone-200 bg-white text-stone-600 hover:bg-stone-50 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800',
].join(' ')}>
{TAB_LABELS[tab]}
</button>
))}
</div>
<ChipTabs<Tab>
as="tab"
ariaLabel="Messaging sections"
className="flex gap-1 px-4 py-3 border-b border-stone-200 dark:border-neutral-800 overflow-x-auto shrink-0"
items={tabs.map(tab => ({ id: tab, label: TAB_LABELS[tab] }))}
value={activeTab}
onChange={setActiveTab}
/>
)}
{/* Signal key status — always visible when wallet is connected */}
+3 -6
View File
@@ -10,6 +10,7 @@
import { useCallback, useEffect, useState } from 'react';
import PanelScaffold from '../../components/layout/PanelScaffold';
import Button from '../../components/ui/Button';
import {
type FollowStats,
type GqlAttestation,
@@ -379,13 +380,9 @@ function AgentProfileCard({ data }: { data: ProfileData }) {
{/* Export identity */}
<div className="mt-4 border-t border-stone-200 pt-4 dark:border-neutral-800">
<button
type="button"
className="rounded-md bg-stone-100 px-3 py-1.5 text-xs font-medium text-stone-700 transition-colors hover:bg-stone-200 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700"
disabled={exportLoading}
onClick={handleExport}>
<Button variant="secondary" size="sm" disabled={exportLoading} onClick={handleExport}>
{exportLoading ? 'Exporting...' : exportData ? 'Hide Export' : 'Export Identity'}
</button>
</Button>
{exportError && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">{exportError}</p>
)}
+19 -23
View File
@@ -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) => {
<span className="text-sm font-medium text-white">{headerLabel(phase, t)}</span>
</div>
{(phase === 'ready_to_install' || phase === 'error') && (
<button
<Button
iconOnly
variant="tertiary"
size="xs"
onClick={phase === 'error' ? handleDismissError : handleLater}
className="p-1 text-stone-500 hover:text-stone-300 transition-colors"
aria-label={t('app.update.dismissNotification')}>
<CloseIcon className="w-3.5 h-3.5" />
</button>
</Button>
)}
</div>
@@ -134,16 +137,12 @@ const AppUpdatePrompt = (props: AppUpdatePromptProps) => {
{t('app.update.restartNote')}
</p>
<div className="mt-3 flex gap-2">
<button
onClick={handleInstall}
className="flex-1 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-400 text-white text-xs font-medium transition-colors">
<Button size="sm" onClick={handleInstall} className="flex-1">
{t('app.update.restartNow')}
</button>
<button
onClick={handleLater}
className="px-3 py-1.5 rounded-lg border border-stone-700 text-stone-300 hover:bg-stone-800 text-xs transition-colors">
</Button>
<Button variant="secondary" size="sm" onClick={handleLater}>
{t('app.update.later')}
</button>
</Button>
</div>
</>
)}
@@ -164,16 +163,12 @@ const AppUpdatePrompt = (props: AppUpdatePromptProps) => {
{error ?? t('app.update.errorFallback')}
</p>
<div className="mt-3 flex gap-2">
<button
onClick={handleRetryDownload}
className="flex-1 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-400 text-white text-xs font-medium transition-colors">
<Button size="sm" onClick={handleRetryDownload} className="flex-1">
{t('common.retry')}
</button>
<button
onClick={handleDismissError}
className="px-3 py-1.5 rounded-lg border border-stone-700 text-stone-300 hover:bg-stone-800 text-xs transition-colors">
</Button>
<Button variant="secondary" size="sm" onClick={handleDismissError}>
{t('common.dismiss')}
</button>
</Button>
</div>
</>
)}
@@ -264,12 +259,13 @@ const ReleaseNotesToggle = ({
}) => {
const { t } = useT();
return (
<button
type="button"
<Button
variant="tertiary"
size="xs"
onClick={onToggle}
className="mt-1 text-[11px] text-primary-300 hover:text-primary-200 transition-colors">
className="mt-1 px-0 text-[11px] text-primary-300 hover:bg-transparent hover:text-primary-200">
{expanded ? t('common.showLess') : t('common.showMore')}
</button>
</Button>
);
};
@@ -38,6 +38,7 @@ import {
import { isTauri } from '../../utils/tauriCommands/common';
import AppBackground from '../AppBackground';
import LanguageSelect from '../LanguageSelect';
import Button from '../ui/Button';
const log = debug('boot-check');
const logError = debug('boot-check:error');
@@ -350,15 +351,15 @@ function ModePicker({ onConfirm }: PickerProps) {
</div>
<div className="flex items-center gap-3">
<button
type="button"
<Button
variant="secondary"
size="sm"
onClick={handleTestConnection}
disabled={testStatus.kind === 'testing'}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
disabled={testStatus.kind === 'testing'}>
{testStatus.kind === 'testing'
? t('bootCheck.testing')
: t('bootCheck.testConnection')}
</button>
</Button>
{testStatus.kind === 'ok' && (
<span className="text-xs text-emerald-600" data-testid="test-status-ok">
{t('bootCheck.connectedOk')}
@@ -380,12 +381,7 @@ function ModePicker({ onConfirm }: PickerProps) {
</div>
<div className="mt-6 flex justify-end">
<button
type="button"
onClick={handleContinue}
className="rounded-lg bg-primary-500 px-5 py-2 text-sm font-medium text-white hover:bg-primary-600">
{t('common.continue')}
</button>
<Button onClick={handleContinue}>{t('common.continue')}</Button>
</div>
</Panel>
);
@@ -463,54 +459,36 @@ function ResultScreen({
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
<div className="mt-5 flex gap-3 flex-wrap">
{isPortConflict && foreignOwner ? (
<button
type="button"
<Button
tone="danger"
onClick={() => onForceQuit(foreignOwner.pid)}
disabled={actionBusy}
data-testid="force-quit-owner-btn"
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60">
data-testid="force-quit-owner-btn">
{(actionBusy
? t('bootCheck.portConflictForceQuitting')
: t('bootCheck.portConflictForceQuit')
)
.replace('{name}', foreignOwner.name)
.trim()}
</button>
</Button>
) : (
isPortConflict && (
<button
type="button"
onClick={onAction}
disabled={actionBusy}
data-testid="fix-automatically-btn"
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-60">
<Button onClick={onAction} disabled={actionBusy} data-testid="fix-automatically-btn">
{actionBusy
? t('bootCheck.portConflictFixing')
: t('bootCheck.portConflictFixButton')}
</button>
</Button>
)
)}
<button
type="button"
onClick={onRetry}
disabled={actionBusy}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
<Button variant="secondary" onClick={onRetry} disabled={actionBusy}>
{t('common.retry')}
</button>
<button
type="button"
onClick={onSwitchMode}
disabled={actionBusy}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
</Button>
<Button variant="secondary" onClick={onSwitchMode} disabled={actionBusy}>
{t('bootCheck.switchMode')}
</button>
<button
type="button"
onClick={onQuit}
disabled={actionBusy}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60">
</Button>
<Button tone="danger" onClick={onQuit} disabled={actionBusy}>
{t('bootCheck.quit')}
</button>
</Button>
</div>
</Panel>
);
@@ -527,20 +505,12 @@ function ResultScreen({
</p>
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
type="button"
onClick={onAction}
disabled={actionBusy}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60">
<Button tone="danger" onClick={onAction} disabled={actionBusy}>
{actionBusy ? t('bootCheck.removing') : t('bootCheck.removeContinue')}
</button>
<button
type="button"
onClick={onSwitchMode}
disabled={actionBusy}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
</Button>
<Button variant="secondary" onClick={onSwitchMode} disabled={actionBusy}>
{t('bootCheck.switchMode')}
</button>
</Button>
</div>
</Panel>
);
@@ -557,20 +527,12 @@ function ResultScreen({
</p>
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
type="button"
onClick={onAction}
disabled={actionBusy}
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-60">
<Button onClick={onAction} disabled={actionBusy}>
{actionBusy ? t('bootCheck.restarting') : t('bootCheck.restartCore')}
</button>
<button
type="button"
onClick={onSwitchMode}
disabled={actionBusy}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
</Button>
<Button variant="secondary" onClick={onSwitchMode} disabled={actionBusy}>
{t('bootCheck.switchMode')}
</button>
</Button>
</div>
</Panel>
);
@@ -587,20 +549,12 @@ function ResultScreen({
</p>
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
type="button"
onClick={onAction}
disabled={actionBusy}
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-60">
<Button onClick={onAction} disabled={actionBusy}>
{actionBusy ? t('bootCheck.updating') : t('bootCheck.updateCloudCore')}
</button>
<button
type="button"
onClick={onSwitchMode}
disabled={actionBusy}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
</Button>
<Button variant="secondary" onClick={onSwitchMode} disabled={actionBusy}>
{t('bootCheck.switchMode')}
</button>
</Button>
</div>
</Panel>
);
@@ -617,20 +571,12 @@ function ResultScreen({
</p>
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
type="button"
onClick={onAction}
disabled={actionBusy}
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-60">
<Button onClick={onAction} disabled={actionBusy}>
{actionBusy ? t('bootCheck.working') : t('bootCheck.restartUpdateCore')}
</button>
<button
type="button"
onClick={onSwitchMode}
disabled={actionBusy}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
</Button>
<Button variant="secondary" onClick={onSwitchMode} disabled={actionBusy}>
{t('bootCheck.switchMode')}
</button>
</Button>
</div>
</Panel>
);
+11 -6
View File
@@ -16,6 +16,7 @@ import {
openhumanLocalAiDownloadsProgress,
openhumanLocalAiStatus,
} from '../utils/tauriCommands';
import Button from './ui/Button';
const POLL_INTERVAL = 2000;
@@ -153,22 +154,26 @@ const LocalAIDownloadSnackbar = () => {
<span className="text-sm font-medium text-white">{label}</span>
</div>
<div className="flex items-center gap-1">
<button
<Button
iconOnly
variant="tertiary"
size="xs"
onClick={handleToggleCollapse}
className="p-1 text-stone-500 hover:text-stone-300 transition-colors"
aria-label={t('app.localAiDownload.collapseAria')}>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.75 7.25a.75.75 0 000 1.5h8.5a.75.75 0 000-1.5h-8.5z" />
</svg>
</button>
<button
</Button>
<Button
iconOnly
variant="tertiary"
size="xs"
onClick={handleDismiss}
className="p-1 text-stone-500 hover:text-stone-300 transition-colors"
aria-label={t('app.localAiDownload.dismissAria')}>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.28 3.22a.75.75 0 00-1.06 1.06L6.94 8l-3.72 3.72a.75.75 0 101.06 1.06L8 9.06l3.72 3.72a.75.75 0 101.06-1.06L9.06 8l3.72-3.72a.75.75 0 00-1.06-1.06L8 6.94 4.28 3.22z" />
</svg>
</button>
</Button>
</div>
</div>
+24 -38
View File
@@ -33,6 +33,7 @@ import { BILLING_DASHBOARD_URL, DISCORD_INVITE_URL } from '../utils/links';
import { openUrl } from '../utils/openUrl';
import { ProviderIcon } from './accounts/providerIcons';
import ChannelSetupModal from './channels/ChannelSetupModal';
import Button from './ui/Button';
interface OpenhumanLinkEvent {
path: string;
@@ -92,11 +93,12 @@ const OpenhumanLinkModal = () => {
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{titleForPath(activePath, t)}
</h2>
<button
type="button"
<Button
iconOnly
variant="tertiary"
size="xs"
onClick={close}
aria-label={t('common.close')}
className="rounded p-1 text-stone-500 dark:text-neutral-400 hover:bg-stone-100 dark:hover:bg-neutral-800/60 hover:text-stone-800 dark:hover:text-neutral-100">
aria-label={t('common.close')}>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path
strokeLinecap="round"
@@ -105,7 +107,7 @@ const OpenhumanLinkModal = () => {
d="M6 6l12 12M6 18L18 6"
/>
</svg>
</button>
</Button>
</div>
<div className="p-5">{renderBody(activePath, close)}</div>
</div>
@@ -144,12 +146,9 @@ const MessagingSetupBridge = ({ onClose }: { onClose: () => void }) => {
onClick={e => e.stopPropagation()}>
<p>{t('app.openhumanLink.telegramUnavailable')}</p>
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60">
<Button variant="secondary" size="sm" onClick={onClose}>
{t('common.close')}
</button>
</Button>
</div>
</div>
</div>
@@ -275,17 +274,13 @@ const NotificationsBody = ({ close }: { close: () => void }) => {
{t('app.openhumanLink.notifications.promptHint')}
</div>
)}
<button
type="button"
onClick={() => void handleAllow()}
disabled={status === 'sending'}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors disabled:opacity-60">
<Button onClick={() => void handleAllow()} disabled={status === 'sending'} className="w-full">
{status === 'sending'
? t('app.openhumanLink.notifications.asking')
: status === 'error'
? t('app.openhumanLink.notifications.retry')
: t('app.openhumanLink.notifications.send')}
</button>
</Button>
{status === 'sent' && (
<p className="text-xs text-sage-700">{t('app.openhumanLink.notifications.sent')}</p>
)}
@@ -316,14 +311,13 @@ const BillingBody = ({ close }: { close: () => void }) => {
{t('app.openhumanLink.billing.trialDesc')}
</p>
</div>
<button
type="button"
<Button
onClick={() => {
void openUrl(BILLING_DASHBOARD_URL).catch(() => {});
}}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors">
className="w-full">
{t('app.openhumanLink.billing.openDashboard')}
</button>
</Button>
<DoneFooter close={close} skipLabel={t('app.openhumanLink.billing.stayOnTrial')} />
</div>
);
@@ -354,8 +348,7 @@ const DiscordBody = ({ close }: { close: () => void }) => {
{t('app.openhumanLink.discord.perk4')}
</li>
</ul>
<button
type="button"
<Button
onClick={async () => {
try {
await openUrl(DISCORD_INVITE_URL);
@@ -363,9 +356,9 @@ const DiscordBody = ({ close }: { close: () => void }) => {
// Ignore launcher errors from OS URL handler failures.
}
}}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors">
className="w-full">
{t('app.openhumanLink.discord.openInvite')}
</button>
</Button>
<DoneFooter close={close} skipLabel={t('app.openhumanLink.maybeLater')} />
</div>
);
@@ -385,8 +378,7 @@ const DiscordReportBody = ({ close }: { close: () => void }) => {
return (
<div className="space-y-4 text-sm text-stone-700 dark:text-neutral-200">
<p>{t('app.openhumanLink.discordReport.intro')}</p>
<button
type="button"
<Button
onClick={async () => {
try {
await openUrl(DISCORD_INVITE_URL);
@@ -394,9 +386,9 @@ const DiscordReportBody = ({ close }: { close: () => void }) => {
close();
}
}}
className="w-full rounded-xl bg-primary-500 px-3 py-2.5 text-sm font-medium text-white hover:bg-primary-600 transition-colors">
className="w-full">
{t('app.openhumanLink.discordReport.openDiscord')}
</button>
</Button>
</div>
);
};
@@ -597,18 +589,12 @@ const DoneFooter = ({
const resolvedSkip = skipLabel ?? t('app.openhumanLink.skipForNow');
return (
<div className="flex items-center justify-between gap-3 pt-1">
<button
type="button"
onClick={close}
className="text-xs font-medium text-stone-500 dark:text-neutral-400 hover:text-stone-800 dark:hover:text-neutral-100">
<Button variant="tertiary" size="sm" onClick={close}>
{resolvedSkip}
</button>
<button
type="button"
onClick={onDone ?? close}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60">
</Button>
<Button variant="secondary" size="sm" onClick={onDone ?? close}>
{resolvedDone}
</button>
</Button>
</div>
);
};
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { useT } from '../lib/i18n/I18nContext';
import { persistor } from '../store';
import RouteLoadingScreen from './RouteLoadingScreen';
import Button from './ui/Button';
const persistWarn = debug('persist:warn');
@@ -62,13 +63,9 @@ function PersistRehydrationScreen() {
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('app.persistRehydration.body')}
</p>
<button
type="button"
onClick={handleReset}
disabled={resetting}
className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-60">
<Button onClick={handleReset} disabled={resetting} className="w-full">
{resetting ? t('app.persistRehydration.resetting') : t('app.persistRehydration.resetCta')}
</button>
</Button>
</div>
</div>
);
-53
View File
@@ -1,53 +0,0 @@
import type { ReactNode } from 'react';
interface PillTabBarItem<T extends string> {
label: string;
value: T;
}
interface PillTabBarProps<T extends string> {
activeClassName?: string;
containerClassName?: string;
inactiveClassName?: string;
itemClassName?: string;
items: PillTabBarItem<T>[];
onChange: (value: T) => void;
renderItem?: (item: PillTabBarItem<T>, active: boolean) => ReactNode;
selected: T;
}
export default function PillTabBar<T extends string>({
activeClassName = 'border-primary-200 dark:border-primary-500/40 bg-primary-50 dark:bg-primary-500/15 text-primary-700 dark:text-primary-300',
containerClassName = 'flex gap-2 overflow-x-auto pb-1 scrollbar-hide',
inactiveClassName = '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',
itemClassName = 'px-3',
items,
onChange,
renderItem,
selected,
}: PillTabBarProps<T>) {
return (
<div className={containerClassName} role="tablist">
{items.map(item => {
const active = selected === item.value;
const tabId = `pill-tab-${String(item.value)}`;
return (
<button
key={item.value}
type="button"
id={tabId}
role="tab"
aria-selected={active}
tabIndex={active ? 0 : -1}
onClick={() => onChange(item.value)}
className={`flex-shrink-0 rounded-full border py-1 text-xs font-medium transition-colors ${itemClassName} ${
active ? activeClassName : inactiveClassName
}`}>
{renderItem ? renderItem(item, active) : item.label}
</button>
);
})}
</div>
);
}
@@ -4,6 +4,7 @@ import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useT } from '../../lib/i18n/I18nContext';
import { type AccountProvider, type ProviderDescriptor, PROVIDERS } from '../../types/accounts';
import { CloseIcon } from '../ui';
import Button from '../ui/Button';
import { ProviderIcon } from './providerIcons';
interface AddAccountModalProps {
@@ -47,15 +48,16 @@ const AddAccountModal = ({ open, onClose, onPick, connectedProviders }: AddAccou
className="text-lg font-semibold text-stone-900 dark:text-neutral-100">
{t('accounts.addModal.title')}
</h2>
<button
<Button
ref={closeBtnRef}
type="button"
iconOnly
variant="tertiary"
size="sm"
onClick={onClose}
data-analytics-id="add-account-modal-close"
className="rounded p-1 text-stone-500 dark:text-neutral-400 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60"
aria-label={t('common.close')}>
<CloseIcon className="h-5 w-5" />
</button>
</Button>
</div>
<div className="space-y-1">
@@ -1,6 +1,7 @@
import { useT } from '../../lib/i18n/I18nContext';
import type { RespondQueueItem } from '../../types/providerSurfaces';
import { openUrl } from '../../utils/openUrl';
import Button from '../ui/Button';
interface RespondQueuePanelProps {
items: RespondQueueItem[];
@@ -46,13 +47,13 @@ export default function RespondQueuePanel({
{count} {t('accounts.respondQueue.pending')}
</p>
</div>
<button
type="button"
<Button
variant="secondary"
size="xs"
onClick={onRefresh}
data-analytics-id="respond-queue-refresh"
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-2 py-1 text-xs text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/60">
data-analytics-id="respond-queue-refresh">
{t('common.refresh')}
</button>
</Button>
</div>
<div className="flex-1 overflow-y-auto px-3 py-3">
{status === 'loading' && items.length === 0 ? (
+6 -5
View File
@@ -11,6 +11,7 @@ import {
} from '../../services/webviewAccountService';
import { useAppSelector } from '../../store/hooks';
import type { AccountProvider, AccountStatus } from '../../types/accounts';
import Button from '../ui/Button';
import { ProviderIcon } from './providerIcons';
const log = debug('webview-accounts:host');
@@ -260,8 +261,9 @@ const WebviewHost = ({ accountId, provider }: WebviewHostProps) => {
{t('accounts.webviewHost.timeoutHint')}
</p>
</div>
<button
type="button"
<Button
variant="primary"
size="sm"
data-analytics-id={`webview-host-retry-${provider}`}
onClick={() => {
log('retry clicked account=%s provider=%s', accountId, provider);
@@ -276,10 +278,9 @@ const WebviewHost = ({ accountId, provider }: WebviewHostProps) => {
// service-layer dispatched error status + breadcrumb; absorbing
// the rejection keeps onunhandledrejection clean.
});
}}
className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-primary-700">
}}>
{t('accounts.webviewHost.retryLoading')}
</button>
</Button>
</div>
) : null}
</div>
@@ -9,6 +9,7 @@ import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useT } from '../../lib/i18n/I18nContext';
import type { ChannelDefinition, ChannelType } from '../../types/channels';
import { CloseIcon } from '../ui';
import Button from '../ui/Button';
import { renderChannelIcon } from './channelIcon';
import DiscordConfig from './DiscordConfig';
import TelegramConfig from './TelegramConfig';
@@ -92,11 +93,15 @@ export default function ChannelSetupModal({ definition, onClose }: ChannelSetupM
{definition.description}
</p>
</div>
<button
<Button
iconOnly
variant="tertiary"
size="xs"
aria-label={t('common.close')}
onClick={onClose}
className="p-1 text-stone-400 dark:text-neutral-500 hover:text-stone-900 dark:hover:text-neutral-100 dark:text-neutral-100 dark:hover:text-neutral-100 transition-colors rounded-lg hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60 flex-shrink-0">
className="flex-shrink-0">
<CloseIcon className="w-5 h-5" />
</button>
</Button>
</div>
</div>
@@ -22,6 +22,7 @@ import type {
import { isLocalSessionToken } from '../../utils/localSession';
import { openUrl } from '../../utils/openUrl';
import { restartCoreProcess } from '../../utils/tauriCommands/core';
import Button from '../ui/Button';
import {
ChannelAuthFields,
ChannelAuthModeCard,
@@ -333,12 +334,9 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
<code className="flex-1 rounded bg-white dark:bg-neutral-900 border border-primary-200 dark:border-primary-500/30 px-2 py-1 text-xs font-mono text-stone-800 dark:text-neutral-100 select-all break-all">
{linkToken}
</code>
<button
type="button"
onClick={copyToken}
className="shrink-0 rounded-lg border border-primary-300 dark:border-primary-500/40 px-2 py-1 text-xs font-medium text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-500/20">
<Button variant="secondary" size="xs" onClick={copyToken} className="shrink-0">
{copied ? t('common.copied') : t('common.copy')}
</button>
</Button>
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('channels.discord.linkTokenInstruction').replace('{token}', linkToken)}
+33 -30
View File
@@ -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) => {
)}
<div className="mt-3 flex gap-2">
<button
type="button"
<Button
variant="primary"
size="sm"
disabled={busy}
onClick={handleConnect}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50">
{busy && (
<svg
className="h-3 w-3 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
leadingIcon={
busy ? (
<svg
className="h-3 w-3 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : undefined
}>
{busy
? t('channels.yuanbao.connecting')
: status === 'connected'
? t('channels.yuanbao.reconnect')
: t('channels.yuanbao.connect')}
</button>
<button
type="button"
</Button>
<Button
variant="secondary"
size="sm"
disabled={busy || status === 'disconnected'}
onClick={handleDisconnect}
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
onClick={handleDisconnect}>
{t('accounts.disconnect')}
</button>
</Button>
</div>
</div>
</div>
@@ -1,6 +1,7 @@
import { type ReactNode, useCallback, useState } from 'react';
import type { AuthModeSpec, ChannelConnectionStatus } from '../../types/channels';
import Button from '../ui/Button';
import ChannelFieldInput from './ChannelFieldInput';
import ChannelStatusBadge from './ChannelStatusBadge';
@@ -138,21 +139,17 @@ export function ChannelConnectActions({
return (
<div className={`mt-3 flex gap-2 ${className ?? ''}`}>
{showConnect && onConnect ? (
<button
type="button"
disabled={busy}
onClick={onConnect}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50">
<Button variant="primary" size="sm" disabled={busy} onClick={onConnect}>
{connectLabel}
</button>
</Button>
) : null}
<button
type="button"
<Button
variant="secondary"
size="sm"
disabled={busy || status === 'disconnected'}
onClick={onDisconnect}
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
onClick={onDisconnect}>
{disconnectLabel}
</button>
</Button>
</div>
);
}
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { BubbleMarkdown } from '../../../pages/conversations/components/AgentMessageBubble';
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
import Button from '../../ui/Button';
const log = debug('mcp-clients:config-assist');
@@ -213,13 +214,14 @@ const ConfigAssistantPanel = ({
placeholder={t('mcp.configAssistant.inputPlaceholder')}
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-sm 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 resize-none"
/>
<button
type="button"
<Button
variant="primary"
size="md"
disabled={sending || !input.trim()}
onClick={() => void handleSend()}
className="self-end rounded-lg bg-primary-500 px-3 py-2 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors shrink-0">
className="self-end shrink-0">
{t('mcp.configAssistant.send')}
</button>
</Button>
</div>
</div>
);
@@ -8,6 +8,7 @@
* only this modal and returns to whatever opened it.
*/
import { useT } from '../../../lib/i18n/I18nContext';
import Button from '../../ui/Button';
import ConfigAssistantPanel from './ConfigAssistantPanel';
interface ConfigHelpModalProps {
@@ -51,13 +52,14 @@ const ConfigHelpModal = ({
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('mcp.connectAuth.howToGetToken')}
</h3>
<button
type="button"
<Button
variant="secondary"
size="xs"
onClick={onClose}
aria-label={t('common.cancel')}
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-2 py-1 text-xs text-stone-500 dark:text-neutral-400 hover:border-stone-300 dark:hover:border-neutral-600">
className="shrink-0">
</button>
</Button>
</div>
<div className="min-h-0 flex-1">
<ConfigAssistantPanel
@@ -24,6 +24,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
import { openUrl } from '../../../utils/openUrl';
import Button from '../../ui/Button';
import ConfigHelpModal from './ConfigHelpModal';
import type { InstalledServer, McpTool } from './types';
@@ -268,13 +269,9 @@ const ConnectAuthModal = ({ server, onClose, onConnected }: ConnectAuthModalProp
<p className="text-xs text-stone-600 dark:text-neutral-300">
{t('mcp.connectAuth.oauthHint')}
</p>
<button
type="button"
onClick={handleOAuth}
disabled={busy}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
<Button variant="primary" size="sm" onClick={handleOAuth} disabled={busy}>
{oauthWaiting ? t('mcp.connectAuth.oauthWaiting') : t('mcp.connectAuth.signIn')}
</button>
</Button>
<p className="text-[11px] text-stone-400 dark:text-neutral-500">
{t('mcp.connectAuth.oauthOrToken')}
</p>
@@ -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"
/>
<button
type="button"
<Button
variant="secondary"
size="xs"
onClick={() => setReveal(prev => ({ ...prev, [key]: !prev[key] }))}
disabled={busy}
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-2 py-1 text-[11px] text-stone-500 dark:text-neutral-400 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
className="shrink-0">
{reveal[key] ? t('mcp.install.hide') : t('mcp.install.show')}
</button>
</Button>
</div>
</div>
))}
@@ -392,14 +390,15 @@ const ConnectAuthModal = ({ server, onClose, onConnected }: ConnectAuthModalProp
<option value="bearer">{t('mcp.connectAuth.schemeBearer')}</option>
<option value="raw">{t('mcp.connectAuth.schemeRaw')}</option>
</select>
<button
type="button"
<Button
variant="secondary"
size="xs"
onClick={() => removeCustomHeader(h.id)}
disabled={busy}
aria-label={t('mcp.connectAuth.removeHeader')}
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-2 py-1 text-[11px] text-stone-500 dark:text-neutral-400 hover:border-coral-300 hover:text-coral-600 disabled:opacity-50">
className="shrink-0">
</button>
</Button>
</div>
{/* Row 2: full-width value (tokens are long) */}
<input
@@ -426,20 +425,12 @@ const ConnectAuthModal = ({ server, onClose, onConnected }: ConnectAuthModalProp
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
disabled={busy}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
<Button variant="secondary" size="sm" onClick={onClose} disabled={busy}>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleConnect}
disabled={busy}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
</Button>
<Button variant="primary" size="sm" onClick={handleConnect} disabled={busy}>
{busy ? t('mcp.detail.connecting') : t('mcp.detail.connect')}
</button>
</Button>
</div>
</div>
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
import Button from '../../ui/Button';
import { deriveAuthor } from './McpServerCard';
import type { InstalledServer, SmitheryConnection, SmitheryServerDetail } from './types';
@@ -177,12 +178,13 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{detailError}
</div>
<button
type="button"
<Button
variant="tertiary"
size="sm"
onClick={onCancel}
className="text-sm text-stone-500 dark:text-neutral-400 hover:underline">
className="text-stone-500 dark:text-neutral-400 hover:underline">
{t('mcp.install.back')}
</button>
</Button>
</div>
);
}
@@ -197,12 +199,13 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
if (step === 'detail') {
return (
<div className="space-y-5">
<button
type="button"
<Button
variant="tertiary"
size="xs"
onClick={onCancel}
className="text-xs text-stone-500 dark:text-neutral-400 hover:underline">
className="text-stone-500 dark:text-neutral-400 hover:underline">
{t('mcp.install.back')}
</button>
</Button>
{/* Header */}
<div className="flex items-start gap-4">
@@ -320,24 +323,20 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
type="button"
<Button
variant="primary"
size="lg"
disabled={installing}
onClick={() => void handleDirectInstall()}
className="rounded-lg bg-primary-500 px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
onClick={() => void handleDirectInstall()}>
{installing
? t('mcp.install.installing')
: hasEnvKeys
? t('mcp.install.configureAndInstall')
: t('mcp.install.button')}
</button>
<button
type="button"
disabled={installing}
onClick={onCancel}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-5 py-2.5 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50 transition-colors">
</Button>
<Button variant="secondary" size="lg" disabled={installing} onClick={onCancel}>
{t('common.cancel')}
</button>
</Button>
</div>
</div>
);
@@ -347,15 +346,16 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
return (
<div className="space-y-4">
<button
type="button"
<Button
variant="tertiary"
size="xs"
onClick={() => {
setStep('detail');
setInstallError(null);
}}
className="text-xs text-stone-500 dark:text-neutral-400 hover:underline">
className="text-stone-500 dark:text-neutral-400 hover:underline">
{detail.display_name}
</button>
</Button>
{/* Compact header */}
<div className="flex items-center gap-3">
@@ -398,13 +398,14 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
disabled={installing}
className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm 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"
/>
<button
type="button"
<Button
variant="secondary"
size="sm"
onClick={() => toggleShowEnv(key)}
disabled={installing}
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-2.5 py-1.5 text-xs text-stone-500 dark:text-neutral-400 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
className="shrink-0">
{showEnv[key] ? t('mcp.install.hide') : t('mcp.install.show')}
</button>
</Button>
</div>
</div>
))}
@@ -448,20 +449,16 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
type="button"
<Button
variant="primary"
size="lg"
disabled={installing}
onClick={() => void handleInstall()}
className="rounded-lg bg-primary-500 px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
onClick={() => void handleInstall()}>
{installing ? t('mcp.install.installing') : t('mcp.install.button')}
</button>
<button
type="button"
disabled={installing}
onClick={onCancel}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-4 py-2.5 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50 transition-colors">
</Button>
<Button variant="secondary" size="lg" disabled={installing} onClick={onCancel}>
{t('common.cancel')}
</button>
</Button>
</div>
</div>
);
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
import Button from '../../ui/Button';
import { clearConfigChat } from './ConfigAssistantPanel';
import ConfigHelpModal from './ConfigHelpModal';
import ConnectAuthModal from './ConnectAuthModal';
@@ -278,44 +279,40 @@ const InstalledServerDetail = ({
core refuses connect calls on disabled servers. */}
{server.enabled &&
(status !== 'connected' ? (
<button
type="button"
<Button
variant="primary"
size="sm"
disabled={busy || status === 'connecting'}
onClick={handleConnect}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
onClick={handleConnect}>
{status === 'connecting'
? t('mcp.detail.connecting')
: status === 'unauthorized'
? t('mcp.detail.authenticate')
: t('mcp.detail.connect')}
</button>
</Button>
) : (
<button
type="button"
disabled={busy}
onClick={handleDisconnect}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
<Button variant="secondary" size="sm" disabled={busy} onClick={handleDisconnect}>
{t('mcp.detail.disconnect')}
</button>
</Button>
))}
{/* Enable / Disable toggle */}
<button
type="button"
<Button
variant="secondary"
size="sm"
disabled={busy}
onClick={() => handleSetEnabled(!server.enabled)}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
onClick={() => handleSetEnabled(!server.enabled)}>
{server.enabled ? t('mcp.detail.disable') : t('mcp.detail.enable')}
</button>
</Button>
{SHOW_CONFIG_ASSISTANT && (
<button
type="button"
<Button
variant="secondary"
size="sm"
disabled={busy}
onClick={() => setShowAssistant(prev => !prev)}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
onClick={() => setShowAssistant(prev => !prev)}>
{showAssistant ? t('mcp.connectAuth.hideHelp') : t('mcp.connectAuth.howToGetToken')}
</button>
</Button>
)}
{confirmUninstall ? (
@@ -323,29 +320,31 @@ const InstalledServerDetail = ({
<span className="text-xs text-coral-600 dark:text-coral-400 font-medium">
{t('mcp.detail.confirmUninstall')}
</span>
<button
type="button"
<Button
variant="primary"
tone="danger"
size="sm"
disabled={busy}
onClick={handleUninstall}
className="rounded-lg bg-coral-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-coral-600 disabled:opacity-50">
onClick={handleUninstall}>
{t('mcp.detail.confirmUninstallAction')}
</button>
<button
type="button"
</Button>
<Button
variant="secondary"
size="sm"
disabled={busy}
onClick={() => setConfirmUninstall(false)}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 disabled:opacity-50">
onClick={() => setConfirmUninstall(false)}>
{t('common.cancel')}
</button>
</Button>
</div>
) : (
<button
type="button"
<Button
variant="secondary"
tone="danger"
size="sm"
disabled={busy}
onClick={() => setConfirmUninstall(true)}
className="rounded-lg border border-coral-200 dark:border-coral-500/30 px-3 py-1.5 text-xs font-medium text-coral-600 dark:text-coral-400 hover:bg-coral-50 dark:hover:bg-coral-500/10 disabled:opacity-50">
onClick={() => setConfirmUninstall(true)}>
{t('mcp.detail.uninstall')}
</button>
</Button>
)}
</div>
@@ -399,23 +398,20 @@ const InstalledServerDetail = ({
disabled={busy}
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"
/>
<button
type="button"
<Button
variant="secondary"
size="xs"
onClick={() => setShowReconfig(prev => ({ ...prev, [key]: !prev[key] }))}
disabled={busy}
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-2 py-1 text-[11px] text-stone-500 dark:text-neutral-400 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
className="shrink-0">
{showReconfig[key] ? t('mcp.install.hide') : t('mcp.install.show')}
</button>
</Button>
</div>
</div>
))}
<button
type="button"
disabled={busy}
onClick={handleSaveReconfigure}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
<Button variant="primary" size="sm" disabled={busy} onClick={handleSaveReconfigure}>
{busy ? t('mcp.detail.reconfigureSaving') : t('mcp.detail.saveReconnect')}
</button>
</Button>
</div>
)}
</div>
@@ -12,6 +12,7 @@
import { type KeyboardEvent as ReactKeyboardEvent, useMemo, useRef } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import Button from '../../ui/Button';
import type { ConnStatus, InstalledServer, ServerStatus } from './types';
interface InstalledServerListProps {
@@ -93,23 +94,21 @@ const InstalledServerList = ({
<h3 className="text-xs font-semibold text-stone-500 dark:text-neutral-400 uppercase tracking-wide">
{t('mcp.installed.title')}
</h3>
<button
type="button"
<Button
variant="tertiary"
size="xs"
onClick={onBrowseCatalog}
className="text-xs text-primary-600 dark:text-primary-300 hover:underline font-medium">
className="text-primary-600 dark:text-primary-300 hover:underline">
{t('mcp.installed.browseCatalog')}
</button>
</Button>
</div>
{servers.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3 py-8">
<p className="text-sm text-stone-400 dark:text-neutral-500">{t('mcp.installed.empty')}</p>
<button
type="button"
onClick={onBrowseCatalog}
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600 transition-colors">
<Button variant="primary" size="md" onClick={onBrowseCatalog}>
{t('mcp.installed.browseCatalog')}
</button>
</Button>
</div>
) : (
<>
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
import Button from '../../ui/Button';
import McpServerCard from './McpServerCard';
import type { SmitheryServer } from './types';
@@ -126,13 +127,9 @@ const McpCatalogBrowser = ({ onSelectInstall }: McpCatalogBrowserProps) => {
{page < totalPages && (
<div className="flex justify-center pt-2">
<button
type="button"
disabled={loading}
onClick={handleLoadMore}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-4 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
<Button variant="secondary" size="md" disabled={loading} onClick={handleLoadMore}>
{loading ? t('common.loading') : t('mcp.catalog.loadMore')}
</button>
</Button>
</div>
)}
</>
@@ -20,6 +20,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import Button from '../../ui/Button';
import type { ConnStatus } from './types';
interface McpConnectionHealthToolbarProps {
@@ -232,18 +233,16 @@ const McpConnectionHealthToolbar = ({
)}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmDisconnect(false)}
className="px-3 py-1.5 text-xs rounded-lg border border-stone-200 dark:border-neutral-700 text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800">
<Button variant="secondary" size="sm" onClick={() => setConfirmDisconnect(false)}>
{t('mcp.health.disconnectConfirm.cancel')}
</button>
<button
type="button"
onClick={() => void runDisconnectAll()}
className="px-3 py-1.5 text-xs rounded-lg bg-coral-500 text-white hover:bg-coral-600">
</Button>
<Button
variant="primary"
tone="danger"
size="sm"
onClick={() => void runDisconnectAll()}>
{t('mcp.health.disconnectConfirm.confirm')}
</button>
</Button>
</div>
</div>
</div>
@@ -9,6 +9,7 @@
import { useMemo, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import Button from '../../ui/Button';
import {
buildManifest,
type McpInventoryManifest,
@@ -76,22 +77,22 @@ const McpInventoryExportTab = ({ servers }: McpInventoryExportTabProps) => {
{t('mcp.inventory.export.serverCount').replace('{count}', String(servers.length))}
</p>
<div className="flex items-center gap-2">
<button
type="button"
<Button
variant="secondary"
size="sm"
onClick={() => void handleCopy()}
aria-label={t('mcp.inventory.export.copyAria')}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800">
aria-label={t('mcp.inventory.export.copyAria')}>
{copyStatus === 'copied'
? t('mcp.inventory.export.copied')
: t('mcp.inventory.export.copy')}
</button>
<button
type="button"
</Button>
<Button
variant="primary"
size="sm"
onClick={handleDownload}
aria-label={t('mcp.inventory.export.downloadAria')}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600">
aria-label={t('mcp.inventory.export.downloadAria')}>
{t('mcp.inventory.export.download')}
</button>
</Button>
</div>
</div>
<pre
@@ -21,6 +21,7 @@
import { type ChangeEvent, useCallback, useMemo, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import Button from '../../ui/Button';
import {
type ClassifiedImportEntry,
classifyImport,
@@ -181,20 +182,17 @@ const McpInventoryImportTab = ({
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<button
type="button"
<Button
variant="primary"
size="sm"
onClick={handleParse}
disabled={rawInput.trim().length === 0}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed">
disabled={rawInput.trim().length === 0}>
{t('mcp.inventory.import.preview')}
</button>
</Button>
{(manifest || parseError || rawInput.length > 0) && (
<button
type="button"
onClick={handleClear}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800">
<Button variant="secondary" size="sm" onClick={handleClear}>
{t('mcp.inventory.import.clear')}
</button>
</Button>
)}
</div>
<label className="text-xs text-primary-600 dark:text-primary-300 cursor-pointer hover:underline">
@@ -276,16 +274,17 @@ const McpInventoryImportTab = ({
)}
</div>
{status === 'new' ? (
<button
type="button"
<Button
variant="primary"
size="xs"
onClick={() => handleInstall(entry)}
aria-label={t('mcp.inventory.import.installAria').replace(
'{name}',
entry.display_name
)}
className="shrink-0 rounded-lg bg-primary-500 px-3 py-1 text-xs font-medium text-white hover:bg-primary-600">
className="shrink-0">
{t('mcp.inventory.import.install')}
</button>
</Button>
) : (
<span className="shrink-0 text-[10px] text-stone-400 dark:text-neutral-500 italic">
{t('mcp.inventory.import.skipped')}
@@ -18,6 +18,7 @@
import { useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import Button from '../../ui/Button';
import McpInventoryExportTab from './McpInventoryExportTab';
import McpInventoryImportTab from './McpInventoryImportTab';
import type { InstalledServer } from './types';
@@ -76,11 +77,13 @@ const McpInventoryPanel = ({ servers, onInstallServer, onClose }: McpInventoryPa
{t('mcp.inventory.subtitle')}
</p>
</div>
<button
type="button"
<Button
iconOnly
variant="tertiary"
size="sm"
onClick={onClose}
aria-label={t('mcp.inventory.close')}
className="shrink-0 rounded-lg p-1.5 text-stone-400 hover:text-stone-700 dark:text-neutral-500 dark:hover:text-neutral-200 transition-colors">
className="shrink-0">
<svg
className="w-4 h-4"
fill="none"
@@ -90,7 +93,7 @@ const McpInventoryPanel = ({ servers, onInstallServer, onClose }: McpInventoryPa
aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Button>
</div>
{/* Tab bar */}
@@ -145,9 +145,9 @@ describe('McpServersTab', () => {
expect(screen.queryByText('Loading MCP servers...')).not.toBeInTheDocument();
});
expect(screen.getByRole('button', { name: 'All' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Installed/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Registry' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'All' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Installed/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Registry' })).toBeInTheDocument();
});
it('shows empty-installed state when Installed chip is active and no servers', async () => {
@@ -161,7 +161,7 @@ describe('McpServersTab', () => {
expect(screen.queryByText('Loading MCP servers...')).not.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();
@@ -518,7 +518,7 @@ describe('McpServersTab', () => {
await waitFor(() => screen.getByText('File Server'));
fireEvent.click(screen.getByRole('button', { name: 'Registry' }));
fireEvent.click(screen.getByRole('tab', { name: 'Registry' }));
await waitFor(() => {
expect(screen.queryByText('File Server')).not.toBeInTheDocument();
@@ -10,6 +10,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
import ChipTabs from '../../layout/ChipTabs';
import Button from '../../ui/Button';
import InstallDialog from './InstallDialog';
import InstalledServerDetail from './InstalledServerDetail';
import McpInventoryPanel from './McpInventoryPanel';
@@ -225,20 +227,22 @@ const McpServersTab = () => {
if (view.mode === 'detail' && selectedServer) {
return (
<div className="space-y-3">
<button
type="button"
<Button
variant="tertiary"
size="xs"
onClick={() => setView({ mode: 'home' })}
className="inline-flex items-center gap-1.5 text-xs font-medium text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 transition-colors">
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
leadingIcon={
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
}>
{t('mcp.install.back')}
</button>
</Button>
<InstalledServerDetail
server={selectedServer}
connStatus={selectedConnStatus}
@@ -253,20 +257,22 @@ const McpServersTab = () => {
if (view.mode === 'install') {
return (
<div className="space-y-3">
<button
type="button"
<Button
variant="tertiary"
size="xs"
onClick={() => setView({ mode: 'home' })}
className="inline-flex items-center gap-1.5 text-xs font-medium text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 transition-colors">
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
leadingIcon={
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
}>
{t('mcp.install.back')}
</button>
</Button>
<InstallDialog
qualifiedName={view.qualifiedName}
prefillEnv={view.prefillEnv}
@@ -290,34 +296,33 @@ const McpServersTab = () => {
aria-label={t('mcp.catalog.searchAria')}
className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm 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"
/>
<button
type="button"
<Button
variant="secondary"
size="md"
onClick={() => setInventoryOpen(true)}
aria-label={t('mcp.inventory.openAria')}
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-2 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800">
className="shrink-0">
{t('mcp.inventory.openButton')}
</button>
</Button>
</div>
{/* Filter chips */}
<div className="flex items-center gap-2">
{(['all', 'installed', 'registry'] as FilterChip[]).map(chip => (
<button
key={chip}
type="button"
onClick={() => setActiveChip(chip)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
activeChip === chip
? 'bg-primary-500 text-white'
: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-700'
}`}>
{chip === 'all' && t('mcp.tab.filter.all')}
{chip === 'installed' &&
t('mcp.tab.filter.installed').replace('{count}', String(filteredInstalled.length))}
{chip === 'registry' && t('mcp.tab.filter.registry')}
</button>
))}
</div>
<ChipTabs<FilterChip>
className="flex flex-wrap items-center gap-2"
value={activeChip}
onChange={setActiveChip}
items={[
{ id: 'all', label: t('mcp.tab.filter.all') },
{
id: 'installed',
label: t('mcp.tab.filter.installed').replace(
'{count}',
String(filteredInstalled.length)
),
},
{ id: 'registry', label: t('mcp.tab.filter.registry') },
]}
/>
{loadError && (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
@@ -495,12 +500,13 @@ const McpServersTab = () => {
catalogPage < catalogTotalPages &&
(activeChip === 'all' || activeChip === 'registry') && (
<div className="py-3 text-center border-t border-stone-100 dark:border-neutral-800">
<button
type="button"
<Button
variant="tertiary"
size="xs"
onClick={handleLoadMore}
className="text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline">
className="text-primary-600 dark:text-primary-400 hover:underline">
{t('mcp.catalog.loadMore')}
</button>
</Button>
</div>
)}
</div>
@@ -36,6 +36,7 @@ import {
import { useT } from '../../../lib/i18n/I18nContext';
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
import Button from '../../ui/Button';
import type { McpTool } from './types';
interface McpToolPlaygroundProps {
@@ -261,11 +262,13 @@ const McpToolPlayground = ({ serverId, tool, onClose }: McpToolPlaygroundProps)
</p>
)}
</div>
<button
type="button"
<Button
iconOnly
variant="tertiary"
size="sm"
onClick={onClose}
aria-label={t('mcp.playground.close')}
className="shrink-0 rounded-lg p-1.5 text-stone-400 hover:text-stone-700 dark:text-neutral-500 dark:hover:text-neutral-200 transition-colors">
className="shrink-0">
<svg
className="w-4 h-4"
fill="none"
@@ -275,7 +278,7 @@ const McpToolPlayground = ({ serverId, tool, onClose }: McpToolPlaygroundProps)
aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Button>
</div>
{/* Input schema (collapsible) */}
@@ -348,13 +351,9 @@ const McpToolPlayground = ({ serverId, tool, onClose }: McpToolPlaygroundProps)
{/* Run button */}
<div className="flex justify-end gap-2 mb-4">
<button
type="button"
onClick={() => void handleRun()}
disabled={isRunning}
className="rounded-lg bg-primary-500 px-4 py-1.5 text-xs font-semibold text-white hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed">
<Button variant="primary" size="sm" onClick={() => void handleRun()} disabled={isRunning}>
{isRunning ? t('mcp.playground.running') : t('mcp.playground.run')}
</button>
</Button>
</div>
{/* Result */}
+7 -5
View File
@@ -14,6 +14,7 @@ import {
upsertArtifactReadyForThread,
} from '../../store/chatRuntimeSlice';
import { useAppDispatch } from '../../store/hooks';
import Button from '../ui/Button';
/**
* Popover panel listing every `ready` artifact for a thread (#3024).
@@ -260,12 +261,13 @@ export default function ChatFilesPanel({ threadId, artifacts, onClose }: ChatFil
<span className="text-xs font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('chat.files.panel.title').replace('{count}', String(artifacts.length))}
</span>
<button
type="button"
<Button
iconOnly
variant="tertiary"
size="xs"
onClick={onClose}
data-analytics-id="chat-files-panel-close"
aria-label={t('chat.files.panel.close')}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-stone-100 dark:hover:bg-neutral-800 text-stone-500 dark:text-neutral-400">
aria-label={t('chat.files.panel.close')}>
<svg
className="w-3.5 h-3.5"
fill="none"
@@ -274,7 +276,7 @@ export default function ChatFilesPanel({ threadId, artifacts, onClose }: ChatFil
viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Button>
</header>
{artifacts.length === 0 ? (
@@ -45,6 +45,7 @@ import {
} from '../../lib/composio/types';
import { useT } from '../../lib/i18n/I18nContext';
import { openUrl } from '../../utils/openUrl';
import Button from '../ui/Button';
import type { ComposioToolkitMeta } from './toolkitMeta';
import {
getRequiredFieldsForToolkit,
@@ -592,10 +593,12 @@ export default function ComposioConnectModal({
{toolkit.description}
</p>
</div>
<button
type="button"
<Button
iconOnly
variant="tertiary"
size="sm"
onClick={onClose}
className="p-1 text-stone-400 dark:text-neutral-500 hover:text-stone-900 dark:hover:text-neutral-100 dark:text-neutral-100 dark:hover:text-neutral-100 transition-colors rounded-lg hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60 flex-shrink-0"
className="text-stone-400 dark:text-neutral-500 hover:text-stone-900 dark:hover:text-neutral-100 flex-shrink-0"
aria-label={t('common.close')}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -605,7 +608,7 @@ export default function ComposioConnectModal({
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Button>
</div>
</div>
@@ -639,13 +642,14 @@ export default function ComposioConnectModal({
}}
/>
{error && phase === 'idle' && <p className="text-[11px] text-coral-600">{error}</p>}
<button
type="button"
<Button
variant="primary"
size="lg"
disabled={connectInFlight}
onClick={() => void handleConnect()}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors disabled:opacity-60 disabled:cursor-not-allowed">
className="w-full">
{`${t('composio.connect.connect')} ${toolkit.name}`}
</button>
</Button>
</>
)}
@@ -670,23 +674,25 @@ export default function ComposioConnectModal({
}
}}
/>
<button
type="button"
<Button
variant="primary"
size="lg"
disabled={connectInFlight}
onClick={() => void handleConnect()}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors disabled:opacity-60 disabled:cursor-not-allowed">
className="w-full">
{t('composio.connect.retryConnection')}
</button>
<button
type="button"
</Button>
<Button
variant="secondary"
size="md"
onClick={() => {
setPhase('idle');
setFieldErrors({});
setError(null);
}}
className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-600 dark:text-neutral-300 text-xs font-medium py-2 hover:bg-stone-50 dark:hover:bg-neutral-800/60 transition-colors">
className="w-full">
{t('common.cancel')}
</button>
</Button>
</>
)}
@@ -703,12 +709,13 @@ export default function ComposioConnectModal({
{`${t('composio.connect.waitingFor')} ${toolkit.name} ${t('composio.connect.oauthComplete')}`}
</div>
{connectUrl && (
<button
type="button"
<Button
variant="secondary"
size="md"
onClick={() => void openUrl(connectUrl)}
className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 text-stone-700 dark:text-neutral-200 text-xs font-medium py-2 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-colors">
className="w-full">
{t('composio.connect.reopenBrowser')}
</button>
</Button>
)}
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('composio.connect.waitingHint')}
@@ -727,13 +734,14 @@ export default function ComposioConnectModal({
{t('composio.expiredDescription').replace('{name}', toolkit.name)}
</p>
</div>
<button
type="button"
<Button
variant="primary"
size="lg"
disabled={connectInFlight}
onClick={() => void handleConnect()}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors disabled:opacity-60 disabled:cursor-not-allowed">
className="w-full">
{`${t('composio.reconnect')} ${toolkit.name}`}
</button>
</Button>
</>
)}
@@ -775,12 +783,14 @@ export default function ComposioConnectModal({
</span>
)}
</div>
<button
type="button"
<Button
variant="tertiary"
tone="danger"
size="xs"
onClick={() => void handleDisconnect(conn)}
className="text-[11px] text-coral-600 hover:text-coral-700 font-medium shrink-0">
className="shrink-0">
{t('composio.connect.disconnectAccount')}
</button>
</Button>
</div>
))}
</div>
@@ -809,13 +819,14 @@ export default function ComposioConnectModal({
connectionId={activeConnection.id}
/>
)}
<button
type="button"
<Button
variant="secondary"
size="lg"
disabled={connectInFlight}
onClick={() => void handleConnect()}
className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-700 dark:text-neutral-200 text-sm font-medium py-2.5 hover:bg-stone-50 dark:hover:bg-neutral-800/60 transition-colors disabled:opacity-60 disabled:cursor-not-allowed">
className="w-full">
{t('composio.connect.addAnotherAccount')}
</button>
</Button>
<label className="flex items-start gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<input
type="checkbox"
@@ -833,18 +844,17 @@ export default function ComposioConnectModal({
</span>
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
<Button
variant="secondary"
tone="danger"
size="lg"
onClick={() => void handleDisconnect()}
className="w-full rounded-xl border border-coral-200 bg-coral-50 text-coral-700 text-sm font-medium py-2.5 hover:bg-coral-100 transition-colors">
className="w-full">
{t('skills.disconnect')}
</button>
<button
type="button"
onClick={onClose}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors">
</Button>
<Button variant="primary" size="lg" onClick={onClose} className="w-full">
{t('common.close')}
</button>
</Button>
</div>
</>
)}
@@ -860,8 +870,9 @@ export default function ComposioConnectModal({
<div className="rounded-xl border border-coral-200 bg-coral-50 p-3">
<p className="text-sm text-coral-700">{error ?? t('misc.somethingWentWrong')}</p>
</div>
<button
type="button"
<Button
variant="secondary"
size="md"
onClick={() => {
setClearMemoryOnDisconnect(false);
setPhase(
@@ -869,9 +880,9 @@ export default function ComposioConnectModal({
);
setError(null);
}}
className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-700 dark:text-neutral-200 text-sm font-medium py-2 hover:bg-stone-50 dark:hover:bg-neutral-800/60 transition-colors">
className="w-full">
{t('common.dismiss')}
</button>
</Button>
</>
)}
</div>
@@ -5,6 +5,7 @@ import { useUser } from '../../hooks/useUser';
import { useT } from '../../lib/i18n/I18nContext';
import { feedbackApi } from '../../services/api/feedbackApi';
import type { FeedbackComment } from '../../types/feedback';
import Button from '../ui/Button';
const log = debugFactory('feedback:comments');
@@ -126,13 +127,14 @@ export default function FeedbackComments({ feedbackId, onCommentAdded }: Feedbac
rows={2}
className="flex-1 resize-y rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm text-neutral-900 placeholder:text-neutral-400 transition-all focus:border-primary-500/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 dark:border-neutral-700 dark:bg-white/[0.03] dark:text-neutral-100 dark:placeholder:text-neutral-500"
/>
<button
type="button"
<Button
variant="primary"
size="md"
onClick={handlePost}
disabled={posting || !input.trim()}
className="whitespace-nowrap rounded-xl bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-600 active:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50">
className="whitespace-nowrap">
{posting ? '...' : t('feedback.comments.post')}
</button>
</Button>
</div>
{postError && <p className="text-xs text-coral-600 dark:text-coral-400">{postError}</p>}
</div>
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { feedbackApi } from '../../services/api/feedbackApi';
import type { CreateFeedbackResult, FeedbackType } from '../../types/feedback';
import Button from '../ui/Button';
const log = debugFactory('feedback:submit');
@@ -153,12 +154,9 @@ export default function FeedbackSubmitForm({ onAccepted }: FeedbackSubmitFormPro
/>
<div className="mt-3 flex items-center justify-between gap-3">
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="rounded-xl bg-primary-500 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-primary-600 active:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50">
<Button variant="primary" size="lg" onClick={handleSubmit} disabled={!canSubmit}>
{status === 'loading' ? '...' : t('feedback.submit.action')}
</button>
</Button>
<div className="flex items-center gap-3">
{message && <p className={`text-xs ${messageClass}`}>{message}</p>}
{body.length > 0 && (
@@ -29,6 +29,7 @@ import {
SOURCE_KIND_LABEL_KEYS,
type SourceKind,
} from '../../services/memorySourcesService';
import Button from '../ui/Button';
const log = debug('intelligence:add-memory-source-dialog');
@@ -224,13 +225,9 @@ export function AddMemorySourceDialog({ open, onClose, onAdded }: AddMemorySourc
))}
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={handleClose}
className="rounded-md px-4 py-2 text-sm text-stone-600 hover:text-stone-900
dark:text-neutral-400 dark:hover:text-neutral-100">
<Button variant="tertiary" size="md" onClick={handleClose}>
{t('common.cancel')}
</button>
</Button>
</div>
</>
) : (
@@ -279,32 +276,26 @@ export function AddMemorySourceDialog({ open, onClose, onAdded }: AddMemorySourc
)}
<div className="mt-5 flex items-center justify-between">
<button
type="button"
<Button
variant="tertiary"
size="sm"
onClick={() => {
setKind(null);
setError(null);
}}
className="text-sm text-stone-500 hover:text-stone-800 dark:text-neutral-400 dark:hover:text-neutral-200">
}}>
{t('memorySources.backToKinds')}
</button>
</Button>
<div className="flex gap-2">
<button
type="button"
onClick={handleClose}
className="rounded-md px-4 py-2 text-sm text-stone-600 hover:text-stone-900
dark:text-neutral-400 dark:hover:text-neutral-100">
<Button variant="tertiary" size="md" onClick={handleClose}>
{t('common.cancel')}
</button>
<button
type="button"
</Button>
<Button
variant="primary"
size="md"
onClick={handleSubmit}
disabled={!isValid || submitting}
className="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white
shadow-sm transition-colors hover:bg-primary-600
disabled:cursor-not-allowed disabled:opacity-50">
disabled={!isValid || submitting}>
{submitting ? t('memorySources.adding') : t('memorySources.add')}
</button>
</Button>
</div>
</div>
</>
@@ -4,6 +4,7 @@ import { LuBot, LuClipboard, LuPlay, LuRefreshCw, LuSend } from 'react-icons/lu'
import { useT } from '../../lib/i18n/I18nContext';
import { type AgentDefinitionDisplay, agentLibraryApi } from '../../services/api/agentLibraryApi';
import Button from '../ui/Button';
const log = debug('intelligence:agents-library');
@@ -124,13 +125,13 @@ export default function AgentsLibraryPanel({
{t('intelligence.agents.subtitle')}
</p>
</div>
<button
type="button"
<Button
variant="secondary"
size="sm"
onClick={() => void loadAgents()}
className="inline-flex items-center gap-1.5 rounded-md border border-stone-200 px-2.5 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
<LuRefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
leadingIcon={<LuRefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />}>
{t('intelligence.agents.refresh')}
</button>
</Button>
</div>
{loading && (
@@ -186,25 +187,25 @@ export default function AgentsLibraryPanel({
</div>
</div>
<div className="flex flex-none flex-wrap gap-1.5">
<button
type="button"
<Button
variant="secondary"
size="sm"
onClick={() =>
void handleRun(agent, t('intelligence.agents.startChatPrompt'))
}
disabled={Boolean(runningAgentId)}
className="inline-flex items-center gap-1.5 rounded-md border border-stone-200 px-2.5 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 disabled:opacity-50 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
<LuSend className="h-3.5 w-3.5" />
leadingIcon={<LuSend className="h-3.5 w-3.5" />}>
{t('intelligence.agents.startChat')}
</button>
<button
type="button"
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void handleCopy(agent.id)}
className="inline-flex items-center gap-1.5 rounded-md border border-stone-200 px-2.5 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
<LuClipboard className="h-3.5 w-3.5" />
leadingIcon={<LuClipboard className="h-3.5 w-3.5" />}>
{copiedId === agent.id
? t('intelligence.agents.copied')
: t('intelligence.agents.copyId')}
</button>
</Button>
</div>
</div>
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import type { ConfirmationModal as ConfirmationModalType } from '../../types/intelligence';
import Button from '../ui/Button';
interface ConfirmationModalProps {
modal: ConfirmationModalType;
@@ -84,23 +85,16 @@ export function ConfirmationModal({ modal, onClose }: ConfirmationModalProps) {
{/* Actions */}
<div className="flex items-center justify-end gap-3 p-6 pt-4 border-t border-stone-200 dark:border-neutral-800">
<button
onClick={handleCancel}
className="px-4 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:text-stone-900 dark:hover:text-neutral-100 dark:text-neutral-100 rounded-lg hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-colors">
<Button variant="tertiary" size="md" onClick={handleCancel}>
{modal.cancelText || t('common.cancel')}
</button>
<button
onClick={handleConfirm}
className={`
px-4 py-2 text-sm font-medium rounded-lg transition-colors
${
modal.destructive
? 'bg-coral-500 hover:bg-coral-600 text-white'
: 'bg-primary-500 hover:bg-primary-600 text-white'
}
`}>
</Button>
<Button
variant="primary"
size="md"
tone={modal.destructive ? 'danger' : 'default'}
onClick={handleConfirm}>
{modal.confirmText || t('common.confirm')}
</button>
</Button>
</div>
</div>
</div>
@@ -6,6 +6,7 @@ import { Fragment } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import type { ConnectionPathResult } from '../../lib/memory/connectionPath';
import Button from '../ui/Button';
interface ConnectionPathPanelProps {
result: ConnectionPathResult | null;
@@ -62,12 +63,9 @@ const ConnectionPathPanel = ({
{t('connectionPath.errorPrefix')} {error}
</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600">
<Button variant="primary" size="sm" onClick={onRetry} className="mt-2">
{t('connectionPath.retry')}
</button>
</Button>
)}
</div>
</div>
@@ -6,6 +6,7 @@ import {
type DiagramViewerSettings,
openhumanGetDashboardSettings,
} from '../../utils/tauriCommands/config';
import Button from '../ui/Button';
const DEFAULT_SETTINGS: DiagramViewerSettings = {
enabled: true,
@@ -100,14 +101,14 @@ export default function DiagramViewerTab() {
{t('intelligence.diagram.description')}
</p>
</div>
<button
type="button"
<Button
variant="secondary"
size="md"
onClick={refreshDiagram}
className="inline-flex items-center gap-2 rounded-md border border-stone-200 bg-white px-3 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
leadingIcon={<LuRefreshCw aria-hidden="true" className="h-4 w-4" />}
aria-label={t('intelligence.diagram.refreshAria')}>
<LuRefreshCw aria-hidden="true" className="h-4 w-4" />
{t('intelligence.diagram.refresh')}
</button>
</Button>
</div>
{showEmptyState && (
@@ -4,6 +4,7 @@
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { AssociationReport } from '../../lib/memory/entityAssociations';
import Button from '../ui/Button';
interface EntityAssociationsPanelProps {
report: AssociationReport | null;
@@ -68,12 +69,9 @@ const EntityAssociationsPanel = ({
{t('entityAssociations.errorPrefix')} {error}
</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600">
<Button variant="primary" size="sm" onClick={onRetry} className="mt-2">
{t('entityAssociations.retry')}
</button>
</Button>
)}
</div>
</div>
@@ -246,7 +246,7 @@ export default function GoalsPanel() {
/>
<Button
type="button"
variant="ghost"
variant="tertiary"
size="xs"
onClick={() => void saveEdit(goal.id)}
disabled={busyId === goal.id || !editText.trim()}
@@ -255,7 +255,7 @@ export default function GoalsPanel() {
</Button>
<Button
type="button"
variant="ghost"
variant="tertiary"
size="xs"
onClick={cancelEdit}
aria-label={t('common.cancel')}>
@@ -270,7 +270,7 @@ export default function GoalsPanel() {
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
variant="tertiary"
size="xs"
onClick={() => startEdit(goal)}
disabled={busyId === goal.id}
@@ -279,7 +279,8 @@ export default function GoalsPanel() {
</Button>
<Button
type="button"
variant="danger"
variant="secondary"
tone="danger"
size="xs"
onClick={() => void handleDelete(goal.id)}
disabled={busyId === goal.id}
@@ -7,6 +7,7 @@ import { useMemo } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { type CentralityResult, findBridges } from '../../lib/memory/graphCentrality';
import Button from '../ui/Button';
const MAX_ROWS = 25;
@@ -84,12 +85,9 @@ const GraphCentralityPanel = ({ result, loading, error, onRetry }: GraphCentrali
{t('graphCentrality.errorPrefix')} {error}
</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600">
<Button variant="primary" size="sm" onClick={onRetry} className="mt-2">
{t('graphCentrality.retry')}
</button>
</Button>
)}
</div>
</div>
@@ -8,6 +8,7 @@ import { useMemo } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { type CohesionResult, findBrokers } from '../../lib/memory/graphCohesion';
import Button from '../ui/Button';
const MAX_ROWS = 25;
@@ -69,12 +70,9 @@ const GraphCohesionPanel = ({ result, loading, error, onRetry }: GraphCohesionPa
{t('graphCohesion.errorPrefix')} {error}
</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600">
<Button variant="primary" size="sm" onClick={onRetry} className="mt-2">
{t('graphCohesion.retry')}
</button>
</Button>
)}
</div>
</div>
@@ -1,5 +1,6 @@
import { useT } from '../../lib/i18n/I18nContext';
import type { ActionableItem, ActionableItemSource, TimeGroup } from '../../types/intelligence';
import Button from '../ui/Button';
import { ActionableCard } from './ActionableCard';
interface IntelligenceMemoryTabProps {
@@ -133,12 +134,13 @@ export default function IntelligenceMemoryTab({
<p className="text-stone-400 dark:text-neutral-500 text-sm mb-4">
{t('memory.noAnalysisHint')}
</p>
<button
<Button
variant="primary"
size="md"
onClick={() => void handleAnalyzeNow()}
disabled={isRunning}
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-40 text-white text-sm rounded-lg transition-colors">
disabled={isRunning}>
{t('memory.analyzeNow')}
</button>
</Button>
</>
)}
</div>
@@ -28,6 +28,7 @@ import {
type WorkflowRun,
workflowRunsApi,
} from '../../services/api/workflowRunsApi';
import Button from '../ui/Button';
import { SAFETY_TIER_KEY, WorkflowRunApprovalCard } from './WorkflowRunApprovalCard';
import WorkflowRunDetail from './WorkflowRunDetail';
@@ -237,15 +238,15 @@ export default function IntelligenceOrchestrationTab() {
<div className="rounded-xl border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
{t('orchestration.failedToLoad')}: {error}
</div>
<button
type="button"
<Button
variant="secondary"
size="sm"
onClick={() => {
setLoading(true);
void load();
}}
className="rounded-lg border border-stone-300 bg-white px-3 py-1.5 text-xs font-medium text-stone-700 hover:bg-stone-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300">
}}>
{t('common.retry')}
</button>
</Button>
</div>
);
}
@@ -295,13 +296,14 @@ export default function IntelligenceOrchestrationTab() {
</p>
</div>
{!isStarting && (
<button
type="button"
<Button
variant="primary"
size="sm"
data-testid={`orchestration-start-${def.id}`}
onClick={() => beginStart(def)}
className="flex-none rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white shadow-soft hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500">
className="flex-none shadow-soft">
{t('orchestration.start')}
</button>
</Button>
)}
</div>
@@ -334,23 +336,24 @@ export default function IntelligenceOrchestrationTab() {
/>
) : (
<div className="flex items-center gap-2">
<button
type="button"
<Button
variant="primary"
size="sm"
data-testid="orchestration-confirm-start"
disabled={starting}
onClick={() => void doStart()}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white shadow-soft hover:bg-primary-600 disabled:opacity-50">
className="shadow-soft">
{starting
? t('orchestration.starting')
: t('orchestration.confirmStart')}
</button>
<button
type="button"
</Button>
<Button
variant="secondary"
size="sm"
onClick={cancelStart}
disabled={starting}
className="rounded-lg border border-stone-300 bg-white px-3 py-1.5 text-xs font-medium text-stone-700 hover:bg-stone-50 disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300">
disabled={starting}>
{t('orchestration.approval.cancel')}
</button>
</Button>
</div>
)}
</div>
@@ -5,6 +5,7 @@ import { useT } from '../../lib/i18n/I18nContext';
import type { SubconsciousMode } from '../../utils/tauriCommands/heartbeat';
import type { SubconsciousStatus } from '../../utils/tauriCommands/subconscious';
import { settingsNavState } from '../settings/modal/settingsOverlay';
import Button from '../ui/Button';
interface ModeOption {
id: SubconsciousMode;
@@ -205,11 +206,12 @@ export default function IntelligenceSubconsciousTab({
</>
)}
</div>
<button
<Button
variant="secondary"
size="sm"
onClick={() => void handleRunTick()}
disabled={triggering || providerUnavailable}
title={providerUnavailable ? t('subconscious.providerUnavailableTitle') : undefined}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 disabled:opacity-40 border border-stone-200 dark:border-neutral-800 rounded-lg text-stone-600 dark:text-neutral-300 transition-colors">
title={providerUnavailable ? t('subconscious.providerUnavailableTitle') : undefined}>
{triggering ? (
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
) : (
@@ -223,7 +225,7 @@ export default function IntelligenceSubconsciousTab({
</svg>
)}
{t('subconscious.runNow')}
</button>
</Button>
</div>
)}
@@ -49,6 +49,7 @@ import type { ThreadMessage } from '../../types/thread';
import type { TaskBoard, TaskBoardCard, TaskBoardCardStatus } from '../../types/turnState';
import { chatThreadPath } from '../../utils/chatRoutes';
import { settingsNavState } from '../settings/modal/settingsOverlay';
import Button from '../ui/Button';
import { UserTaskComposer } from './UserTaskComposer';
const log = debug('intelligence:tasks');
@@ -917,13 +918,15 @@ function TaskSourceRefinementDialog({
{t('intelligence.tasks.sourcePlan.subtitle')}
</p>
</div>
<button
type="button"
<Button
iconOnly
variant="tertiary"
size="sm"
aria-label={t('common.close')}
onClick={onClose}
className="flex h-8 w-8 flex-none items-center justify-center rounded-md text-stone-500 hover:bg-stone-100 dark:text-neutral-300 dark:hover:bg-neutral-800">
className="flex-none">
<LuX className="h-4 w-4" />
</button>
</Button>
</div>
<div className="max-h-[calc(90vh-8rem)] space-y-4 overflow-y-auto px-4 py-4">
@@ -995,13 +998,9 @@ function TaskSourceRefinementDialog({
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-stone-100 px-4 py-3 dark:border-neutral-800">
<button
type="button"
onClick={onClose}
disabled={saving}
className="rounded-md border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 disabled:opacity-40 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
<Button variant="secondary" size="sm" onClick={onClose} disabled={saving}>
{t('common.cancel')}
</button>
</Button>
<button
type="button"
onClick={() => void approve()}
@@ -32,6 +32,7 @@ import {
type TeamMessage,
type TeamView,
} from '../../services/api/agentTeamApi';
import Button from '../ui/Button';
import { TeamActivityRail } from './TeamActivityRail';
import { TeamHeader } from './TeamHeader';
import { TeamTaskBoard } from './TeamTaskBoard';
@@ -282,13 +283,14 @@ export default function IntelligenceTeamsTab() {
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
{teams.length > 1 ? (
<button
type="button"
<Button
variant="secondary"
size="xs"
onClick={deselect}
className="inline-flex items-center gap-1 rounded-md border border-stone-200 px-2 py-1 text-[11px] font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
<LuArrowLeft className="h-3 w-3" />
leadingIcon={<LuArrowLeft className="h-3 w-3" />}
className="gap-1 text-[11px]">
{t('intelligence.teams.backToList')}
</button>
</Button>
) : (
<span />
)}
@@ -377,13 +379,14 @@ function RefreshButton({
t: (key: string) => string;
}) {
return (
<button
type="button"
<Button
variant="secondary"
size="xs"
disabled={refreshing}
onClick={onClick}
className="inline-flex items-center gap-1 rounded-md border border-stone-200 px-2 py-1 text-[11px] font-medium text-stone-600 hover:bg-stone-50 disabled:opacity-40 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
<LuRefreshCw className={`h-3 w-3 ${refreshing ? 'animate-spin' : ''}`} />
leadingIcon={<LuRefreshCw className={`h-3 w-3 ${refreshing ? 'animate-spin' : ''}`} />}
className="gap-1 text-[11px]">
{t('intelligence.teams.refresh')}
</button>
</Button>
);
}
@@ -25,6 +25,7 @@ import {
memoryTreeResetTree,
memoryTreeWipeAll,
} from '../../utils/tauriCommands';
import ChipTabs from '../layout/ChipTabs';
import { ObsidianVaultSection } from './ObsidianVaultSection';
interface MemoryControlsProps {
@@ -212,36 +213,19 @@ interface ModeToggleProps {
function ModeToggle({ mode, onChange }: ModeToggleProps) {
const { t } = useT();
const baseBtn =
'px-3.5 py-1.5 text-xs font-semibold rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-200';
const active = 'bg-primary-500 text-white shadow-sm';
const idle =
'text-stone-600 hover:text-stone-900 dark:text-neutral-300 dark:hover:text-neutral-100';
return (
<div
className="inline-flex items-center gap-1 rounded-full border border-stone-200 bg-stone-50 p-1 dark:border-neutral-800 dark:bg-neutral-800/60"
role="tablist"
aria-label={t('workspace.graphViewMode')}
data-testid="memory-graph-mode-toggle">
<button
type="button"
onClick={() => onChange('tree')}
className={`${baseBtn} ${mode === 'tree' ? active : idle}`}
role="tab"
aria-selected={mode === 'tree'}
data-testid="memory-graph-mode-tree">
{t('workspace.trees')}
</button>
<button
type="button"
onClick={() => onChange('contacts')}
className={`${baseBtn} ${mode === 'contacts' ? active : idle}`}
role="tab"
aria-selected={mode === 'contacts'}
data-testid="memory-graph-mode-contacts">
{t('workspace.contacts')}
</button>
</div>
<ChipTabs<GraphMode>
items={[
{ id: 'tree', label: t('workspace.trees'), testId: 'memory-graph-mode-tree' },
{ id: 'contacts', label: t('workspace.contacts'), testId: 'memory-graph-mode-contacts' },
]}
value={mode}
onChange={onChange}
as="tab"
ariaLabel={t('workspace.graphViewMode')}
testId="memory-graph-mode-toggle"
className="inline-flex items-center gap-1.5"
/>
);
}
@@ -4,6 +4,7 @@
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { FactFreshness, FreshnessReport } from '../../lib/memory/memoryFreshness';
import Button from '../ui/Button';
const MAX_QUEUE_ROWS = 50;
@@ -77,12 +78,9 @@ const MemoryFreshnessPanel = ({ report, loading, error, onRetry }: MemoryFreshne
{t('memoryFreshness.errorPrefix')} {error}
</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600">
<Button variant="primary" size="sm" onClick={onRetry} className="mt-2">
{t('memoryFreshness.retry')}
</button>
</Button>
)}
</div>
</div>
@@ -42,6 +42,7 @@ import { useAppSelector } from '../../store/hooks';
import { resolveTheme, type ThemeMode } from '../../store/themeSlice';
import { type GraphEdge, type GraphMode, type GraphNode } from '../../utils/tauriCommands';
import { openWorkspacePath, previewWorkspaceText } from '../../utils/tauriCommands/workspacePaths';
import Button from '../ui/Button';
import {
CONTACT_COLOR,
LEAF_COLOR,
@@ -590,13 +591,14 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGr
{item.label}
</span>
))}
<button
type="button"
<Button
variant="secondary"
size="xs"
onClick={resetView}
data-testid="memory-graph-reset-view"
className="rounded-md border border-stone-200 bg-white px-2 py-1 text-[11px] font-medium text-stone-600 shadow-sm hover:bg-stone-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800">
className="text-[11px] shadow-sm">
{t('graph.resetView')}
</button>
</Button>
</div>
</div>
{useWebGL ? (
@@ -724,16 +726,17 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGr
<span className="ml-3 break-all font-mono text-stone-400 dark:text-neutral-500">
workspace:{hoveredSummaryPath}
</span>
<button
type="button"
<Button
variant="secondary"
size="xs"
data-testid={`memory-graph-preview-${hovered.id}`}
disabled={previewingPath === hoveredSummaryPath}
onClick={() => void previewSummary(hovered)}
className="ml-3 rounded-md border border-stone-200 bg-white px-2 py-1 text-[11px] font-medium text-stone-700 shadow-sm hover:bg-stone-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800">
className="ml-3 text-[11px] shadow-sm">
{previewingPath === hoveredSummaryPath
? t('migration.previewRunning')
: t('migration.previewAction')}
</button>
</Button>
</>
)}
</>
@@ -36,6 +36,7 @@ import {
openhumanUpdateMemorySyncSettings,
} from '../../utils/tauriCommands/config';
import { memoryTreeFlushSource } from '../../utils/tauriCommands/memoryTree';
import Button from '../ui/Button';
import { AddMemorySourceDialog } from './AddMemorySourceDialog';
import { ConfirmationModal } from './ConfirmationModal';
import { SourceSettingsPanel } from './SourceSettingsPanel';
@@ -514,15 +515,13 @@ export function MemorySourcesRegistry({
<AllInIcon />
{t('memorySources.allIn.button')}
</button>
<button
type="button"
<Button
variant="primary"
size="sm"
onClick={() => setDialogOpen(true)}
className="inline-flex items-center gap-1 rounded-md bg-primary-500 px-3 py-1.5
text-xs font-semibold text-white shadow-sm transition-colors
hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-200">
<PlusIcon />
leadingIcon={<PlusIcon />}>
{t('memorySources.addSource')}
</button>
</Button>
</div>
</header>
@@ -839,19 +838,16 @@ function SourceRow({
}`}>
<GearIcon />
</button>
<button
type="button"
<Button
variant="primary"
size="sm"
onClick={() => onSync(source)}
disabled={!source.enabled || isSyncing}
title={t('sync.sync')}
data-testid={`memory-source-sync-${source.toolkit ?? source.kind}`}
className="inline-flex items-center gap-1 rounded-md bg-primary-500 px-3 py-1.5
text-xs font-semibold text-white shadow-sm transition-colors
hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-primary-200">
{isSyncing ? <Spinner /> : <SyncIcon />}
leadingIcon={isSyncing ? <Spinner /> : <SyncIcon />}>
{isSyncing ? t('sync.syncing') : t('sync.sync')}
</button>
</Button>
<button
type="button"
onClick={() => onBuild(source)}
@@ -880,15 +876,16 @@ function SourceRow({
}`}
/>
</button>
<button
type="button"
<Button
iconOnly
variant="tertiary"
tone="danger"
size="xs"
onClick={() => onRemove(source)}
title={t('memorySources.remove')}
className="rounded p-1 text-stone-400 transition-colors hover:bg-coral-50
hover:text-coral-600 dark:text-neutral-500 dark:hover:bg-coral-500/10
dark:hover:text-coral-400">
aria-label={t('memorySources.remove')}>
<TrashIcon />
</button>
</Button>
</div>
</div>
{settingsExpanded && (
@@ -4,6 +4,7 @@
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { TimelineReport } from '../../lib/memory/memoryTimeline';
import Button from '../ui/Button';
const MAX_BARS = 24;
@@ -63,12 +64,9 @@ const MemoryTimelinePanel = ({ report, loading, error, onRetry }: MemoryTimeline
{t('memoryTimeline.errorPrefix')} {error}
</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600">
<Button variant="primary" size="sm" onClick={onRetry} className="mt-2">
{t('memoryTimeline.retry')}
</button>
</Button>
)}
</div>
</div>
@@ -30,6 +30,7 @@ import {
type MemoryTreePipelineStatus,
memoryTreeSetEnabled,
} from '../../utils/tauriCommands';
import Button from '../ui/Button';
/** Translator function shape exposed by `useT()`. */
type TFn = (key: string, fallback?: string) => string;
@@ -401,14 +402,15 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) {
className="flex items-center justify-between gap-3 rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-sm text-coral-700 dark:text-coral-300"
data-testid="memory-tree-status-error">
<span>{t('memoryTree.status.fetchError')}</span>
<button
type="button"
<Button
variant="secondary"
tone="danger"
size="xs"
onClick={() => {
void refresh();
}}
className="rounded-md border border-coral-300 dark:border-coral-500/40 bg-white dark:bg-neutral-900 px-2 py-1 text-xs font-medium text-coral-700 dark:text-coral-300 hover:bg-coral-50 dark:hover:bg-coral-500/20">
}}>
{t('memoryTree.status.retry')}
</button>
</Button>
</div>
) : null}
@@ -35,6 +35,7 @@ import {
} from '../../store/agentProfileSlice';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import type { AgentProfile } from '../../types/agentProfile';
import Button from '../ui/Button';
/** Matches the server-side MAX_COUNCIL_MEMBERS cap. */
const MAX_MEMBERS = 5;
@@ -301,12 +302,9 @@ const ModelPickerDialog = ({
{t('modelCouncil.modelPickerHelp')}
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-md px-2 py-1 text-xs font-semibold text-stone-500 hover:bg-stone-100 dark:text-neutral-400 dark:hover:bg-neutral-800">
<Button variant="tertiary" size="xs" onClick={onClose}>
{t('modelCouncil.closeModelPicker')}
</button>
</Button>
</div>
<div className="mt-4 space-y-2">
@@ -389,13 +387,13 @@ const ModelPickerDialog = ({
{providersError || modelsError}
</p>
)}
<button
type="button"
<Button
variant="primary"
onClick={saveProviderModel}
disabled={selectionMode !== 'custom' || !provider.trim() || !model.trim()}
className="w-full rounded-lg bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50">
className="w-full">
{t('modelCouncil.useProviderModel')}
</button>
</Button>
</div>
</div>
</div>
@@ -1044,13 +1042,12 @@ const ModelCouncilTab = () => {
{t('modelCouncil.listIntro')}
</p>
</div>
<button
type="button"
<Button
variant="primary"
onClick={handleCreateCouncil}
className="inline-flex items-center gap-2 rounded-lg bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-600">
<Icon name="plus" size={16} />
leadingIcon={<Icon name="plus" size={16} />}>
{t('modelCouncil.addCouncil')}
</button>
</Button>
</div>
{registryError && (
@@ -1083,26 +1080,32 @@ const ModelCouncilTab = () => {
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
<Button
iconOnly
variant="tertiary"
size="sm"
onClick={() => {
applyCouncilDefinition(council);
setView('edit');
}}
aria-label={t('modelCouncil.editCouncilAria').replace('{name}', council.name)}
className="rounded-md p-1.5 text-stone-500 hover:bg-stone-100 hover:text-stone-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100">
aria-label={t('modelCouncil.editCouncilAria').replace(
'{name}',
council.name
)}>
<Icon name="settings" size={16} />
</button>
<button
type="button"
</Button>
<Button
iconOnly
variant="tertiary"
tone="danger"
size="sm"
onClick={() => void handleDeleteCouncil(council)}
aria-label={t('modelCouncil.deleteCouncilAria').replace(
'{name}',
council.name
)}
className="rounded-md p-1.5 text-stone-500 hover:bg-coral-50 hover:text-coral-700 dark:text-neutral-400 dark:hover:bg-coral-500/10 dark:hover:text-coral-300">
)}>
<Icon name="trash" size={16} />
</button>
</Button>
</div>
</div>
<dl className="mt-4 grid grid-cols-2 gap-2 text-xs">
@@ -1141,13 +1144,14 @@ const ModelCouncilTab = () => {
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
<Button
iconOnly
variant="tertiary"
size="sm"
onClick={() => setView('list')}
aria-label={t('modelCouncil.backToCouncils')}
className="rounded-md p-1.5 text-stone-500 hover:bg-stone-100 hover:text-stone-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100">
aria-label={t('modelCouncil.backToCouncils')}>
<Icon name="arrow-left" size={18} />
</button>
</Button>
<div className="min-w-0">
<h2 className="truncate text-lg font-semibold text-stone-900 dark:text-neutral-50">
{view === 'edit' ? t('modelCouncil.editCouncil') : councilName}
@@ -1162,32 +1166,29 @@ const ModelCouncilTab = () => {
<div className="flex items-center gap-2">
{view === 'edit' ? (
<>
<button
type="button"
<Button
variant="secondary"
onClick={() => {
if (selectedCouncil) applyCouncilDefinition(selectedCouncil);
setView(selectedCouncil ? 'run' : 'list');
}}
className="rounded-lg border border-stone-200 px-3 py-2 text-sm font-semibold text-stone-700 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800">
}}>
{t('modelCouncil.cancelEdit')}
</button>
<button
type="button"
</Button>
<Button
variant="primary"
onClick={() => void saveCouncil()}
disabled={registrySaving}
className="rounded-lg bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50">
disabled={registrySaving}>
{registrySaving ? t('modelCouncil.savingCouncil') : t('modelCouncil.saveCouncil')}
</button>
</Button>
</>
) : (
<button
type="button"
<Button
variant="secondary"
onClick={() => setView('edit')}
aria-label={t('modelCouncil.editCurrentCouncil')}
className="inline-flex items-center gap-2 rounded-lg border border-stone-200 px-3 py-2 text-sm font-semibold text-stone-700 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800">
<Icon name="settings" size={16} />
leadingIcon={<Icon name="settings" size={16} />}>
{t('modelCouncil.editCouncil')}
</button>
</Button>
)}
</div>
</div>
@@ -1738,13 +1739,9 @@ const ModelCouncilTab = () => {
{view === 'run' && (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => void handleRun()}
disabled={!canRun}
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50">
<Button variant="primary" onClick={() => void handleRun()} disabled={!canRun}>
{running ? t('modelCouncil.running') : t('modelCouncil.run')}
</button>
</Button>
{running && (
<span
role="status"
@@ -5,6 +5,7 @@
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { NamespaceOverviewReport } from '../../lib/memory/namespaceOverview';
import Button from '../ui/Button';
const MAX_ROWS = 50;
@@ -69,12 +70,9 @@ const NamespaceOverviewPanel = ({
{t('namespaceOverview.errorPrefix')} {error}
</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600">
<Button variant="primary" size="sm" onClick={onRetry} className="mt-2">
{t('namespaceOverview.retry')}
</button>
</Button>
)}
</div>
</div>
@@ -29,6 +29,7 @@ import {
resolveWorkspaceAbsolutePath,
revealWorkspacePath,
} from '../../utils/tauriCommands/workspacePaths';
import Button from '../ui/Button';
import { MEMORY_CONTENT_WORKSPACE_PATH } from './memoryWorkspacePaths';
/** localStorage key for the optional Obsidian config-dir override. */
@@ -193,22 +194,16 @@ export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultS
return (
<div className="flex flex-col items-end gap-2" data-testid="obsidian-vault-section">
<button
type="button"
<Button
variant="secondary"
size="sm"
onClick={handleViewVault}
disabled={checking}
data-testid="memory-open-in-obsidian"
className="inline-flex items-center gap-1.5 rounded-lg
border border-stone-200 bg-white px-3 py-1.5 text-xs font-semibold
text-stone-700 shadow-sm transition-colors hover:bg-stone-50
disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-stone-200
dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200
dark:hover:bg-neutral-800 dark:focus:ring-neutral-700"
title={`obsidian://open?path=${contentRootAbs}`}>
<ExternalLinkIcon />
title={`obsidian://open?path=${contentRootAbs}`}
leadingIcon={<ExternalLinkIcon />}>
{checking ? t('workspace.checkingVault') : t('workspace.viewVault')}
</button>
</Button>
{expanded && (
<div
@@ -225,15 +220,9 @@ export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultS
</code>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={reveal}
data-testid="obsidian-reveal"
className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs font-semibold
text-neutral-700 hover:bg-neutral-50 dark:border-neutral-600
dark:bg-neutral-800 dark:text-neutral-200">
<Button variant="secondary" size="sm" onClick={reveal} data-testid="obsidian-reveal">
{t('workspace.revealFolder')}
</button>
</Button>
<button
type="button"
onClick={openAnyway}
@@ -243,15 +232,13 @@ export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultS
dark:bg-neutral-800 dark:text-violet-300">
{t('workspace.openAnyway')}
</button>
<button
type="button"
<Button
variant="secondary"
size="sm"
onClick={installObsidian}
data-testid="obsidian-install"
className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs font-semibold
text-neutral-700 hover:bg-neutral-50 dark:border-neutral-600
dark:bg-neutral-800 dark:text-neutral-200">
data-testid="obsidian-install">
{t('workspace.installObsidian')}
</button>
</Button>
</div>
<button
@@ -17,6 +17,7 @@ import {
type SourceKind,
updateMemorySource,
} from '../../services/memorySourcesService';
import Button from '../ui/Button';
// Which limit fields are relevant per kind. Order determines display order.
// Only caps that are actually enforced at sync time are surfaced — the
@@ -191,16 +192,9 @@ export function SourceSettingsPanel({
})}
</div>
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={() => void handleSave()}
disabled={saving}
className="inline-flex items-center gap-1 rounded-md bg-primary-600 px-3 py-1.5
text-xs font-semibold text-white shadow-sm transition-colors
hover:bg-primary-500 disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-primary-200">
<Button variant="primary" size="sm" onClick={() => void handleSave()} disabled={saving}>
{saving ? t('memorySources.settings.saving') : t('memorySources.settings.save')}
</button>
</Button>
</div>
</div>
);
@@ -6,6 +6,7 @@ import {
subconsciousTriggersStatus,
type SubconsciousTriggersStatus,
} from '../../utils/tauriCommands/subconscious';
import Button from '../ui/Button';
const cardClass =
'rounded-lg border border-stone-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900';
@@ -79,28 +80,24 @@ export default function SubconsciousTriggersPanel() {
</div>
<div className="flex items-center gap-2">
{status && (
<button
type="button"
<Button
variant={status.triggers_enabled ? 'secondary' : 'primary'}
size="xs"
onClick={() => void toggle()}
disabled={toggling}
data-testid="subconscious-triggers-toggle"
className={`rounded-md px-2.5 py-1 text-xs font-medium transition disabled:opacity-50 ${
status.triggers_enabled
? 'border border-stone-200 text-stone-600 hover:bg-stone-50 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800'
: 'bg-primary-500 text-white hover:bg-primary-600'
}`}>
data-testid="subconscious-triggers-toggle">
{status.triggers_enabled
? t('subconsciousTriggers.disable')
: t('subconsciousTriggers.enable')}
</button>
</Button>
)}
<button
type="button"
<Button
variant="secondary"
size="xs"
onClick={() => void refresh()}
data-testid="subconscious-triggers-refresh"
className="rounded-md border border-stone-200 px-2.5 py-1 text-xs text-stone-600 transition hover:bg-stone-50 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
data-testid="subconscious-triggers-refresh">
{t('common.refresh')}
</button>
</Button>
</div>
</div>
@@ -2,6 +2,7 @@ import { useCallback, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { updateMemorySource } from '../../services/memorySourcesService';
import Button from '../ui/Button';
interface SyncBudgetDialogProps {
source: {
@@ -121,17 +122,12 @@ export default function SyncBudgetDialog({ source, onClose, onSaved }: SyncBudge
{error && <p className="mt-3 text-xs text-coral-600">{error}</p>}
<div className="flex justify-end gap-2 mt-5">
<button
onClick={onClose}
className="px-3 py-1.5 rounded-md text-xs font-medium text-stone-600 dark:text-neutral-400 hover:bg-stone-100 dark:hover:bg-neutral-800">
<Button variant="tertiary" size="sm" onClick={onClose}>
{t('syncConfirm.cancel')}
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1.5 rounded-md bg-primary-600 hover:bg-primary-500 disabled:opacity-50 text-white text-xs font-medium">
</Button>
<Button variant="primary" size="sm" onClick={handleSave} disabled={saving}>
{saving ? t('autonomy.statusSaving') : t('common.save')}
</button>
</Button>
</div>
</div>
</div>
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../services/coreRpcClient';
import Button from '../ui/Button';
interface SyncEstimate {
item_count: number;
@@ -91,17 +92,12 @@ export default function SyncConfirmDialog({
)}
<div className="flex justify-end gap-2 mt-5">
<button
onClick={onCancel}
className="px-3 py-1.5 rounded-md text-xs font-medium text-stone-600 dark:text-neutral-400 hover:bg-stone-100 dark:hover:bg-neutral-800">
<Button variant="tertiary" size="sm" onClick={onCancel}>
{t('syncConfirm.cancel')}
</button>
<button
onClick={onConfirm}
disabled={!estimate}
className="px-3 py-1.5 rounded-md bg-primary-600 hover:bg-primary-500 disabled:opacity-50 text-white text-xs font-medium">
</Button>
<Button variant="primary" size="sm" onClick={onConfirm} disabled={!estimate}>
{t('syncConfirm.proceed')}
</button>
</Button>
</div>
</div>
</div>
@@ -14,6 +14,7 @@ import { useT } from '../../lib/i18n/I18nContext';
import { todosApi, USER_TASKS_THREAD_ID } from '../../services/api/todosApi';
import { useAppSelector } from '../../store/hooks';
import type { TaskBoard, TaskBoardCardStatus } from '../../types/turnState';
import Button from '../ui/Button';
const log = debug('intelligence:task-composer');
@@ -210,21 +211,14 @@ export function UserTaskComposer({ onCreated, onClose }: UserTaskComposerProps)
)}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="rounded-md border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
<Button variant="secondary" size="sm" onClick={onClose}>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700 disabled:opacity-50">
</Button>
<Button variant="primary" size="sm" onClick={handleSubmit} disabled={!canSubmit}>
{submitting
? t('intelligence.tasks.composer.creating')
: t('intelligence.tasks.composer.create')}
</button>
</Button>
</div>
</div>
</section>
@@ -4,6 +4,7 @@ import { useT } from '../../lib/i18n/I18nContext';
import type { ToastNotification } from '../../types/intelligence';
import { openUrl, revealPath } from '../../utils/openUrl';
import { memoryTreeVaultHealthCheck, type VaultHealthCheck } from '../../utils/tauriCommands';
import Button from '../ui/Button';
const OBSIDIAN_DOWNLOAD_URL = 'https://obsidian.md/download';
@@ -145,16 +146,16 @@ export function VaultHealthChecklist({ onToast, title }: VaultHealthChecklistPro
{t('vaultHealth.workspaceVault')} <code className="font-mono">memory_tree/content</code>
</p>
</div>
<button
type="button"
<Button
variant="secondary"
size="xs"
onClick={() => {
void runCheck();
}}
disabled={refreshing}
className="rounded-md border border-stone-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-2.5 py-1 text-xs font-medium text-stone-700 dark:text-neutral-200 disabled:opacity-60"
data-testid="vault-health-refresh">
{refreshing ? t('vaultHealth.refreshing') : t('vaultHealth.refresh')}
</button>
</Button>
</div>
{health?.content_root_abs ? (
@@ -166,14 +167,14 @@ export function VaultHealthChecklist({ onToast, title }: VaultHealthChecklistPro
) : null}
<div className="flex flex-wrap gap-2">
<button
type="button"
<Button
variant="secondary"
size="sm"
onClick={revealVault}
disabled={!health?.content_root_abs}
className="rounded-md border border-stone-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-1.5 text-xs font-semibold text-stone-700 dark:text-neutral-200 disabled:opacity-50"
data-testid="vault-health-reveal">
{t('vaultHealth.revealFolder')}
</button>
</Button>
<button
type="button"
onClick={openObsidian}
@@ -182,13 +183,13 @@ export function VaultHealthChecklist({ onToast, title }: VaultHealthChecklistPro
data-testid="vault-health-open-obsidian">
{t('vaultHealth.openInObsidian')}
</button>
<button
type="button"
<Button
variant="secondary"
size="sm"
onClick={installObsidian}
className="rounded-md border border-stone-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-1.5 text-xs font-semibold text-stone-700 dark:text-neutral-200"
data-testid="vault-health-install-obsidian">
{t('vaultHealth.installObsidian')}
</button>
</Button>
</div>
{loading ? (
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { whatsappListChats } from '../../utils/tauriCommands/memory';
import Button from '../ui/Button';
interface WhatsAppMemorySectionProps {
pollIntervalMs?: number;
@@ -61,18 +62,15 @@ export function WhatsAppMemorySection({ pollIntervalMs = 30000 }: WhatsAppMemory
{lastSyncTs !== null && <> · {relativeTime(lastSyncTs, t)}</>}
</span>
</div>
<button
type="button"
<Button
variant="secondary"
size="sm"
className="shrink-0"
onClick={() => void handleSync()}
disabled={syncing}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-stone-200 dark:border-neutral-800
bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs font-semibold text-stone-700 dark:text-neutral-200 shadow-sm
transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800/60
disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-primary-200">
<RefreshIcon spinning={syncing} />
leadingIcon={<RefreshIcon spinning={syncing} />}>
{syncing ? t('sync.syncing') : t('sync.sync')}
</button>
</Button>
</div>
</div>
);
@@ -25,6 +25,7 @@ import {
type WorkflowRun,
type WorkflowRunStatus,
} from '../../services/api/workflowRunsApi';
import Button from '../ui/Button';
const log = debug('intelligence:workflow-detail');
@@ -122,17 +123,18 @@ export const WorkflowRunDetail: React.FC<Props> = ({
<div className="flex items-center gap-2">
{isRunning && (
<button
type="button"
<Button
variant="secondary"
tone="danger"
size="sm"
data-testid="workflow-run-stop"
disabled={busy}
onClick={() => {
log('stop id=%s', run.id);
onStop(run.id);
}}
className="rounded-lg border border-coral-300 bg-white px-3 py-1.5 text-xs font-medium text-coral-700 hover:bg-coral-50 disabled:opacity-50 dark:border-coral-700 dark:bg-neutral-900 dark:text-coral-300 dark:hover:bg-coral-900/40">
}}>
{t('orchestration.detail.stop')}
</button>
</Button>
)}
{canResume && (
<button
@@ -28,6 +28,7 @@ import CreateSkillModal from '../skills/CreateSkillModal';
import UnifiedSkillCard from '../skills/SkillCard';
import { BUILT_IN_SKILL_ICONS } from '../skills/skillIcons';
import UninstallSkillConfirmDialog from '../skills/UninstallSkillConfirmDialog';
import Button from '../ui/Button';
import { ToastContainer } from './Toast';
const log = debug('intelligence:workflows');
@@ -94,13 +95,13 @@ export default function WorkflowsTab({ asSettingsPanel = false }: WorkflowsTabPr
const isEmpty = workflows.length === 0 && !loading && !loadError;
const newWorkflowButton = (
<button
type="button"
<Button
variant="primary"
size="sm"
data-testid="workflows-create-btn"
onClick={() => setCreateModalOpen(true)}
className="rounded-lg bg-primary-500 px-3 py-2 text-xs font-semibold text-white shadow-soft transition-colors hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1">
onClick={() => setCreateModalOpen(true)}>
{t('workflows.createNew')}
</button>
</Button>
);
const body = (
@@ -117,15 +118,17 @@ export default function WorkflowsTab({ asSettingsPanel = false }: WorkflowsTabPr
<p className="mt-1 break-words font-mono text-[11px] text-coral-700/90 dark:text-coral-300/90">
{loadError}
</p>
<button
type="button"
<Button
variant="secondary"
tone="danger"
size="sm"
className="mt-3"
onClick={() => {
setLoading(true);
void refresh();
}}
className="mt-3 rounded-lg border border-coral-300 dark:border-coral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs font-medium text-coral-700 dark:text-coral-300 hover:bg-coral-50 dark:hover:bg-coral-900/40">
}}>
{t('common.retry')}
</button>
</Button>
</div>
) : null}
@@ -147,12 +150,13 @@ export default function WorkflowsTab({ asSettingsPanel = false }: WorkflowsTabPr
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('workflows.empty.body')}
</p>
<button
type="button"
onClick={() => setCreateModalOpen(true)}
className="mt-4 rounded-lg bg-primary-500 px-4 py-2 text-xs font-semibold text-white shadow-soft hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500">
<Button
variant="primary"
size="md"
className="mt-4"
onClick={() => setCreateModalOpen(true)}>
{t('workflows.createNew')}
</button>
</Button>
</div>
) : null}
+1 -1
View File
@@ -24,7 +24,7 @@ interface TwoPaneNavProps {
/**
* Vertical, grouped tab navigation for the sidebar pane of a
* {@link TwoPanelLayout} the left-rail counterpart to a horizontal
* PillTabBar, styled to match the settings sidebar (title header, labelled
* ChipTabs row, styled to match the settings sidebar (title header, labelled
* sub-groups, icon + label rows). The list scrolls independently below the
* optional fixed header.
*/
@@ -9,6 +9,7 @@ import {
markRead,
type NotificationItem,
} from '../../store/notificationSlice';
import Button from '../ui/Button';
import NotificationBody from './NotificationBody';
// Namespaced debug per project logging rules (mirrors nativeNotifications).
@@ -128,20 +129,16 @@ const CoreNotificationCard = ({ notification: n }: Props) => {
const label = labelKey ? t(labelKey) : action.label;
const primary = isPrimaryAction(action.actionId);
return (
<button
<Button
key={action.actionId}
type="button"
variant={primary ? 'primary' : 'secondary'}
size="xs"
disabled={pendingActionId !== null}
onClick={() => {
void handleAction(action.actionId, action.payload);
}}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
primary
? 'bg-primary-500 text-white hover:bg-primary-600'
: 'bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 hover:bg-stone-200 dark:hover:bg-neutral-800/60'
}`}>
}}>
{label}
</button>
</Button>
);
})}
</div>
@@ -1,5 +1,6 @@
import { useT } from '../../lib/i18n/I18nContext';
import type { IntegrationNotification } from '../../types/notifications';
import Button from '../ui/Button';
import NotificationBody from './NotificationBody';
// ─────────────────────────────────────────────────────────────────────────────
@@ -144,9 +145,12 @@ const NotificationCard = ({ notification: n, onMarkRead, onNavigate, onDismiss }
)}
</div>
{onDismiss && (
<button
<Button
iconOnly
variant="tertiary"
size="xs"
onClick={() => onDismiss(n.id)}
className="mt-0.5 ml-1 flex-shrink-0 p-0.5 rounded hover:bg-stone-200 dark:hover:bg-neutral-800/60 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 transition-colors"
className="mt-0.5 ml-1 flex-shrink-0 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300"
aria-label={t('notifications.card.dismiss')}>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -156,7 +160,7 @@ const NotificationCard = ({ notification: n, onMarkRead, onNavigate, onDismiss }
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Button>
)}
</div>
</div>
@@ -18,6 +18,7 @@ import {
setIntegrationLoading,
setIntegrationNotifications,
} from '../../store/notificationSlice';
import Button from '../ui/Button';
import CoreNotificationCard from './CoreNotificationCard';
import NotificationCard from './NotificationCard';
@@ -139,13 +140,15 @@ const NotificationCenter = () => {
)}
</div>
{filteredUnreadCount > 0 && (
<button
<Button
variant="tertiary"
size="xs"
onClick={() => {
void handleMarkAllRead();
}}
className="text-xs text-primary-600 hover:text-primary-700 font-medium transition-colors">
className="text-primary-600 hover:text-primary-700">
{t('notifications.center.markAllRead')}
</button>
</Button>
)}
</div>
@@ -6,6 +6,7 @@ import { useCoreState } from '../../providers/CoreStateProvider';
import { referralApi } from '../../services/api/referralApi';
import type { ReferralRelationshipStatus, ReferralStats } from '../../types/referral';
import { LATEST_APP_DOWNLOAD_URL } from '../../utils/config';
import Button from '../ui/Button';
function formatUsd(n: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
@@ -183,12 +184,13 @@ const ReferralRewardsSection = () => {
{loadError ? (
<div className="rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-sm text-coral-800 dark:text-coral-200">
{loadError}
<button
type="button"
<Button
variant="tertiary"
size="xs"
onClick={() => void loadStats()}
className="ml-2 underline font-medium">
className="ml-2 underline">
{t('rewards.referralSection.retry')}
</button>
</Button>
</div>
) : null}
@@ -272,15 +274,15 @@ const ReferralRewardsSection = () => {
disabled={applyLoading}
className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 font-mono text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
/>
<button
type="button"
<Button
variant="primary"
size="md"
onClick={() => void handleApply()}
disabled={applyLoading || !applyCode.trim()}
className="rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50">
disabled={applyLoading || !applyCode.trim()}>
{applyLoading
? t('rewards.referralSection.applying')
: t('rewards.referralSection.apply')}
</button>
</Button>
</div>
{applyError ? (
<p className="text-xs text-coral-600 dark:text-coral-300">{applyError}</p>
@@ -8,6 +8,7 @@ import type { RewardsAchievement, RewardsSnapshot } from '../../types/rewards';
import { DISCORD_INVITE_URL } from '../../utils/links';
import { setOAuthReturnRoute } from '../../utils/oauthReturnRoute';
import { openUrl } from '../../utils/openUrl';
import Button from '../ui/Button';
const log = createDebug('rewards:discord');
@@ -360,15 +361,16 @@ export default function RewardsCommunityTab({
{t('rewards.community.roleClaimDesc')}
</p>
</div>
<button
type="button"
<Button
variant="primary"
size="md"
data-testid="rewards-claim-roles-join"
onClick={() => {
void openUrl(inviteUrl);
}}
className="inline-flex flex-shrink-0 items-center justify-center gap-2 rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-primary-700">
className="flex-shrink-0">
{t('rewards.community.joinDiscord')}
</button>
</Button>
</div>
) : null}
{isLoading ? (
@@ -5,6 +5,7 @@ import { useUser } from '../../hooks/useUser';
import { useT } from '../../lib/i18n/I18nContext';
import { useCoreState } from '../../providers/CoreStateProvider';
import { type CreditBalance, creditsApi, type RedeemedCoupon } from '../../services/api/creditsApi';
import Button from '../ui/Button';
const log = createDebug('openhuman:rewards-coupons');
@@ -188,13 +189,13 @@ const RewardsCouponSection = () => {
disabled={submitLoading}
className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 font-mono text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
/>
<button
type="button"
<Button
variant="primary"
size="md"
onClick={() => void handleRedeem()}
disabled={submitLoading || !couponCode.trim()}
className="rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-primary-700 disabled:opacity-50">
disabled={submitLoading || !couponCode.trim()}>
{submitLoading ? t('rewards.coupon.redeeming') : t('rewards.coupon.redeemButton')}
</button>
</Button>
</div>
{submitSuccess ? (
<div className="rounded-xl border border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10 px-3 py-2 text-sm text-sage-800 dark:text-sage-200">
@@ -209,12 +210,13 @@ const RewardsCouponSection = () => {
{loadError ? (
<div className="rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-sm text-coral-800 dark:text-coral-200">
{loadError}
<button
type="button"
<Button
variant="tertiary"
size="xs"
onClick={() => void loadCouponState()}
className="ml-2 font-medium underline">
className="ml-2 underline">
{t('common.retry')}
</button>
</Button>
</div>
) : null}
</div>
@@ -225,13 +227,14 @@ const RewardsCouponSection = () => {
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('rewards.coupon.recentRedemptions')}
</h3>
<button
type="button"
<Button
variant="tertiary"
size="xs"
onClick={() => void loadCouponState()}
disabled={loading}
className="text-xs font-medium text-stone-500 dark:text-neutral-400 transition-colors hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 dark:hover:text-neutral-200 dark:text-neutral-200 disabled:opacity-50">
className="text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200">
{t('common.refresh')}
</button>
</Button>
</div>
{loading && redeemedCoupons.length === 0 ? (
@@ -4,6 +4,7 @@ import { useId, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { useCoreState } from '../../providers/CoreStateProvider';
import { clearAllAppData } from '../../utils/clearAllAppData';
import Button from '../ui/Button';
import SettingsMenuItem from './components/SettingsMenuItem';
const warnLog = debug('settings:account:warn');
@@ -145,15 +146,16 @@ const LogoutAndClearActions = () => {
</div>
<div className="flex gap-3">
<button
<Button
variant="secondary"
onClick={() => {
setShowLogoutAndClearModal(false);
setError(null);
}}
disabled={isLoading}
className="flex-1 px-4 py-2 rounded-lg border border-stone-200 dark:border-neutral-800 text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 transition-colors disabled:opacity-50">
className="flex-1">
{t('common.cancel')}
</button>
</Button>
<button
onClick={handleLogoutAndClearData}
disabled={isLoading}
@@ -35,7 +35,7 @@ const SettingsListItem = ({
{onRemove && (
<Button
type="button"
variant="ghost"
variant="tertiary"
size="xs"
onClick={onRemove}
aria-label={removeLabel}
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { useEscapeKey } from '../../../hooks/useEscapeKey';
import { useT } from '../../../lib/i18n/I18nContext';
import Button from '../../ui/Button';
import { CloseIcon } from '../../ui/icons';
interface SettingsModalFrameProps {
@@ -53,14 +54,16 @@ export function SettingsModalFrame({ onClose, children, labelledBy }: SettingsMo
<div
className="relative mx-4 flex h-[80vh] w-full max-w-5xl"
onClick={event => event.stopPropagation()}>
<button
type="button"
<Button
variant="secondary"
iconOnly
size="sm"
aria-label={t('common.close')}
data-testid="settings-modal-close"
onClick={onClose}
className="absolute bottom-full right-0 mb-2 flex h-8 w-8 items-center justify-center rounded-full border border-stone-200 bg-white text-stone-500 shadow-md transition-colors hover:bg-stone-100 hover:text-stone-700 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-200">
className="absolute bottom-full right-0 mb-2 h-8 w-8 rounded-full text-stone-500 shadow-md hover:text-stone-700 dark:text-neutral-400 dark:hover:text-neutral-200">
<CloseIcon className="h-4 w-4" />
</button>
</Button>
<div
ref={dialogRef}
role="dialog"
+19 -13
View File
@@ -2210,7 +2210,7 @@ const CustomRoutingDialog = ({
</div>
<Button
type="button"
variant="ghost"
variant="tertiary"
size="xs"
onClick={onClose}
aria-label={t('common.close')}>
@@ -2317,12 +2317,13 @@ const CustomRoutingDialog = ({
{cloudModelsError}
</div>
<div className="flex items-center gap-2">
<button
<Button
type="button"
onClick={() => setModelsKey(k => k + 1)}
className="text-xs text-primary-600 dark:text-primary-400 hover:underline">
variant="tertiary"
size="xs"
onClick={() => setModelsKey(k => k + 1)}>
{t('common.retry')}
</button>
</Button>
<span className="text-xs text-neutral-400 dark:text-neutral-500">
{t('settings.ai.enterModelIdManually')}
</span>
@@ -3251,17 +3252,19 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
className={`inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-medium ring-1 transition-colors ${tone}`}>
<span>{label}</span>
{enabled && (
<button
<Button
type="button"
iconOnly
variant="tertiary"
size="xs"
aria-label={t('settings.ai.editEndpoint')}
title={t('settings.ai.editEndpoint')}
onClick={() => {
setKeyDialogFor(localKind);
setPendingLocalLabel(label);
}}
className="rounded p-0.5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors">
}}>
<LuPencil className="h-3 w-3" />
</button>
</Button>
)}
<SettingsSwitch
id={`local-runtime-toggle-${localKind}`}
@@ -3830,11 +3833,14 @@ const CloudProviderEditor = ({
<label className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
<span>{t('settings.ai.apiKeyFieldLabel')}</span>
{hasExistingKey && (
<button
onClick={() => void onClearKey(slug)}
className="text-[10px] font-medium normal-case text-coral-600 dark:text-coral-300 hover:text-coral-700">
<Button
variant="tertiary"
tone="danger"
size="xs"
className="text-[10px] font-medium normal-case"
onClick={() => void onClearKey(slug)}>
{t('settings.ai.clearStoredKey')}
</button>
</Button>
)}
</label>
<SettingsTextField
@@ -534,13 +534,15 @@ function ToolsPickerModal({
)}
</p>
</div>
<button
<Button
iconOnly
variant="tertiary"
size="xs"
type="button"
aria-label={t('common.close')}
onClick={onClose}
className="rounded-full p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600 dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-300">
onClick={onClose}>
<LuX className="h-4 w-4" />
</button>
</Button>
</div>
<div className="border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
@@ -198,12 +198,18 @@ function AgentRow({
{/* Built-in agents can't be edited only custom agents expose Edit.
Built-ins keep the toggle (enable/disable) and Reset (clear override). */}
{isCustom && (
<Button type="button" variant="ghost" size="xs" onClick={onEdit}>
<Button type="button" variant="tertiary" size="xs" onClick={onEdit}>
<LuPencil className="h-3 w-3 mr-1" />
{t('settings.agents.edit')}
</Button>
)}
<Button type="button" variant="danger" size="xs" disabled={busy} onClick={onRemove}>
<Button
type="button"
variant="secondary"
tone="danger"
size="xs"
disabled={busy}
onClick={onRemove}>
{isCustom ? (
<LuTrash2 className="h-3 w-3 mr-1" />
) : (
@@ -9,8 +9,8 @@ import GraphCohesionTab from '../../intelligence/GraphCohesionTab';
import MemoryFreshnessTab from '../../intelligence/MemoryFreshnessTab';
import MemoryTimelineTab from '../../intelligence/MemoryTimelineTab';
import NamespaceOverviewTab from '../../intelligence/NamespaceOverviewTab';
import ChipTabs from '../../layout/ChipTabs';
import PanelPage from '../../layout/PanelPage';
import PillTabBar from '../../PillTabBar';
import SettingsBackButton from '../components/SettingsBackButton';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -55,11 +55,11 @@ const AnalysisViewsPanel = () => {
description={t('settings.analysisViews.menuDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 space-y-4">
<PillTabBar
items={views.map(view => ({ label: view.label, value: view.id }))}
selected={activeView}
<ChipTabs<AnalysisView>
items={views.map(view => ({ id: view.id, label: view.label }))}
value={activeView}
onChange={setActiveView}
containerClassName="flex flex-wrap gap-2 pb-1"
className="flex flex-wrap gap-2 pb-1"
/>
{activeView === 'diagram' && <DiagramViewerTab />}
@@ -114,7 +114,7 @@ const ApprovalHistoryPanel = () => {
<SettingsStatusLine saving={false} error={error} savingLabel="" />
<Button
type="button"
variant="ghost"
variant="tertiary"
size="xs"
onClick={handleRefresh}
className="text-primary-600 dark:text-primary-400">
@@ -540,7 +540,8 @@ const AutocompleteDebugPanel = () => {
</Button>
<Button
type="button"
variant="danger"
variant="secondary"
tone="danger"
size="sm"
onClick={() => void stop()}
disabled={!status?.running}>
@@ -700,7 +701,8 @@ const AutocompleteDebugPanel = () => {
<div className="flex justify-end">
<Button
type="button"
variant="danger"
variant="secondary"
tone="danger"
size="xs"
onClick={() => void clearHistory()}
disabled={isClearingHistory || historyEntries.length === 0}>
@@ -358,7 +358,8 @@ const AutocompletePanel = () => {
</Button>
<Button
type="button"
variant="danger"
variant="secondary"
tone="danger"
size="sm"
onClick={() => void stop()}
disabled={!status?.running}>
@@ -376,15 +377,18 @@ const AutocompletePanel = () => {
/>
{/* ── Advanced link ────────────────────────────────────────────── */}
<button
<Button
type="button"
variant="tertiary"
size="xs"
onClick={() => navigateToSettings('autocomplete-debug')}
className="flex items-center gap-1.5 text-xs text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors">
trailingIcon={
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
}>
{t('autocomplete.advancedSettings')}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</Button>
</SettingsPanel>
);
};
@@ -139,7 +139,7 @@ const AutonomyRateLimitSection = () => {
<Button
key={p.value}
type="button"
variant="ghost"
variant="tertiary"
size="xs"
onClick={() => applyPreset(p.value)}>
{p.labelKey ? t(p.labelKey) : p.label}
@@ -113,7 +113,8 @@ const CompanionPanel = () => {
sessionActive ? (
<Button
type="button"
variant="danger"
variant="secondary"
tone="danger"
size="sm"
onClick={handleStop}
disabled={isStopping}>
@@ -16,6 +16,7 @@ import {
openhumanUpdateVoiceServerSettings,
syncNotchVisibility,
} from '../../../utils/tauriCommands';
import Button from '../../ui/Button';
import {
SettingsBadge,
type SettingsBadgeVariant,
@@ -267,16 +268,16 @@ const DesktopAgentPanel = () => {
{t('settings.desktopAgent.notRequiredOnOs')}
</span>
) : canGrant ? (
<button
type="button"
<Button
variant="secondary"
size="sm"
data-testid={`desktop-agent-grant-${kind}`}
onClick={() => void grant(kind)}
disabled={busy}
className="rounded-lg border border-primary-400 bg-primary-50 dark:bg-primary-500/10 px-3 py-1.5 text-xs text-primary-700 dark:text-primary-300 disabled:opacity-50">
disabled={busy}>
{requestingKind === kind
? t('settings.screenIntel.permissions.requesting')
: t('settings.desktopAgent.grant')}
</button>
</Button>
) : null}
</div>
}
@@ -380,16 +381,16 @@ const DesktopAgentPanel = () => {
) : null}
<div className="flex flex-wrap gap-2">
<button
type="button"
<Button
variant="secondary"
size="md"
data-testid="desktop-agent-recheck"
onClick={() => void refresh()}
disabled={busy}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2 text-sm text-stone-700 dark:text-neutral-200 disabled:opacity-50">
disabled={busy}>
{isLoading
? t('settings.screenIntel.permissions.refreshing')
: t('settings.desktopAgent.recheck')}
</button>
</Button>
{actionable.length > 0 ? (
<button
type="button"
@@ -596,7 +596,12 @@ const DevWorkflowPanel = () => {
disabled={running}>
{running ? t('settings.devWorkflow.running') : t('settings.devWorkflow.runNow')}
</Button>
<Button type="button" variant="danger" size="xs" onClick={() => void handleRemove()}>
<Button
type="button"
variant="secondary"
tone="danger"
size="xs"
onClick={() => void handleRemove()}>
{t('settings.devWorkflow.remove')}
</Button>
</div>
@@ -144,7 +144,8 @@ function ConfirmRevokeDialog({
</Button>
<Button
type="button"
variant="danger"
variant="primary"
tone="danger"
size="md"
onClick={onConfirm}
className="flex-1"
@@ -545,7 +545,8 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
{currentEntry?.requires_api_key && currentEntry.has_api_key && (
<Button
type="button"
variant="danger"
variant="secondary"
tone="danger"
size="xs"
onClick={() => void handleClearKey()}>
{t('settings.embeddings.clearKey')}
@@ -734,7 +735,7 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
variant="tertiary"
size="xs"
onClick={() => setSetupProvider(null)}>
{t('settings.embeddings.cancel')}
@@ -778,10 +779,19 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
{t('settings.embeddings.wipeBody')}
</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="xs" onClick={() => setPendingWipe(null)}>
<Button
type="button"
variant="tertiary"
size="xs"
onClick={() => setPendingWipe(null)}>
{t('settings.embeddings.cancel')}
</Button>
<Button type="button" variant="danger" size="xs" onClick={() => void confirmWipe()}>
<Button
type="button"
variant="primary"
tone="danger"
size="xs"
onClick={() => void confirmWipe()}>
{t('settings.embeddings.confirmWipe')}
</Button>
</div>
@@ -257,7 +257,7 @@ const EventLogPanel = () => {
{!autoScroll && (
<Button
type="button"
variant="ghost"
variant="tertiary"
size="xs"
onClick={() => {
setAutoScroll(true);
@@ -242,7 +242,7 @@ const McpServerPanel = ({ embedded = false }: McpServerPanelProps = {}) => {
{isTauri() && (
<Button
type="button"
variant="ghost"
variant="tertiary"
size="xs"
onClick={() => void handleOpenConfig()}>
{t('settings.mcpServer.openConfigFile')}
@@ -249,7 +249,7 @@ const MemoryDebugPanel = () => {
</div>
<Button
type="button"
variant="ghost"
variant="tertiary"
size="xs"
disabled={Boolean(deleteLoadingId)}
onClick={() => void handleDelete(doc)}>
@@ -421,7 +421,8 @@ const MemoryDebugPanel = () => {
)}
<Button
type="button"
variant="danger"
variant="secondary"
tone="danger"
size="xs"
onClick={() => void handleClearNamespace()}
disabled={clearLoading || !clearNamespaceInput.trim()}>
@@ -188,7 +188,7 @@ const MigrationPanel = ({ embedded = false }: MigrationPanelProps = {}) => {
</Button>
<Button
type="button"
variant="ghost"
variant="tertiary"
size="sm"
data-testid="migration-apply-button"
onClick={() => void runApply()}
@@ -271,7 +271,7 @@ const ModelHealthPanel = () => {
{isReplace && candidates.length > 0 && (
<Button
type="button"
variant="ghost"
variant="tertiary"
size="xs"
className="ml-1 text-amber-400 hover:text-amber-300"
onClick={() => setSwapTarget(m)}>
@@ -317,13 +317,14 @@ const PermissionsPanel = () => {
{agentPaths?.action_dir ?? '~/OpenHuman/projects'}
</p>
{!actionDirEnvLocked && (
<button
type="button"
className="text-xs font-medium text-ocean hover:underline"
<Button
variant="tertiary"
size="xs"
className="text-ocean hover:underline"
onClick={startEditActionDir}
data-testid="permissions-action-dir-edit">
{t('settings.agentAccess.actionDir.edit')}
</button>
</Button>
)}
</div>
)}
@@ -153,7 +153,7 @@ const ProfilesPanel = () => {
{!profile.builtIn && (
<Button
type="button"
variant="ghost"
variant="tertiary"
size="sm"
onClick={() => void remove(profile.id)}>
{t('common.delete')}

Some files were not shown because too many files have changed in this diff Show More