mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(ui): consolidate buttons — 3 variants (primary/secondary/tertiary) + danger tone (#4057)
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user