mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat: prod-test 2026-05-23 — composer, settings reorg, MCP/search, alpha banners (#2554)
This commit is contained in:
@@ -137,6 +137,11 @@ const BottomTabBar = () => {
|
||||
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
|
||||
const unreadCount = useAppSelector(state => selectUnreadCount(state.notifications.items));
|
||||
const companionActive = useAppSelector(selectCompanionSessionActive);
|
||||
// `state.theme` is undefined in some test fixtures that build a minimal
|
||||
// store without the theme slice; default to the historical 'hover' behavior
|
||||
// so an absent theme branch can't crash the bar.
|
||||
const tabBarLabels = useAppSelector(state => state.theme?.tabBarLabels ?? 'hover');
|
||||
const labelsAlwaysVisible = tabBarLabels === 'always';
|
||||
|
||||
const hiddenPaths = ['/', '/login'];
|
||||
if (
|
||||
@@ -233,7 +238,7 @@ const BottomTabBar = () => {
|
||||
</span>
|
||||
<span
|
||||
className={`overflow-hidden whitespace-nowrap transition-[max-width,margin-left,opacity] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${
|
||||
active
|
||||
active || labelsAlwaysVisible
|
||||
? 'max-w-[160px] ml-2 opacity-100'
|
||||
: 'max-w-0 ml-0 opacity-0 group-hover:max-w-[160px] group-hover:ml-2 group-hover:opacity-100 group-focus-visible:max-w-[160px] group-focus-visible:ml-2 group-focus-visible:opacity-100'
|
||||
}`}>
|
||||
|
||||
@@ -168,6 +168,19 @@ describe('InstalledServerDetail', () => {
|
||||
await waitFor(() => screen.getByText('Connection refused'));
|
||||
});
|
||||
|
||||
it('renders without crashing when connStatus is undefined (no status badge data)', () => {
|
||||
// connStatus=undefined is the cold-start case before status polling resolves.
|
||||
// The component must not crash and must default to disconnected state.
|
||||
render(
|
||||
<InstalledServerDetail server={BASE_SERVER} connStatus={undefined} onUninstalled={() => {}} />
|
||||
);
|
||||
expect(screen.getByText('Test Server')).toBeInTheDocument();
|
||||
// Connect button shown (defaulted to disconnected)
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
|
||||
// No tool list shown in disconnected state
|
||||
expect(screen.getByText('No tools available.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders status badge from connStatus', () => {
|
||||
render(
|
||||
<InstalledServerDetail
|
||||
|
||||
@@ -200,6 +200,25 @@ describe('InstalledServerList', () => {
|
||||
expect(screen.getByTitle('disconnected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Defensive rendering with malformed props
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('does not crash when statuses is undefined', () => {
|
||||
// Guard: passing undefined instead of [] should not throw
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1]}
|
||||
statuses={undefined as unknown as ConnStatus[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
/>
|
||||
);
|
||||
// Server name still renders; status falls back to disconnected
|
||||
expect(screen.getByText('File Server')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onBrowseCatalog from the header link', () => {
|
||||
const onBrowse = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -25,7 +25,7 @@ const InstalledServerList = ({
|
||||
onSelect,
|
||||
onBrowseCatalog,
|
||||
}: InstalledServerListProps) => {
|
||||
const statusMap = new Map(statuses.map(s => [s.server_id, s]));
|
||||
const statusMap = new Map((statuses ?? []).map(s => [s.server_id, s]));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
|
||||
@@ -108,6 +108,41 @@ describe('McpCatalogBrowser', () => {
|
||||
expect(screen.getByRole('button', { name: 'Load more' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not crash when registrySearch returns servers: undefined', async () => {
|
||||
// Simulates a malformed envelope where the `servers` field is missing.
|
||||
// The catalog component spreads `result.servers` — if undefined, the spread
|
||||
// would throw. This test verifies a graceful "no results" render instead.
|
||||
mockRegistrySearch.mockResolvedValue({ servers: undefined, page: 1, total_pages: 1 });
|
||||
render(<McpCatalogBrowser onSelectInstall={() => {}} />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
// Should show empty/no-results state, not crash
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Search Smithery catalog...')).toBeInTheDocument();
|
||||
});
|
||||
// No "Install" button — nothing to install from an undefined list
|
||||
expect(screen.queryByRole('button', { name: 'Install' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not crash when registrySearch returns null servers', async () => {
|
||||
mockRegistrySearch.mockResolvedValue({ servers: null, page: 1, total_pages: 1 });
|
||||
render(<McpCatalogBrowser onSelectInstall={() => {}} />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Search Smithery catalog...')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: 'Install' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state when search fails', async () => {
|
||||
mockRegistrySearch.mockRejectedValue(new Error('Network error'));
|
||||
render(<McpCatalogBrowser onSelectInstall={() => {}} />);
|
||||
|
||||
@@ -47,8 +47,10 @@ const McpCatalogBrowser = ({ onSelectInstall }: McpCatalogBrowserProps) => {
|
||||
}
|
||||
setTotalPages(result.total_pages);
|
||||
setPage(result.page);
|
||||
setServers(prev => (append ? [...prev, ...result.servers] : result.servers));
|
||||
log('loaded %d servers (append=%s)', result.servers.length, append);
|
||||
// Guard against malformed envelope where `servers` is null/undefined.
|
||||
const incoming = result.servers ?? [];
|
||||
setServers(prev => (append ? [...prev, ...incoming] : incoming));
|
||||
log('loaded %d servers (append=%s)', incoming.length, append);
|
||||
} catch (err) {
|
||||
if (seq !== requestSeqRef.current) return;
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load catalog';
|
||||
|
||||
@@ -223,6 +223,80 @@ describe('McpServersTab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Regression: malformed RPC envelopes must not crash the tab
|
||||
// (Commit 38fcbd8f5 — `Cannot read properties of undefined (reading 'find')`)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('renders empty state when installedList resolves with undefined installed field', async () => {
|
||||
// Simulates core returning `{}` on first launch before MCP store is init'd.
|
||||
// The api layer now returns [] in this case; this test verifies the full path.
|
||||
mockInstalledList.mockResolvedValue([]);
|
||||
mockStatus.mockResolvedValue([]);
|
||||
|
||||
render(<McpServersTab />);
|
||||
vi.useRealTimers();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No MCP servers installed yet.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not crash when installedList resolves with null', async () => {
|
||||
// If mcpClientsApi.installedList ever passes through null (belt + suspenders).
|
||||
mockInstalledList.mockResolvedValue(null as unknown as never[]);
|
||||
mockStatus.mockResolvedValue([]);
|
||||
|
||||
// Should not throw
|
||||
const { container } = render(<McpServersTab />);
|
||||
vi.useRealTimers();
|
||||
|
||||
// Either shows empty state or loading — but does NOT crash
|
||||
await waitFor(() => {
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not crash when status resolves with undefined', async () => {
|
||||
mockInstalledList.mockResolvedValue(SERVERS);
|
||||
mockStatus.mockResolvedValue(undefined as unknown as never[]);
|
||||
|
||||
render(<McpServersTab />);
|
||||
vi.useRealTimers();
|
||||
|
||||
// Server row still renders; just no status badge data
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('File Server')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error banner when installedList rejects, not a crash', async () => {
|
||||
mockInstalledList.mockRejectedValue(new Error('RPC timeout'));
|
||||
mockStatus.mockResolvedValue([]);
|
||||
|
||||
render(<McpServersTab />);
|
||||
vi.useRealTimers();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('RPC timeout')).toBeInTheDocument();
|
||||
});
|
||||
// Loading state should be gone
|
||||
expect(screen.queryByText('Loading MCP servers...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('server row renders even when status rejects', async () => {
|
||||
mockInstalledList.mockResolvedValue(SERVERS);
|
||||
mockStatus.mockRejectedValue(new Error('status unavailable'));
|
||||
|
||||
render(<McpServersTab />);
|
||||
vi.useRealTimers();
|
||||
|
||||
// Tab should still show the server list; status error is non-fatal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('File Server')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows tool count badge when connected', async () => {
|
||||
mockInstalledList.mockResolvedValue(SERVERS);
|
||||
mockStatus.mockResolvedValue(STATUSES_CONNECTED);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import debug from 'debug';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
|
||||
import InstallDialog from './InstallDialog';
|
||||
import InstalledServerDetail from './InstalledServerDetail';
|
||||
@@ -24,6 +25,7 @@ type RightPane =
|
||||
| { mode: 'install'; qualifiedName: string; prefillEnv?: Record<string, string> };
|
||||
|
||||
const McpServersTab = () => {
|
||||
const { t } = useT();
|
||||
const [servers, setServers] = useState<InstalledServer[]>([]);
|
||||
const [statuses, setStatuses] = useState<ConnStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -35,7 +37,10 @@ const McpServersTab = () => {
|
||||
log('loading installed servers');
|
||||
try {
|
||||
const installed = await mcpClientsApi.installedList();
|
||||
setServers(installed);
|
||||
// Defensive: API contract guarantees an array, but if a future regression
|
||||
// or malformed envelope returns `undefined`, downstream `.find` crashes
|
||||
// the entire tab. Normalise here.
|
||||
setServers(Array.isArray(installed) ? installed : []);
|
||||
// Clear any previous error on successful reload.
|
||||
setLoadError(null);
|
||||
log('loaded %d installed servers', installed.length);
|
||||
@@ -50,7 +55,9 @@ const McpServersTab = () => {
|
||||
log('polling statuses');
|
||||
try {
|
||||
const sv = await mcpClientsApi.status();
|
||||
setStatuses(sv);
|
||||
// Defensive: same reasoning as `loadInstalled` — `.find` / `.map`
|
||||
// downstream cannot tolerate an undefined array.
|
||||
setStatuses(Array.isArray(sv) ? sv : []);
|
||||
} catch (err) {
|
||||
log('status poll error: %o', err);
|
||||
}
|
||||
@@ -137,51 +144,61 @@ const McpServersTab = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 h-full min-h-0">
|
||||
{/* Left pane: installed list */}
|
||||
<div className="w-56 shrink-0 flex flex-col">
|
||||
{loadError && (
|
||||
<div className="mb-2 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">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
<InstalledServerList
|
||||
servers={servers}
|
||||
statuses={statuses}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={handleSelectServer}
|
||||
onBrowseCatalog={handleBrowseCatalog}
|
||||
/>
|
||||
<div className="flex flex-col gap-3 h-full min-h-0">
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-start gap-2 rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider bg-amber-200/70 dark:bg-amber-500/30 text-amber-900 dark:text-amber-100 shrink-0 mt-0.5">
|
||||
{t('mcp.alphaBadge')}
|
||||
</span>
|
||||
<span className="leading-relaxed">{t('mcp.alphaBannerText')}</span>
|
||||
</div>
|
||||
|
||||
{/* Right pane */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto">
|
||||
{rightPane.mode === 'none' && (
|
||||
<div className="h-full flex items-center justify-center text-sm text-stone-400 dark:text-neutral-500">
|
||||
Select a server or browse the catalog.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rightPane.mode === 'catalog' && (
|
||||
<McpCatalogBrowser onSelectInstall={handleSelectInstall} />
|
||||
)}
|
||||
|
||||
{rightPane.mode === 'install' && (
|
||||
<InstallDialog
|
||||
qualifiedName={rightPane.qualifiedName}
|
||||
prefillEnv={rightPane.prefillEnv}
|
||||
onSuccess={server => void handleInstallSuccess(server)}
|
||||
onCancel={() => setRightPane({ mode: 'catalog' })}
|
||||
<div className="flex gap-4 flex-1 min-h-0">
|
||||
{/* Left pane: installed list */}
|
||||
<div className="w-56 shrink-0 flex flex-col">
|
||||
{loadError && (
|
||||
<div className="mb-2 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">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
<InstalledServerList
|
||||
servers={servers}
|
||||
statuses={statuses}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={handleSelectServer}
|
||||
onBrowseCatalog={handleBrowseCatalog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rightPane.mode === 'detail' && selectedServer && (
|
||||
<InstalledServerDetail
|
||||
server={selectedServer}
|
||||
connStatus={selectedConnStatus}
|
||||
onUninstalled={serverId => void handleUninstalled(serverId)}
|
||||
/>
|
||||
)}
|
||||
{/* Right pane */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto">
|
||||
{rightPane.mode === 'none' && (
|
||||
<div className="h-full flex items-center justify-center text-sm text-stone-400 dark:text-neutral-500">
|
||||
Select a server or browse the catalog.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rightPane.mode === 'catalog' && (
|
||||
<McpCatalogBrowser onSelectInstall={handleSelectInstall} />
|
||||
)}
|
||||
|
||||
{rightPane.mode === 'install' && (
|
||||
<InstallDialog
|
||||
qualifiedName={rightPane.qualifiedName}
|
||||
prefillEnv={rightPane.prefillEnv}
|
||||
onSuccess={server => void handleInstallSuccess(server)}
|
||||
onCancel={() => setRightPane({ mode: 'catalog' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rightPane.mode === 'detail' && selectedServer && (
|
||||
<InstalledServerDetail
|
||||
server={selectedServer}
|
||||
connStatus={selectedConnStatus}
|
||||
onUninstalled={serverId => void handleUninstalled(serverId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -68,6 +68,14 @@ describe('McpToolList', () => {
|
||||
expect(screen.queryByText('read_file')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when tools is undefined (malformed prop)', () => {
|
||||
// McpToolList receives `tools` typed as McpTool[] but defensive test for runtime safety.
|
||||
// tools.length would throw if undefined; the component must guard or fall back.
|
||||
render(<McpToolList tools={undefined as unknown as McpTool[]} />);
|
||||
// Should render empty state, not crash
|
||||
expect(screen.getByText('No tools available.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('arrow rotates when expanded', () => {
|
||||
render(<McpToolList tools={TOOLS} />);
|
||||
const arrow = screen.getByText('▶');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import type { McpTool } from './types';
|
||||
|
||||
interface McpToolListProps {
|
||||
@@ -10,10 +11,15 @@ interface McpToolListProps {
|
||||
}
|
||||
|
||||
const McpToolList = ({ tools }: McpToolListProps) => {
|
||||
const { t } = useT();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Guard against undefined/null passed at runtime (TypeScript can't always prevent this).
|
||||
const safeTools = tools ?? [];
|
||||
|
||||
if (tools.length === 0) {
|
||||
return <p className="text-xs text-stone-400 dark:text-neutral-500">No tools available.</p>;
|
||||
if (safeTools.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-stone-400 dark:text-neutral-500">{t('mcp.toolList.noTools')}</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -25,12 +31,12 @@ const McpToolList = ({ tools }: McpToolListProps) => {
|
||||
<span className={`transition-transform ${expanded ? 'rotate-90' : ''}`} aria-hidden="true">
|
||||
▶
|
||||
</span>
|
||||
{tools.length} tool{tools.length !== 1 ? 's' : ''} available
|
||||
{safeTools.length} tool{safeTools.length !== 1 ? 's' : ''} available
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<ul className="mt-2 space-y-1 pl-4 border-l-2 border-stone-100 dark:border-neutral-800">
|
||||
{tools.map(tool => (
|
||||
{safeTools.map(tool => (
|
||||
<li key={tool.name} className="space-y-0.5">
|
||||
<p className="text-xs font-mono font-medium text-stone-800 dark:text-neutral-100">
|
||||
{tool.name}
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('<TriggerToggles>', () => {
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText(/Enable Gmail New Gmail Message/)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Enable New Gmail Message/)).toBeInTheDocument()
|
||||
);
|
||||
expect(mockListAvailable).toHaveBeenCalledWith('gmail', 'c1');
|
||||
expect(mockListTriggers).toHaveBeenCalledWith('gmail');
|
||||
@@ -111,7 +111,7 @@ describe('<TriggerToggles>', () => {
|
||||
|
||||
render(<TriggerToggles toolkitSlug="gmail" toolkitName="Gmail" connectionId="c1" />);
|
||||
|
||||
const sw = await screen.findByLabelText(/Disable Gmail New Gmail Message/);
|
||||
const sw = await screen.findByLabelText(/Disable New Gmail Message/);
|
||||
expect(sw).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('<TriggerToggles>', () => {
|
||||
|
||||
render(<TriggerToggles toolkitSlug="gmail" toolkitName="Gmail" connectionId="c1" />);
|
||||
|
||||
const sw = await screen.findByLabelText(/Enable Gmail New Gmail Message/);
|
||||
const sw = await screen.findByLabelText(/Enable New Gmail Message/);
|
||||
expect(sw).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('<TriggerToggles>', () => {
|
||||
|
||||
render(<TriggerToggles toolkitSlug="slack" toolkitName="Slack" connectionId="c1" />);
|
||||
|
||||
const sw = await screen.findByLabelText(/Enable Slack New Message/);
|
||||
const sw = await screen.findByLabelText(/Enable New Message/);
|
||||
expect(sw).toBeDisabled();
|
||||
expect(screen.getByText('Needs configuration')).toBeInTheDocument();
|
||||
});
|
||||
@@ -159,11 +159,11 @@ describe('<TriggerToggles>', () => {
|
||||
|
||||
render(<TriggerToggles toolkitSlug="gmail" toolkitName="Gmail" connectionId="c1" />);
|
||||
|
||||
const sw = await screen.findByLabelText(/Enable Gmail New Gmail Message/);
|
||||
const sw = await screen.findByLabelText(/Enable New Gmail Message/);
|
||||
fireEvent.click(sw);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText(/Disable Gmail New Gmail Message/)).toHaveAttribute(
|
||||
expect(screen.getByLabelText(/Disable New Gmail Message/)).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true'
|
||||
)
|
||||
@@ -192,7 +192,7 @@ describe('<TriggerToggles>', () => {
|
||||
render(<TriggerToggles toolkitSlug="github" toolkitName="GitHub" connectionId="c1" />);
|
||||
|
||||
expect(await screen.findByText('acme/api')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText(/Enable GitHub Push Event/));
|
||||
fireEvent.click(screen.getByLabelText(/Enable Push Event/));
|
||||
|
||||
await waitFor(() => expect(mockEnable).toHaveBeenCalled());
|
||||
expect(mockEnable).toHaveBeenCalledWith('c1', 'GITHUB_PUSH_EVENT', {
|
||||
@@ -214,11 +214,11 @@ describe('<TriggerToggles>', () => {
|
||||
|
||||
render(<TriggerToggles toolkitSlug="gmail" toolkitName="Gmail" connectionId="c1" />);
|
||||
|
||||
const sw = await screen.findByLabelText(/Disable Gmail New Gmail Message/);
|
||||
const sw = await screen.findByLabelText(/Disable New Gmail Message/);
|
||||
fireEvent.click(sw);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText(/Enable Gmail New Gmail Message/)).toHaveAttribute(
|
||||
expect(screen.getByLabelText(/Enable New Gmail Message/)).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false'
|
||||
)
|
||||
@@ -235,11 +235,11 @@ describe('<TriggerToggles>', () => {
|
||||
|
||||
render(<TriggerToggles toolkitSlug="gmail" toolkitName="Gmail" connectionId="c1" />);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText(/Enable Gmail New Gmail Message/));
|
||||
fireEvent.click(await screen.findByLabelText(/Enable New Gmail Message/));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/Enable failed for Gmail New Gmail Message: upstream 500/)
|
||||
screen.getByText(/Enable failed for New Gmail Message: upstream 500/)
|
||||
).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -126,7 +126,15 @@ export default function TriggerToggles({
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const actionWord = existing ? t('common.disable') : t('common.enable');
|
||||
setRowError(`${actionWord} failed for ${formatTriggerLabel(entry.slug)}: ${msg}`);
|
||||
setRowError(
|
||||
t('triggers.toggleFailed')
|
||||
.replace('{action}', actionWord)
|
||||
.replace(
|
||||
'{trigger}',
|
||||
formatTriggerLabel(entry.slug, { toolkit: toolkitName || toolkitSlug })
|
||||
)
|
||||
.replace('{message}', msg)
|
||||
);
|
||||
} finally {
|
||||
setPendingSignature(null);
|
||||
}
|
||||
@@ -191,15 +199,17 @@ export default function TriggerToggles({
|
||||
const label =
|
||||
entry.scope === 'github_repo' && entry.repo
|
||||
? `${entry.repo.owner}/${entry.repo.repo}`
|
||||
: formatTriggerLabel(entry.slug);
|
||||
: formatTriggerLabel(entry.slug, { toolkit: toolkitName || toolkitSlug });
|
||||
const sub =
|
||||
entry.scope === 'github_repo'
|
||||
? formatTriggerLabel(entry.slug)
|
||||
? formatTriggerLabel(entry.slug, { toolkit: toolkitName || toolkitSlug })
|
||||
: requiresConfig
|
||||
? t('composio.triggers.needsConfiguration')
|
||||
: '';
|
||||
const action = enabled ? t('common.disable') : t('common.enable');
|
||||
const triggerName = formatTriggerLabel(entry.slug);
|
||||
const triggerName = formatTriggerLabel(entry.slug, {
|
||||
toolkit: toolkitName || toolkitSlug,
|
||||
});
|
||||
const ariaLabel =
|
||||
entry.scope === 'github_repo' && entry.repo
|
||||
? `${action} ${triggerName} for ${entry.repo.owner}/${entry.repo.repo}`
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function RewardsCommunityTab({
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
onClick={() => navigate('/settings/messaging')}
|
||||
onClick={() => navigate('/skills')}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-white dark:bg-neutral-900 px-4 py-3 text-sm font-semibold text-primary-700 dark:text-primary-300 shadow-lg transition-transform active:scale-[0.98]">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
|
||||
@@ -757,7 +757,7 @@ function summarizeSpendSample(transactions: CreditTransaction[]) {
|
||||
return { rows, total, avgRowUsd, sampleHours, spendPerHour, rowsPerHour };
|
||||
}
|
||||
|
||||
function describeProvider(ref: ProviderRef, providers: CloudProvider[]): string {
|
||||
function describeProvider(ref: ProviderRef, providers: BackgroundLoopProviderView[]): string {
|
||||
if (ref.kind === 'openhuman') return 'OpenHuman';
|
||||
if (ref.kind === 'local') return `Local ${ref.model}`;
|
||||
const provider = providers.find(p => p.slug === ref.providerSlug);
|
||||
@@ -828,12 +828,25 @@ const FormulaRow = ({ label, value, detail }: { label: string; value: string; de
|
||||
</div>
|
||||
);
|
||||
|
||||
const BackgroundLoopControls = ({
|
||||
export type BackgroundLoopControlsView = 'all' | 'heartbeat' | 'ledger';
|
||||
|
||||
/** Minimal cloud-provider shape consumed by the loop map's `describeProvider`
|
||||
* helper — only slug/label/id are read. Accepting this narrower shape lets
|
||||
* external panels (HeartbeatPanel, LedgerUsagePanel) feed in the API view
|
||||
* (`CloudProviderView`) without copying the AIPanel-internal extras
|
||||
* (`authStyle`, `maskedKey`). */
|
||||
export type BackgroundLoopProviderView = { id: string; slug: string; label: string };
|
||||
|
||||
export const BackgroundLoopControls = ({
|
||||
routing,
|
||||
cloudProviders,
|
||||
view = 'all',
|
||||
hideHeader = false,
|
||||
}: {
|
||||
routing: RoutingMap;
|
||||
cloudProviders: CloudProvider[];
|
||||
cloudProviders: BackgroundLoopProviderView[];
|
||||
view?: BackgroundLoopControlsView;
|
||||
hideHeader?: boolean;
|
||||
}) => {
|
||||
const [settings, setSettings] = useState<HeartbeatSettings | null>(null);
|
||||
const [usage, setUsage] = useState<TeamUsage | null>(null);
|
||||
@@ -1043,17 +1056,24 @@ const BackgroundLoopControls = ({
|
||||
},
|
||||
];
|
||||
|
||||
const showHeartbeat = view === 'all' || view === 'heartbeat';
|
||||
const showLedger = view === 'all' || view === 'ledger';
|
||||
const gridCols =
|
||||
view === 'all' ? 'lg:grid-cols-[minmax(0,1fr)_minmax(300px,0.8fr)]' : 'lg:grid-cols-1';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="border-b border-stone-200 dark:border-neutral-800 pb-2">
|
||||
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
|
||||
Background loops
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
|
||||
See what runs without a chat message, pause heartbeat work, and inspect recent credit
|
||||
ledger rows.
|
||||
</p>
|
||||
</div>
|
||||
{!hideHeader && (
|
||||
<div className="border-b border-stone-200 dark:border-neutral-800 pb-2">
|
||||
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
|
||||
Background loops
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
|
||||
See what runs without a chat message, pause heartbeat work, and inspect recent credit
|
||||
ledger rows.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md 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">
|
||||
@@ -1061,440 +1081,446 @@ const BackgroundLoopControls = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(300px,0.8fr)]">
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<section className={`grid gap-3 ${gridCols}`}>
|
||||
{showHeartbeat && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
Heartbeat controls
|
||||
</div>
|
||||
<div className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
Defaults off. Enabling starts the loop; disabling aborts the running task.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refresh()}
|
||||
disabled={loading}
|
||||
className="rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 disabled:opacity-50">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settings ? (
|
||||
<div className="space-y-2">
|
||||
<LoopToggle
|
||||
label="Heartbeat loop"
|
||||
description="Master scheduler for planner + optional subconscious inference."
|
||||
checked={settings.enabled}
|
||||
busy={saving === 'enabled'}
|
||||
onToggle={() => void applyHeartbeatPatch({ enabled: !settings.enabled })}
|
||||
/>
|
||||
<LoopToggle
|
||||
label="Subconscious inference"
|
||||
description="Runs model-backed task/reflection evaluation on heartbeat ticks."
|
||||
checked={settings.inference_enabled}
|
||||
busy={saving === 'inference_enabled'}
|
||||
onToggle={() =>
|
||||
void applyHeartbeatPatch({ inference_enabled: !settings.inference_enabled })
|
||||
}
|
||||
/>
|
||||
<LoopToggle
|
||||
label="Calendar meeting checks"
|
||||
description="Calls calendar event list for active Google Calendar connections."
|
||||
checked={settings.notify_meetings}
|
||||
busy={saving === 'notify_meetings'}
|
||||
onToggle={() =>
|
||||
void applyHeartbeatPatch({ notify_meetings: !settings.notify_meetings })
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 md:grid-cols-3">
|
||||
<label className="space-y-1 text-xs font-medium text-stone-700 dark:text-neutral-200">
|
||||
<span>Calendar cap</span>
|
||||
<select
|
||||
value={maxCalendarConnectionsPerTick}
|
||||
disabled={saving === 'max_calendar_connections_per_tick'}
|
||||
onChange={e =>
|
||||
void applyHeartbeatPatch({
|
||||
max_calendar_connections_per_tick: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{[1, 2, 3, 5, 10].map(count => (
|
||||
<option key={count} value={count}>
|
||||
{count} conn/tick
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1 text-xs font-medium text-stone-700 dark:text-neutral-200">
|
||||
<span>Meeting lookahead</span>
|
||||
<select
|
||||
value={settings.meeting_lookahead_minutes}
|
||||
disabled={saving === 'meeting_lookahead_minutes'}
|
||||
onChange={e =>
|
||||
void applyHeartbeatPatch({
|
||||
meeting_lookahead_minutes: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{[15, 30, 60, 120, 240].map(minutes => (
|
||||
<option key={minutes} value={minutes}>
|
||||
{minutes} min
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1 text-xs font-medium text-stone-700 dark:text-neutral-200">
|
||||
<span>Reminder lookahead</span>
|
||||
<select
|
||||
value={settings.reminder_lookahead_minutes}
|
||||
disabled={saving === 'reminder_lookahead_minutes'}
|
||||
onChange={e =>
|
||||
void applyHeartbeatPatch({
|
||||
reminder_lookahead_minutes: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{[5, 15, 30, 60, 120].map(minutes => (
|
||||
<option key={minutes} value={minutes}>
|
||||
{minutes} min
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<LoopToggle
|
||||
label="Cron reminder checks"
|
||||
description="Scans enabled cron jobs for reminder-like upcoming items."
|
||||
checked={settings.notify_reminders}
|
||||
busy={saving === 'notify_reminders'}
|
||||
onToggle={() =>
|
||||
void applyHeartbeatPatch({ notify_reminders: !settings.notify_reminders })
|
||||
}
|
||||
/>
|
||||
<LoopToggle
|
||||
label="Relevant notification checks"
|
||||
description="Promotes urgent local notifications into proactive alerts."
|
||||
checked={settings.notify_relevant_events}
|
||||
busy={saving === 'notify_relevant_events'}
|
||||
onToggle={() =>
|
||||
void applyHeartbeatPatch({
|
||||
notify_relevant_events: !settings.notify_relevant_events,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<LoopToggle
|
||||
label="External delivery"
|
||||
description="Lets heartbeat alerts send proactive messages to external channels."
|
||||
checked={settings.external_delivery_enabled}
|
||||
busy={saving === 'external_delivery_enabled'}
|
||||
onToggle={() =>
|
||||
void applyHeartbeatPatch({
|
||||
external_delivery_enabled: !settings.external_delivery_enabled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2">
|
||||
<label
|
||||
className="text-xs font-medium text-stone-700 dark:text-neutral-200"
|
||||
htmlFor="heartbeat-interval">
|
||||
Interval
|
||||
</label>
|
||||
<select
|
||||
id="heartbeat-interval"
|
||||
value={settings.interval_minutes}
|
||||
disabled={saving === 'interval_minutes'}
|
||||
onChange={e =>
|
||||
void applyHeartbeatPatch({ interval_minutes: Number(e.target.value) })
|
||||
}
|
||||
className="rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{[5, 10, 15, 30, 60].map(minutes => (
|
||||
<option key={minutes} value={minutes}>
|
||||
{minutes} min
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runPlannerNow()}
|
||||
disabled={runningTick}
|
||||
className="ml-auto rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 disabled:opacity-50">
|
||||
{runningTick ? 'Running...' : 'Planner tick now'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{plannerSummary && (
|
||||
<div className="rounded-md border border-primary-100 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-xs text-primary-900">
|
||||
Planner: {plannerSummary.source_events} source events,{' '}
|
||||
{plannerSummary.deliveries_sent} sent,{' '}
|
||||
{plannerSummary.deliveries_skipped_dedup} deduped.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
{loading ? 'Loading heartbeat controls...' : 'Heartbeat controls unavailable.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60">
|
||||
<div className="border-b border-stone-200 dark:border-neutral-800 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
|
||||
Loop map
|
||||
</div>
|
||||
<div className="divide-y divide-stone-200 dark:divide-neutral-800">
|
||||
{loops.map(loop => (
|
||||
<div key={loop.name} className="grid gap-2 px-3 py-3 md:grid-cols-[150px_1fr]">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
|
||||
{loop.name}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap gap-1 text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
<span>{loop.enabled ? 'on' : 'off'}</span>
|
||||
<span>{loop.cadence}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-stone-600 dark:text-neutral-300">
|
||||
<div>{loop.work}</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
route: {loop.route}
|
||||
</div>
|
||||
<div className="mt-1 text-stone-500 dark:text-neutral-400">{loop.risk}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLedger && (
|
||||
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
Heartbeat controls
|
||||
Recent usage ledger
|
||||
</div>
|
||||
<div className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
Defaults off. Enabling starts the loop; disabling aborts the running task.
|
||||
Backend rows expose action/time today; source tags need backend support.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refresh()}
|
||||
disabled={loading}
|
||||
className="rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 disabled:opacity-50">
|
||||
Refresh
|
||||
className="rounded-md border border-stone-200 dark:border-neutral-800 px-2 py-1 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 disabled:opacity-50">
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settings ? (
|
||||
<div className="space-y-2">
|
||||
<LoopToggle
|
||||
label="Heartbeat loop"
|
||||
description="Master scheduler for planner + optional subconscious inference."
|
||||
checked={settings.enabled}
|
||||
busy={saving === 'enabled'}
|
||||
onToggle={() => void applyHeartbeatPatch({ enabled: !settings.enabled })}
|
||||
/>
|
||||
<LoopToggle
|
||||
label="Subconscious inference"
|
||||
description="Runs model-backed task/reflection evaluation on heartbeat ticks."
|
||||
checked={settings.inference_enabled}
|
||||
busy={saving === 'inference_enabled'}
|
||||
onToggle={() =>
|
||||
void applyHeartbeatPatch({ inference_enabled: !settings.inference_enabled })
|
||||
}
|
||||
/>
|
||||
<LoopToggle
|
||||
label="Calendar meeting checks"
|
||||
description="Calls calendar event list for active Google Calendar connections."
|
||||
checked={settings.notify_meetings}
|
||||
busy={saving === 'notify_meetings'}
|
||||
onToggle={() =>
|
||||
void applyHeartbeatPatch({ notify_meetings: !settings.notify_meetings })
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 md:grid-cols-3">
|
||||
<label className="space-y-1 text-xs font-medium text-stone-700 dark:text-neutral-200">
|
||||
<span>Calendar cap</span>
|
||||
<select
|
||||
value={maxCalendarConnectionsPerTick}
|
||||
disabled={saving === 'max_calendar_connections_per_tick'}
|
||||
onChange={e =>
|
||||
void applyHeartbeatPatch({
|
||||
max_calendar_connections_per_tick: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{[1, 2, 3, 5, 10].map(count => (
|
||||
<option key={count} value={count}>
|
||||
{count} conn/tick
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1 text-xs font-medium text-stone-700 dark:text-neutral-200">
|
||||
<span>Meeting lookahead</span>
|
||||
<select
|
||||
value={settings.meeting_lookahead_minutes}
|
||||
disabled={saving === 'meeting_lookahead_minutes'}
|
||||
onChange={e =>
|
||||
void applyHeartbeatPatch({
|
||||
meeting_lookahead_minutes: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{[15, 30, 60, 120, 240].map(minutes => (
|
||||
<option key={minutes} value={minutes}>
|
||||
{minutes} min
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1 text-xs font-medium text-stone-700 dark:text-neutral-200">
|
||||
<span>Reminder lookahead</span>
|
||||
<select
|
||||
value={settings.reminder_lookahead_minutes}
|
||||
disabled={saving === 'reminder_lookahead_minutes'}
|
||||
onChange={e =>
|
||||
void applyHeartbeatPatch({
|
||||
reminder_lookahead_minutes: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{[5, 15, 30, 60, 120].map(minutes => (
|
||||
<option key={minutes} value={minutes}>
|
||||
{minutes} min
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<LoopToggle
|
||||
label="Cron reminder checks"
|
||||
description="Scans enabled cron jobs for reminder-like upcoming items."
|
||||
checked={settings.notify_reminders}
|
||||
busy={saving === 'notify_reminders'}
|
||||
onToggle={() =>
|
||||
void applyHeartbeatPatch({ notify_reminders: !settings.notify_reminders })
|
||||
}
|
||||
/>
|
||||
<LoopToggle
|
||||
label="Relevant notification checks"
|
||||
description="Promotes urgent local notifications into proactive alerts."
|
||||
checked={settings.notify_relevant_events}
|
||||
busy={saving === 'notify_relevant_events'}
|
||||
onToggle={() =>
|
||||
void applyHeartbeatPatch({
|
||||
notify_relevant_events: !settings.notify_relevant_events,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<LoopToggle
|
||||
label="External delivery"
|
||||
description="Lets heartbeat alerts send proactive messages to external channels."
|
||||
checked={settings.external_delivery_enabled}
|
||||
busy={saving === 'external_delivery_enabled'}
|
||||
onToggle={() =>
|
||||
void applyHeartbeatPatch({
|
||||
external_delivery_enabled: !settings.external_delivery_enabled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2">
|
||||
<label
|
||||
className="text-xs font-medium text-stone-700 dark:text-neutral-200"
|
||||
htmlFor="heartbeat-interval">
|
||||
Interval
|
||||
</label>
|
||||
<select
|
||||
id="heartbeat-interval"
|
||||
value={settings.interval_minutes}
|
||||
disabled={saving === 'interval_minutes'}
|
||||
onChange={e =>
|
||||
void applyHeartbeatPatch({ interval_minutes: Number(e.target.value) })
|
||||
}
|
||||
className="rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{[5, 10, 15, 30, 60].map(minutes => (
|
||||
<option key={minutes} value={minutes}>
|
||||
{minutes} min
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runPlannerNow()}
|
||||
disabled={runningTick}
|
||||
className="ml-auto rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 disabled:opacity-50">
|
||||
{runningTick ? 'Running...' : 'Planner tick now'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{plannerSummary && (
|
||||
<div className="rounded-md border border-primary-100 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-xs text-primary-900">
|
||||
Planner: {plannerSummary.source_events} source events,{' '}
|
||||
{plannerSummary.deliveries_sent} sent, {plannerSummary.deliveries_skipped_dedup}{' '}
|
||||
deduped.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
{loading ? 'Loading heartbeat controls...' : 'Heartbeat controls unavailable.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60">
|
||||
<div className="border-b border-stone-200 dark:border-neutral-800 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
|
||||
Loop map
|
||||
</div>
|
||||
<div className="divide-y divide-stone-200 dark:divide-neutral-800">
|
||||
{loops.map(loop => (
|
||||
<div key={loop.name} className="grid gap-2 px-3 py-3 md:grid-cols-[150px_1fr]">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
|
||||
{loop.name}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap gap-1 text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
<span>{loop.enabled ? 'on' : 'off'}</span>
|
||||
<span>{loop.cadence}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-stone-600 dark:text-neutral-300">
|
||||
<div>{loop.work}</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
route: {loop.route}
|
||||
</div>
|
||||
<div className="mt-1 text-stone-500 dark:text-neutral-400">{loop.risk}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
Recent usage ledger
|
||||
</div>
|
||||
<div className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
Backend rows expose action/time today; source tags need backend support.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refresh()}
|
||||
disabled={loading}
|
||||
className="rounded-md border border-stone-200 dark:border-neutral-800 px-2 py-1 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 disabled:opacity-50">
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 md:grid-cols-3">
|
||||
<MetricTile
|
||||
label="Week budget"
|
||||
value={usage ? formatUsd(usage.cycleBudgetUsd) : 'n/a'}
|
||||
detail={`resets ${formatDateTime(usage?.cycleEndsAt)}`}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Cycle remaining"
|
||||
value={usage ? formatUsd(usage.remainingUsd) : 'n/a'}
|
||||
detail={usage ? `${formatUsd(usage.cycleSpentUsd)} used` : undefined}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Cycle total spend"
|
||||
value={usage ? formatUsd(usage.insights.totals.totalUsd) : 'n/a'}
|
||||
detail={
|
||||
usage
|
||||
? `inference ${formatUsd(usage.insights.totals.inferenceUsd)} + integrations ${formatUsd(usage.insights.totals.integrationsUsd)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Avg spend row"
|
||||
value={spendSample.avgRowUsd > 0 ? formatUsd(spendSample.avgRowUsd) : 'n/a'}
|
||||
detail={`${spendRows.length} recent spend rows`}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Bg API reads"
|
||||
value={`${formatCount(backgroundApiReadsPerWeek)}/week`}
|
||||
detail={`${formatCount(calendarPlannerCallsPerWeek)} planner + ${formatCount(composioConnectionScansPerWeek)} sync`}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Bg wakeups"
|
||||
value={`${formatCount(backgroundWakeupsPerWeek)}/week`}
|
||||
detail={`${formatCount(memoryPollsPerWeek)} memory polls`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
|
||||
Budget math
|
||||
</div>
|
||||
<div className="mt-2 grid gap-2">
|
||||
<FormulaRow
|
||||
label="Rows left"
|
||||
value={estimatedRowsLeft !== null ? formatCount(estimatedRowsLeft) : 'n/a'}
|
||||
detail={
|
||||
estimatedRowsLeft !== null
|
||||
? `remaining / avg row = ${formatUsd(usage?.remainingUsd ?? 0)} / ${formatUsd(spendSample.avgRowUsd)}`
|
||||
: 'Need recent spend rows to estimate.'
|
||||
}
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 md:grid-cols-3">
|
||||
<MetricTile
|
||||
label="Week budget"
|
||||
value={usage ? formatUsd(usage.cycleBudgetUsd) : 'n/a'}
|
||||
detail={`resets ${formatDateTime(usage?.cycleEndsAt)}`}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Rows per full week budget"
|
||||
value={
|
||||
estimatedRowsPerBudget !== null ? formatCount(estimatedRowsPerBudget) : 'n/a'
|
||||
}
|
||||
detail={
|
||||
estimatedRowsPerBudget !== null
|
||||
? `cycle budget / avg row = ${formatUsd(usage?.cycleBudgetUsd ?? 0)} / ${formatUsd(spendSample.avgRowUsd)}`
|
||||
: 'Need recent spend rows to estimate.'
|
||||
}
|
||||
<MetricTile
|
||||
label="Cycle remaining"
|
||||
value={usage ? formatUsd(usage.remainingUsd) : 'n/a'}
|
||||
detail={usage ? `${formatUsd(usage.cycleSpentUsd)} used` : undefined}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Sample burn rate"
|
||||
value={
|
||||
spendSample.spendPerHour > 0 ? `${formatUsd(spendSample.spendPerHour)}/hr` : 'n/a'
|
||||
}
|
||||
detail={
|
||||
spendSample.sampleHours > 0
|
||||
? `${formatCount(spendSample.rowsPerHour)} rows/hr across ${spendSample.sampleHours.toFixed(1)}h sample`
|
||||
: 'Need timestamps from at least two spend rows.'
|
||||
}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Projected empty"
|
||||
value={projectedExhaustAt}
|
||||
detail={
|
||||
projectedHoursLeft !== null
|
||||
? `${projectedHoursLeft.toFixed(1)}h after latest spend at recent burn rate`
|
||||
: 'No projection without recent hourly spend.'
|
||||
}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="API reads per $ remaining"
|
||||
value={
|
||||
scheduledCallsPerRemainingDollar !== null
|
||||
? `${formatCount(scheduledCallsPerRemainingDollar)} reads/$`
|
||||
: 'n/a'
|
||||
}
|
||||
<MetricTile
|
||||
label="Cycle total spend"
|
||||
value={usage ? formatUsd(usage.insights.totals.totalUsd) : 'n/a'}
|
||||
detail={
|
||||
usage
|
||||
? `background API reads/week / remaining = ${formatCount(backgroundApiReadsPerWeek)} / ${formatUsd(usage.remainingUsd)}`
|
||||
: 'Need usage response to estimate.'
|
||||
? `inference ${formatUsd(usage.insights.totals.inferenceUsd)} + integrations ${formatUsd(usage.insights.totals.integrationsUsd)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
|
||||
Loop call budget
|
||||
</div>
|
||||
<div className="mt-2 grid gap-2">
|
||||
<FormulaRow
|
||||
label="Heartbeat ticks"
|
||||
value={`${formatCount(heartbeatTicksPerWeek)}/week`}
|
||||
detail={`10080 min/week / ${heartbeatIntervalMinutes} min interval`}
|
||||
<MetricTile
|
||||
label="Avg spend row"
|
||||
value={spendSample.avgRowUsd > 0 ? formatUsd(spendSample.avgRowUsd) : 'n/a'}
|
||||
detail={`${spendRows.length} recent spend rows`}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Calendar planner calls"
|
||||
value={`${formatCount(calendarPlannerCallsPerWeek)}/week`}
|
||||
detail={
|
||||
settings?.notify_meetings
|
||||
? `ticks * (1 list_connections + ${calendarConnectionsPolled} GOOGLECALENDAR_EVENTS_LIST)`
|
||||
: 'Meeting collector disabled.'
|
||||
}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Calendar fanout cap"
|
||||
value={`${formatCount(calendarConnectionsPolled)}/${formatCount(activeCalendarConnections.length)} conn/tick`}
|
||||
detail={`max_calendar_connections_per_tick = ${maxCalendarConnectionsPerTick}; skipped now = ${calendarConnectionsSkipped}`}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Subconscious model calls"
|
||||
value={`${formatCount(subconsciousModelCallsPerWeek)}/week`}
|
||||
detail={
|
||||
settings?.enabled && settings.inference_enabled
|
||||
? 'one kind=subconscious_tick model call per heartbeat tick'
|
||||
: 'Heartbeat inference disabled.'
|
||||
}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Composio sync scans"
|
||||
value={`${formatCount(composioConnectionScansPerWeek)}/week`}
|
||||
detail={`${activeConnections.length} active integration connection(s) scanned every ${COMPOSIO_PERIODIC_TICK_MINUTES} min`}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Total bg API read budget"
|
||||
<MetricTile
|
||||
label="Bg API reads"
|
||||
value={`${formatCount(backgroundApiReadsPerWeek)}/week`}
|
||||
detail={`calendar planner reads + periodic integration scans; excludes user-initiated chat tools`}
|
||||
detail={`${formatCount(calendarPlannerCallsPerWeek)} planner + ${formatCount(composioConnectionScansPerWeek)} sync`}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Memory worker polls"
|
||||
value={`${formatCount(memoryPollsPerWeek)}/week max`}
|
||||
detail={`${MEMORY_WORKERS} workers * ${MEMORY_POLL_SECONDS}s poll; LLM calls only for queued jobs`}
|
||||
<MetricTile
|
||||
label="Bg wakeups"
|
||||
value={`${formatCount(backgroundWakeupsPerWeek)}/week`}
|
||||
detail={`${formatCount(memoryPollsPerWeek)} memory polls`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latestSpend && (
|
||||
<div className="mt-3 rounded-md border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2 text-xs text-stone-600 dark:text-neutral-300">
|
||||
Latest spend: {formatUsd(spendAmount(latestSpend))} at{' '}
|
||||
{new Date(latestSpend.createdAt).toLocaleString()} ({latestSpend.action})
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<div className="mt-3 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
|
||||
Top actions
|
||||
Budget math
|
||||
</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{actionSummary.length > 0 ? (
|
||||
actionSummary.map(([action, count, total]) => (
|
||||
<div
|
||||
key={action}
|
||||
className="flex items-center justify-between gap-2 text-xs text-stone-600 dark:text-neutral-300">
|
||||
<span className="truncate font-mono">{action}</span>
|
||||
<span className="shrink-0 text-stone-500 dark:text-neutral-400">
|
||||
{count} / {formatUsd(total)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
No spend rows loaded.
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 grid gap-2">
|
||||
<FormulaRow
|
||||
label="Rows left"
|
||||
value={estimatedRowsLeft !== null ? formatCount(estimatedRowsLeft) : 'n/a'}
|
||||
detail={
|
||||
estimatedRowsLeft !== null
|
||||
? `remaining / avg row = ${formatUsd(usage?.remainingUsd ?? 0)} / ${formatUsd(spendSample.avgRowUsd)}`
|
||||
: 'Need recent spend rows to estimate.'
|
||||
}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Rows per full week budget"
|
||||
value={
|
||||
estimatedRowsPerBudget !== null ? formatCount(estimatedRowsPerBudget) : 'n/a'
|
||||
}
|
||||
detail={
|
||||
estimatedRowsPerBudget !== null
|
||||
? `cycle budget / avg row = ${formatUsd(usage?.cycleBudgetUsd ?? 0)} / ${formatUsd(spendSample.avgRowUsd)}`
|
||||
: 'Need recent spend rows to estimate.'
|
||||
}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Sample burn rate"
|
||||
value={
|
||||
spendSample.spendPerHour > 0
|
||||
? `${formatUsd(spendSample.spendPerHour)}/hr`
|
||||
: 'n/a'
|
||||
}
|
||||
detail={
|
||||
spendSample.sampleHours > 0
|
||||
? `${formatCount(spendSample.rowsPerHour)} rows/hr across ${spendSample.sampleHours.toFixed(1)}h sample`
|
||||
: 'Need timestamps from at least two spend rows.'
|
||||
}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Projected empty"
|
||||
value={projectedExhaustAt}
|
||||
detail={
|
||||
projectedHoursLeft !== null
|
||||
? `${projectedHoursLeft.toFixed(1)}h after latest spend at recent burn rate`
|
||||
: 'No projection without recent hourly spend.'
|
||||
}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="API reads per $ remaining"
|
||||
value={
|
||||
scheduledCallsPerRemainingDollar !== null
|
||||
? `${formatCount(scheduledCallsPerRemainingDollar)} reads/$`
|
||||
: 'n/a'
|
||||
}
|
||||
detail={
|
||||
usage
|
||||
? `background API reads/week / remaining = ${formatCount(backgroundApiReadsPerWeek)} / ${formatUsd(usage.remainingUsd)}`
|
||||
: 'Need usage response to estimate.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mt-3 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
|
||||
Top hours
|
||||
Loop call budget
|
||||
</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{hourSummary.length > 0 ? (
|
||||
hourSummary.map(([hour, total]) => (
|
||||
<div
|
||||
key={hour}
|
||||
className="flex items-center justify-between gap-2 text-xs text-stone-600 dark:text-neutral-300">
|
||||
<span>{hour}</span>
|
||||
<span className="font-mono text-stone-500 dark:text-neutral-400">
|
||||
{formatUsd(total)}
|
||||
</span>
|
||||
<div className="mt-2 grid gap-2">
|
||||
<FormulaRow
|
||||
label="Heartbeat ticks"
|
||||
value={`${formatCount(heartbeatTicksPerWeek)}/week`}
|
||||
detail={`10080 min/week / ${heartbeatIntervalMinutes} min interval`}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Calendar planner calls"
|
||||
value={`${formatCount(calendarPlannerCallsPerWeek)}/week`}
|
||||
detail={
|
||||
settings?.notify_meetings
|
||||
? `ticks * (1 list_connections + ${calendarConnectionsPolled} GOOGLECALENDAR_EVENTS_LIST)`
|
||||
: 'Meeting collector disabled.'
|
||||
}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Calendar fanout cap"
|
||||
value={`${formatCount(calendarConnectionsPolled)}/${formatCount(activeCalendarConnections.length)} conn/tick`}
|
||||
detail={`max_calendar_connections_per_tick = ${maxCalendarConnectionsPerTick}; skipped now = ${calendarConnectionsSkipped}`}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Subconscious model calls"
|
||||
value={`${formatCount(subconsciousModelCallsPerWeek)}/week`}
|
||||
detail={
|
||||
settings?.enabled && settings.inference_enabled
|
||||
? 'one kind=subconscious_tick model call per heartbeat tick'
|
||||
: 'Heartbeat inference disabled.'
|
||||
}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Composio sync scans"
|
||||
value={`${formatCount(composioConnectionScansPerWeek)}/week`}
|
||||
detail={`${activeConnections.length} active integration connection(s) scanned every ${COMPOSIO_PERIODIC_TICK_MINUTES} min`}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Total bg API read budget"
|
||||
value={`${formatCount(backgroundApiReadsPerWeek)}/week`}
|
||||
detail={`calendar planner reads + periodic integration scans; excludes user-initiated chat tools`}
|
||||
/>
|
||||
<FormulaRow
|
||||
label="Memory worker polls"
|
||||
value={`${formatCount(memoryPollsPerWeek)}/week max`}
|
||||
detail={`${MEMORY_WORKERS} workers * ${MEMORY_POLL_SECONDS}s poll; LLM calls only for queued jobs`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latestSpend && (
|
||||
<div className="mt-3 rounded-md border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2 text-xs text-stone-600 dark:text-neutral-300">
|
||||
Latest spend: {formatUsd(spendAmount(latestSpend))} at{' '}
|
||||
{new Date(latestSpend.createdAt).toLocaleString()} ({latestSpend.action})
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
|
||||
Top actions
|
||||
</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{actionSummary.length > 0 ? (
|
||||
actionSummary.map(([action, count, total]) => (
|
||||
<div
|
||||
key={action}
|
||||
className="flex items-center justify-between gap-2 text-xs text-stone-600 dark:text-neutral-300">
|
||||
<span className="truncate font-mono">{action}</span>
|
||||
<span className="shrink-0 text-stone-500 dark:text-neutral-400">
|
||||
{count} / {formatUsd(total)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
No spend rows loaded.
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
No hourly spend yet.
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
|
||||
Top hours
|
||||
</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{hourSummary.length > 0 ? (
|
||||
hourSummary.map(([hour, total]) => (
|
||||
<div
|
||||
key={hour}
|
||||
className="flex items-center justify-between gap-2 text-xs text-stone-600 dark:text-neutral-300">
|
||||
<span>{hour}</span>
|
||||
<span className="font-mono text-stone-500 dark:text-neutral-400">
|
||||
{formatUsd(total)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
No hourly spend yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
@@ -2342,8 +2368,6 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
</section>
|
||||
</div>
|
||||
{/* end of Routing section */}
|
||||
|
||||
<BackgroundLoopControls routing={draft.routing} cloudProviders={draft.cloudProviders} />
|
||||
</div>
|
||||
|
||||
{isDirty && (
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
* is driven by the globally-mounted `<AppUpdatePrompt />` — calling `apply()`
|
||||
* here would race with that component's own state machine.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useAppUpdate } from '../../../hooks/useAppUpdate';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useAppSelector } from '../../../store/hooks';
|
||||
import { APP_VERSION, LATEST_APP_DOWNLOAD_URL } from '../../../utils/config';
|
||||
import { isTauriEnvironment } from '../../../utils/configPersistence';
|
||||
import { openUrl } from '../../../utils/openUrl';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
@@ -22,6 +25,35 @@ const AboutPanel = () => {
|
||||
// disable it here so opening the panel doesn't double-trigger probes.
|
||||
const { phase, info, error, check } = useAppUpdate({ autoCheck: false });
|
||||
const [lastCheckedAt, setLastCheckedAt] = useState<Date | null>(null);
|
||||
const coreMode = useAppSelector(state => state.coreMode.mode);
|
||||
const [rpcUrl, setRpcUrl] = useState<string | null>(null);
|
||||
|
||||
// Local mode picks a dynamic port at app launch, so the authoritative
|
||||
// value lives in the Tauri shell (`core_rpc_url` command) rather than the
|
||||
// build-time constant. Cloud mode stores the URL the user picked in
|
||||
// Redux; surface that directly.
|
||||
useEffect(() => {
|
||||
if (coreMode.kind === 'cloud') {
|
||||
setRpcUrl(coreMode.url);
|
||||
return;
|
||||
}
|
||||
if (!isTauriEnvironment()) {
|
||||
setRpcUrl(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
invoke<string>('core_rpc_url')
|
||||
.then(url => {
|
||||
if (!cancelled) setRpcUrl(url);
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('[about-panel] failed to resolve core_rpc_url', err);
|
||||
if (!cancelled) setRpcUrl(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [coreMode]);
|
||||
|
||||
const isChecking = phase === 'checking';
|
||||
const summary = summaryFor(phase, info, error, t);
|
||||
@@ -81,6 +113,41 @@ const AboutPanel = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4">
|
||||
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
|
||||
{t('settings.about.connection')}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('settings.about.connectionMode')}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-stone-900 dark:text-neutral-100">
|
||||
{coreMode.kind === 'local'
|
||||
? t('settings.about.connectionModeLocal')
|
||||
: coreMode.kind === 'cloud'
|
||||
? t('settings.about.connectionModeCloud')
|
||||
: t('settings.about.connectionModeUnset')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-stone-500 dark:text-neutral-400 shrink-0">
|
||||
{t('settings.about.serverUrl')}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-mono text-stone-900 dark:text-neutral-100 truncate"
|
||||
title={rpcUrl ?? undefined}>
|
||||
{rpcUrl ?? t('settings.about.serverUrlUnavailable')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-stone-500 dark:text-neutral-400 leading-relaxed">
|
||||
{coreMode.kind === 'cloud'
|
||||
? t('settings.about.connectionHelperCloud')
|
||||
: t('settings.about.connectionHelperLocal')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4">
|
||||
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
|
||||
{t('settings.about.releases')}
|
||||
|
||||
@@ -2,7 +2,12 @@ import type { ReactElement } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import { setThemeMode, type ThemeMode } from '../../../store/themeSlice';
|
||||
import {
|
||||
setTabBarLabels,
|
||||
setThemeMode,
|
||||
type TabBarLabels,
|
||||
type ThemeMode,
|
||||
} from '../../../store/themeSlice';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
@@ -51,6 +56,12 @@ const AppearancePanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const dispatch = useAppDispatch();
|
||||
const mode = useAppSelector(state => state.theme.mode);
|
||||
const tabBarLabels = useAppSelector(state => state.theme.tabBarLabels);
|
||||
const labelsAlwaysVisible = tabBarLabels === 'always';
|
||||
const toggleTabBarLabels = () => {
|
||||
const next: TabBarLabels = labelsAlwaysVisible ? 'hover' : 'always';
|
||||
dispatch(setTabBarLabels(next));
|
||||
};
|
||||
|
||||
// Build at render time so the labels follow the active locale; `t()` itself
|
||||
// memoises on locale change, so this stays stable across re-renders within a
|
||||
@@ -149,6 +160,40 @@ const AppearancePanel = () => {
|
||||
{t('settings.appearance.helperText')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-500 mb-2 px-1">
|
||||
{t('settings.appearance.tabBarHeading')}
|
||||
</h3>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={labelsAlwaysVisible}
|
||||
onClick={toggleTabBarLabels}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800/60 focus:outline-none focus-visible:bg-primary-50 dark:focus-visible:bg-primary-900/30">
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('settings.appearance.tabBarAlwaysShowLabels')}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('settings.appearance.tabBarAlwaysShowLabelsDesc')}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`relative inline-flex w-10 h-6 rounded-full transition-colors flex-shrink-0 ${
|
||||
labelsAlwaysVisible ? 'bg-primary-500' : 'bg-neutral-300 dark:bg-neutral-700'
|
||||
}`}>
|
||||
<span
|
||||
className={`absolute top-0.5 inline-block w-5 h-5 rounded-full bg-white shadow transition-transform ${
|
||||
labelsAlwaysVisible ? 'translate-x-[18px]' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import {
|
||||
openhumanGetAutonomySettings,
|
||||
openhumanUpdateAutonomySettings,
|
||||
@@ -7,15 +8,21 @@ import {
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const PRESETS = [
|
||||
{ label: '20 (default)', value: 20 },
|
||||
// u32::MAX — the Rust default and our sentinel for "no limit". Inputs at or
|
||||
// above this value render as "Unlimited" and clamp to UNLIMITED on save.
|
||||
const UNLIMITED = 4_294_967_295;
|
||||
|
||||
/** Preset rows. The `label` field is an i18n key for the unlimited entry; the
|
||||
* numeric-only rows are intentionally locale-agnostic. */
|
||||
const PRESETS: { labelKey?: string; label?: string; value: number }[] = [
|
||||
{ labelKey: 'autonomy.presetUnlimited', value: UNLIMITED },
|
||||
{ label: '100', value: 100 },
|
||||
{ label: '500', value: 500 },
|
||||
{ label: '1000', value: 1000 },
|
||||
];
|
||||
|
||||
const MIN = 1;
|
||||
const MAX = 10_000;
|
||||
const MAX = UNLIMITED;
|
||||
|
||||
type Status =
|
||||
| { kind: 'idle' }
|
||||
@@ -32,6 +39,7 @@ type Status =
|
||||
* New value applies to the next agent session.
|
||||
*/
|
||||
const AutonomyPanel = () => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const [committed, setCommitted] = useState<number | null>(null);
|
||||
const [draft, setDraft] = useState<string>('');
|
||||
@@ -89,7 +97,7 @@ const AutonomyPanel = () => {
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title="Agent autonomy"
|
||||
title={t('autonomy.title')}
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
@@ -99,12 +107,10 @@ const AutonomyPanel = () => {
|
||||
<label
|
||||
htmlFor="autonomy-max-actions"
|
||||
className="block text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
Max actions per hour
|
||||
{t('autonomy.maxActionsLabel')}
|
||||
</label>
|
||||
<p className="text-xs text-stone-600 dark:text-neutral-400 mt-1">
|
||||
Maximum tool actions an agent can run per rolling hour. New value applies to your next
|
||||
chat. Cron jobs and channel listeners keep their current limit until you restart
|
||||
OpenHuman.
|
||||
{t('autonomy.maxActionsHelp')}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
@@ -128,7 +134,7 @@ const AutonomyPanel = () => {
|
||||
onClick={onSave}
|
||||
disabled={!canSave}
|
||||
className="px-3 py-1.5 rounded-md bg-primary-600 hover:bg-primary-500 disabled:opacity-50 text-white text-xs font-medium transition-colors">
|
||||
{status.kind === 'saving' ? 'Saving…' : 'Save'}
|
||||
{status.kind === 'saving' ? t('autonomy.statusSaving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -138,7 +144,7 @@ const AutonomyPanel = () => {
|
||||
key={p.value}
|
||||
onClick={() => applyPreset(p.value)}
|
||||
className="px-2 py-1 rounded-md border border-stone-200 dark:border-neutral-800 text-xs text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800">
|
||||
{p.label}
|
||||
{p.labelKey ? t(p.labelKey) : p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -150,14 +156,21 @@ const AutonomyPanel = () => {
|
||||
className="mt-3 text-xs min-h-[1rem]">
|
||||
{!isValid && draft.trim() !== '' && (
|
||||
<span className="text-coral-600 dark:text-coral-300">
|
||||
Must be an integer between {MIN} and {MAX.toLocaleString()}.
|
||||
{t('autonomy.invalidIntegerMsg')}
|
||||
</span>
|
||||
)}
|
||||
{isValid && parsed === UNLIMITED && (
|
||||
<span className="text-stone-500 dark:text-neutral-400">
|
||||
{t('autonomy.unlimitedNote')}
|
||||
</span>
|
||||
)}
|
||||
{status.kind === 'saved' && (
|
||||
<span className="text-sage-700 dark:text-sage-300">Saved.</span>
|
||||
<span className="text-sage-700 dark:text-sage-300">{t('autonomy.statusSaved')}</span>
|
||||
)}
|
||||
{status.kind === 'error' && (
|
||||
<span className="text-coral-600 dark:text-coral-300">Failed: {status.message}</span>
|
||||
<span className="text-coral-600 dark:text-coral-300">
|
||||
{t('autonomy.statusFailed')}: {status.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -49,22 +49,6 @@ const developerItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'messaging',
|
||||
titleKey: 'settings.developerMenu.messagingChannels.title',
|
||||
descriptionKey: 'settings.developerMenu.messagingChannels.desc',
|
||||
route: 'messaging',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M21 11c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 19l1.395-3.72C3.512 14.042 3 12.574 3 11c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
titleKey: 'settings.developerMenu.tools.title',
|
||||
@@ -87,22 +71,6 @@ const developerItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'agent-chat',
|
||||
titleKey: 'settings.developerMenu.agentChat.title',
|
||||
descriptionKey: 'settings.developerMenu.agentChat.desc',
|
||||
route: 'agent-chat',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M21 11c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 19l1.395-3.72C3.512 14.042 3 12.574 3 11c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cron-jobs',
|
||||
titleKey: 'settings.developerMenu.cronJobs.title',
|
||||
@@ -120,17 +88,17 @@ const developerItems = [
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'local-model-debug',
|
||||
titleKey: 'settings.developerMenu.localModelDebug.title',
|
||||
descriptionKey: 'settings.developerMenu.localModelDebug.desc',
|
||||
route: 'local-model-debug',
|
||||
id: 'search',
|
||||
titleKey: 'settings.search.title',
|
||||
descriptionKey: 'settings.search.menuDesc',
|
||||
route: 'search',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
d="M21 21l-4.35-4.35M11 19a8 8 0 100-16 8 8 0 000 16z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
@@ -171,26 +139,10 @@ const developerItems = [
|
||||
// as a tab. The old `/settings/notification-routing` path now redirects to
|
||||
// `/settings/notifications#routing`, so deep links continue to work.
|
||||
{
|
||||
id: 'webhooks-triggers',
|
||||
titleKey: 'settings.developerMenu.composeioTriggers.title',
|
||||
descriptionKey: 'settings.developerMenu.composeioTriggers.desc',
|
||||
route: 'webhooks-triggers',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.828 10.172a4 4 0 010 5.656l-2 2a4 4 0 01-5.656-5.656l1-1m5-5a4 4 0 015.656 5.656l-1 1m-5 5l5-5"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'composio-routing',
|
||||
titleKey: 'settings.developerMenu.composioRouting.title',
|
||||
descriptionKey: 'settings.developerMenu.composioRouting.desc',
|
||||
route: 'composio-routing',
|
||||
id: 'composio',
|
||||
titleKey: 'settings.developerMenu.composio.title',
|
||||
descriptionKey: 'settings.developerMenu.composio.desc',
|
||||
route: 'composio',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -202,28 +154,6 @@ const developerItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'composio-triggers',
|
||||
titleKey: 'settings.developerMenu.integrationTriggers.title',
|
||||
descriptionKey: 'settings.developerMenu.integrationTriggers.desc',
|
||||
route: 'composio-triggers',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'mcp-server',
|
||||
titleKey: 'settings.developerMenu.mcpServer.title',
|
||||
@@ -240,22 +170,6 @@ const developerItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'autonomy',
|
||||
titleKey: 'settings.developerMenu.autonomy.title',
|
||||
descriptionKey: 'settings.developerMenu.autonomy.desc',
|
||||
route: 'autonomy',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const CoreModeBadge = () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import createDebug from 'debug';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { callCoreRpc } from '../../../services/coreRpcClient';
|
||||
import type { ToastNotification } from '../../../types/intelligence';
|
||||
import { ToastContainer } from '../../intelligence/Toast';
|
||||
@@ -131,6 +132,7 @@ function ConfirmRevokeDialog({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DevicesPanel = () => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
|
||||
const [devices, setDevices] = useState<PairedDevice[]>([]);
|
||||
@@ -259,9 +261,12 @@ const DevicesPanel = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="px-5 pb-3 text-xs text-stone-500">
|
||||
Pair iOS phones with this OpenHuman to use them as a remote client.
|
||||
</p>
|
||||
<div className="px-5 pb-3 flex items-center gap-2">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200 border border-amber-200 dark:border-amber-800/60">
|
||||
{t('devices.betaBadge')}
|
||||
</span>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400">{t('devices.betaText')}</p>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-5">
|
||||
{loading && (
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { type AISettings, loadAISettings } from '../../../services/api/aiSettingsApi';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
import { BackgroundLoopControls } from './AIPanel';
|
||||
|
||||
const HeartbeatPanel = () => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const [snapshot, setSnapshot] = useState<AISettings | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadAISettings()
|
||||
.then(s => {
|
||||
if (!cancelled) setSnapshot(s);
|
||||
})
|
||||
.catch(err => {
|
||||
if (!cancelled) setLoadError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title={t('settings.heartbeat.title')}
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
<div className="p-4">
|
||||
{loadError && (
|
||||
<div className="mb-3 rounded-md 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">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
{snapshot ? (
|
||||
<BackgroundLoopControls
|
||||
view="heartbeat"
|
||||
hideHeader
|
||||
routing={snapshot.routing}
|
||||
cloudProviders={snapshot.cloudProviders}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-xs text-stone-500 dark:text-neutral-400">{t('common.loading')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeartbeatPanel;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { type AISettings, loadAISettings } from '../../../services/api/aiSettingsApi';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
import { BackgroundLoopControls } from './AIPanel';
|
||||
|
||||
const LedgerUsagePanel = () => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const [snapshot, setSnapshot] = useState<AISettings | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadAISettings()
|
||||
.then(s => {
|
||||
if (!cancelled) setSnapshot(s);
|
||||
})
|
||||
.catch(err => {
|
||||
if (!cancelled) setLoadError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title={t('settings.ledgerUsage.title')}
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
<div className="p-4">
|
||||
{loadError && (
|
||||
<div className="mb-3 rounded-md 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">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
{snapshot ? (
|
||||
<BackgroundLoopControls
|
||||
view="ledger"
|
||||
hideHeader
|
||||
routing={snapshot.routing}
|
||||
cloudProviders={snapshot.cloudProviders}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-xs text-stone-500 dark:text-neutral-400">{t('common.loading')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LedgerUsagePanel;
|
||||
@@ -87,7 +87,14 @@ function buildSnippet(client: McpClient, binaryPath: string): string {
|
||||
// McpServerPanel component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const McpServerPanel = () => {
|
||||
interface McpServerPanelProps {
|
||||
/** When true, skips the SettingsHeader/back-button affordances so the
|
||||
* panel can be embedded in non-settings surfaces (e.g. the Connections
|
||||
* page MCP Clients tab). */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const McpServerPanel = ({ embedded = false }: McpServerPanelProps = {}) => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
|
||||
@@ -157,12 +164,14 @@ const McpServerPanel = () => {
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title={t('settings.mcpServer.title')}
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
{!embedded && (
|
||||
<SettingsHeader
|
||||
title={t('settings.mcpServer.title')}
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
{/* Section 1 — Available Tools */}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import channelConnectionsReducer from '../../../store/channelConnectionsSlice';
|
||||
|
||||
const navigateBack = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack,
|
||||
navigateToSettings: vi.fn(),
|
||||
navigateToTeamManagement: vi.fn(),
|
||||
breadcrumbs: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/SettingsHeader', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="settings-header">{title}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../channels/ChannelSetupModal', () => ({
|
||||
default: ({
|
||||
definition,
|
||||
onClose,
|
||||
}: {
|
||||
definition: { id: string; display_name: string };
|
||||
onClose: () => void;
|
||||
}) => (
|
||||
<div data-testid="channel-setup-modal" data-channel={definition.id}>
|
||||
<p>Setup: {definition.display_name}</p>
|
||||
<button type="button" onClick={onClose}>
|
||||
close
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const useChannelDefinitionsMock = vi.fn();
|
||||
vi.mock('../../../hooks/useChannelDefinitions', () => ({
|
||||
useChannelDefinitions: () => useChannelDefinitionsMock(),
|
||||
}));
|
||||
|
||||
const updatePreferencesMock = vi.fn();
|
||||
vi.mock('../../../services/api/channelConnectionsApi', () => ({
|
||||
channelConnectionsApi: { updatePreferences: (channel: string) => updatePreferencesMock(channel) },
|
||||
}));
|
||||
|
||||
const FIXTURE_DEFINITIONS = [
|
||||
{ id: 'telegram', display_name: 'Telegram', description: 'Chat via Telegram', icon: 'telegram' },
|
||||
{ id: 'discord', display_name: 'Discord', description: 'Chat via Discord', icon: 'discord' },
|
||||
{ id: 'web', display_name: 'Web', description: 'Browser-based chat', icon: 'web' },
|
||||
];
|
||||
|
||||
function buildStore(defaultChannel: 'telegram' | 'discord' | 'web' = 'telegram') {
|
||||
const preloadedState = {
|
||||
channelConnections: {
|
||||
defaultMessagingChannel: defaultChannel,
|
||||
connections: { telegram: {}, discord: {}, web: {} },
|
||||
migrationCompleted: true,
|
||||
},
|
||||
} as unknown as Parameters<typeof configureStore>[0]['preloadedState'];
|
||||
return configureStore({
|
||||
reducer: { channelConnections: channelConnectionsReducer },
|
||||
preloadedState,
|
||||
});
|
||||
}
|
||||
|
||||
async function importPanel() {
|
||||
vi.resetModules();
|
||||
const mod = await import('./MessagingPanel');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
function renderPanel(Panel: React.ComponentType, store = buildStore()) {
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<Panel />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('<MessagingPanel />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useChannelDefinitionsMock.mockReturnValue({
|
||||
definitions: FIXTURE_DEFINITIONS,
|
||||
loading: false,
|
||||
error: null,
|
||||
refreshDefinitions: vi.fn(),
|
||||
});
|
||||
updatePreferencesMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('shows the loading state from useChannelDefinitions', async () => {
|
||||
useChannelDefinitionsMock.mockReturnValue({
|
||||
definitions: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
refreshDefinitions: vi.fn(),
|
||||
});
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
expect(screen.getByText('Loading channel definitions...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces the load error returned by the channel definitions hook', async () => {
|
||||
useChannelDefinitionsMock.mockReturnValue({
|
||||
definitions: [],
|
||||
loading: false,
|
||||
error: 'backend unreachable',
|
||||
refreshDefinitions: vi.fn(),
|
||||
});
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
expect(screen.getByText('backend unreachable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders one button per definition for the default selector and excludes `web` from connections', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
// The default selector shows ALL definitions (telegram, discord, web).
|
||||
const defaultButtons = screen.getAllByRole('button', { name: /^Telegram$|^Discord$|^Web$/ });
|
||||
expect(defaultButtons.map(btn => btn.textContent)).toEqual(
|
||||
expect.arrayContaining(['Telegram', 'Discord', 'Web'])
|
||||
);
|
||||
|
||||
// The "Channel Connections" cards filter out `web` per the panel's
|
||||
// configurableChannels memo, so the configurable rows are only
|
||||
// telegram + discord.
|
||||
const connectionRows = screen.getAllByRole('button', { name: /Chat via (Telegram|Discord)/ });
|
||||
expect(connectionRows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('opens the ChannelSetupModal for the clicked channel and closes it', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
const telegramRow = screen.getByRole('button', { name: /Chat via Telegram/ });
|
||||
fireEvent.click(telegramRow);
|
||||
|
||||
const modal = await screen.findByTestId('channel-setup-modal');
|
||||
expect(modal).toHaveAttribute('data-channel', 'telegram');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('channel-setup-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking a default-channel button calls updatePreferences for that channel', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderPanel(Panel);
|
||||
|
||||
// discord is not currently the default; clicking selects it.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discord' }));
|
||||
await waitFor(() => expect(updatePreferencesMock).toHaveBeenCalledWith('discord'));
|
||||
});
|
||||
});
|
||||
@@ -1,246 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useChannelDefinitions } from '../../../hooks/useChannelDefinitions';
|
||||
import { resolvePreferredAuthModeForChannel } from '../../../lib/channels/routing';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { channelConnectionsApi } from '../../../services/api/channelConnectionsApi';
|
||||
import { setDefaultMessagingChannel } from '../../../store/channelConnectionsSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import type {
|
||||
ChannelConnectionStatus,
|
||||
ChannelDefinition,
|
||||
ChannelType,
|
||||
} from '../../../types/channels';
|
||||
import ChannelSetupModal from '../../channels/ChannelSetupModal';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
/**
|
||||
* Mapping from `ChannelDefinition.icon` slugs to the emoji rendered next to
|
||||
* each channel in the Messaging settings panel. Exported so unit tests can
|
||||
* assert against it without rendering the full panel (the panel pulls in
|
||||
* Redux, i18n, routing, and `useChannelDefinitions`, all of which make a
|
||||
* focused render test more expensive than a direct mapping assertion).
|
||||
* Keep in sync with the icon slugs returned by the backend
|
||||
* `channels::controllers::definitions::all_channel_definitions`.
|
||||
*/
|
||||
export const CHANNEL_ICONS: Record<string, string> = {
|
||||
telegram: '✈️',
|
||||
discord: '🎮',
|
||||
web: '🌐',
|
||||
// Lark (国际版) / Feishu (中国版) — same backend, single icon. See #2048.
|
||||
lark: '🪶',
|
||||
// DingTalk (钉钉). See #2048.
|
||||
dingtalk: '🔔',
|
||||
};
|
||||
|
||||
function statusDot(status: ChannelConnectionStatus): string {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'bg-sage-500';
|
||||
case 'connecting':
|
||||
return 'bg-amber-500 animate-pulse';
|
||||
case 'error':
|
||||
return 'bg-coral-500';
|
||||
default:
|
||||
return 'bg-stone-300 dark:bg-neutral-700';
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: ChannelConnectionStatus, t: (key: string) => string): string {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return t('channels.status.connected');
|
||||
case 'connecting':
|
||||
return t('channels.status.connecting');
|
||||
case 'error':
|
||||
return t('channels.status.error');
|
||||
default:
|
||||
return t('channels.status.notConfigured');
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: ChannelConnectionStatus): string {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'text-sage-600 dark:text-sage-300';
|
||||
case 'connecting':
|
||||
return 'text-amber-600 dark:text-amber-300';
|
||||
case 'error':
|
||||
return 'text-coral-600 dark:text-coral-300';
|
||||
default:
|
||||
return 'text-stone-400 dark:text-neutral-500';
|
||||
}
|
||||
}
|
||||
|
||||
const MessagingPanel = () => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const dispatch = useAppDispatch();
|
||||
const channelConnections = useAppSelector(state => state.channelConnections);
|
||||
const { definitions, loading, error: loadError } = useChannelDefinitions();
|
||||
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({});
|
||||
const [channelModalDef, setChannelModalDef] = useState<ChannelDefinition | null>(null);
|
||||
|
||||
const configurableChannels = useMemo(
|
||||
() => definitions.filter(d => d.id !== 'web'),
|
||||
[definitions]
|
||||
);
|
||||
|
||||
const recommendedRoute = useMemo(() => {
|
||||
const channel = channelConnections.defaultMessagingChannel;
|
||||
const authMode = resolvePreferredAuthModeForChannel(channelConnections, channel);
|
||||
return authMode
|
||||
? t('channels.activeRouteValue').replace('{channel}', channel).replace('{authMode}', authMode)
|
||||
: t('channels.noActiveRoute');
|
||||
}, [channelConnections, t]);
|
||||
|
||||
const bestStatus = useCallback(
|
||||
(channelId: ChannelType): ChannelConnectionStatus => {
|
||||
const conns = channelConnections.connections[channelId];
|
||||
if (!conns) return 'disconnected';
|
||||
const statuses = Object.values(conns).map(c => c?.status ?? 'disconnected');
|
||||
if (statuses.includes('connected')) return 'connected';
|
||||
if (statuses.includes('connecting')) return 'connecting';
|
||||
if (statuses.includes('error')) return 'error';
|
||||
return 'disconnected';
|
||||
},
|
||||
[channelConnections]
|
||||
);
|
||||
|
||||
const handleSetDefaultChannel = useCallback(
|
||||
(channel: ChannelType) => {
|
||||
const key = `default:${channel}`;
|
||||
setBusy(prev => ({ ...prev, [key]: true }));
|
||||
dispatch(setDefaultMessagingChannel(channel));
|
||||
void channelConnectionsApi.updatePreferences(channel).finally(() => {
|
||||
setBusy(prev => ({ ...prev, [key]: false }));
|
||||
});
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t('settings.features.messaging')}
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Default channel selector */}
|
||||
<section className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('channels.defaultMessaging')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{definitions.map(def => {
|
||||
const channelId = def.id as ChannelType;
|
||||
const selected = channelConnections.defaultMessagingChannel === channelId;
|
||||
const busyKey = `default:${channelId}`;
|
||||
return (
|
||||
<button
|
||||
key={channelId}
|
||||
type="button"
|
||||
onClick={() => handleSetDefaultChannel(channelId)}
|
||||
disabled={busy[busyKey]}
|
||||
className={`rounded-lg border px-3 py-2 text-sm transition-colors ${
|
||||
selected
|
||||
? 'border-primary-500/60 bg-primary-50 dark:bg-primary-500/10 text-primary-600 dark:text-primary-300'
|
||||
: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:border-neutral-700 dark:hover:border-neutral-700'
|
||||
}`}>
|
||||
{def.display_name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-stone-400 dark:text-neutral-500">
|
||||
{t('channels.activeRoute')}:{' '}
|
||||
<span className="text-primary-600 dark:text-primary-300">{recommendedRoute}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-coral-500/40 bg-coral-500/10 px-4 py-3 text-sm text-coral-100">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 text-sm text-stone-400 dark:text-neutral-500">
|
||||
{t('channels.loadingDefinitions')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel cards — click to open the shared ChannelSetupModal */}
|
||||
{!loading && (
|
||||
<section className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('channels.channelConnections')}
|
||||
</h3>
|
||||
<p className="text-xs text-stone-400 dark:text-neutral-500">
|
||||
{t('channels.configureAuthModes')}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{configurableChannels.map(def => {
|
||||
const channelId = def.id as ChannelType;
|
||||
const status = bestStatus(channelId);
|
||||
const icon = CHANNEL_ICONS[def.icon] ?? '';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={channelId}
|
||||
type="button"
|
||||
onClick={() => setChannelModalDef(def)}
|
||||
className="w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 text-left transition-colors hover:bg-white dark:bg-neutral-900 dark:hover:bg-neutral-800 hover:border-stone-300 dark:border-neutral-700 dark:hover:border-neutral-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg flex-shrink-0">{icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
|
||||
{def.display_name}
|
||||
</span>
|
||||
<div
|
||||
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${statusDot(status)}`}
|
||||
/>
|
||||
<span className={`text-xs ${statusColor(status)}`}>
|
||||
{statusLabel(status, t)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
|
||||
{def.description}
|
||||
</p>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-400 dark:text-neutral-500 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shared channel config modal */}
|
||||
{channelModalDef && (
|
||||
<ChannelSetupModal definition={channelModalDef} onClose={() => setChannelModalDef(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessagingPanel;
|
||||
@@ -0,0 +1,338 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import {
|
||||
openhumanGetSearchSettings,
|
||||
openhumanUpdateSearchSettings,
|
||||
type SearchEngineId,
|
||||
type SearchSettings,
|
||||
} from '../../../utils/tauriCommands/config';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
type Status =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'saving' }
|
||||
| { kind: 'saved' }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
interface EngineOption {
|
||||
id: SearchEngineId;
|
||||
label: string;
|
||||
description: string;
|
||||
requiresKey: boolean;
|
||||
}
|
||||
|
||||
const SearchPanel = () => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
|
||||
const [settings, setSettings] = useState<SearchSettings | null>(null);
|
||||
const [status, setStatus] = useState<Status>({ kind: 'loading' });
|
||||
const [parallelKey, setParallelKey] = useState<string>('');
|
||||
const [braveKey, setBraveKey] = useState<string>('');
|
||||
const [showParallel, setShowParallel] = useState(false);
|
||||
const [showBrave, setShowBrave] = useState(false);
|
||||
|
||||
const ENGINES: EngineOption[] = [
|
||||
{
|
||||
id: 'managed',
|
||||
label: t('settings.search.engineManagedLabel'),
|
||||
description: t('settings.search.engineManagedDesc'),
|
||||
requiresKey: false,
|
||||
},
|
||||
{
|
||||
id: 'parallel',
|
||||
label: t('settings.search.engineParallelLabel'),
|
||||
description: t('settings.search.engineParallelDesc'),
|
||||
requiresKey: true,
|
||||
},
|
||||
{
|
||||
id: 'brave',
|
||||
label: t('settings.search.engineBraveLabel'),
|
||||
description: t('settings.search.engineBraveDesc'),
|
||||
requiresKey: true,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
openhumanGetSearchSettings()
|
||||
.then(res => {
|
||||
if (cancelled) return;
|
||||
setSettings(res.result);
|
||||
setStatus({ kind: 'idle' });
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return;
|
||||
setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectedEngine = (settings?.engine as SearchEngineId | undefined) ?? 'managed';
|
||||
|
||||
const persistEngine = async (next: SearchEngineId) => {
|
||||
if (!settings || status.kind === 'saving') return;
|
||||
const previous = settings;
|
||||
setSettings({ ...settings, engine: next });
|
||||
setStatus({ kind: 'saving' });
|
||||
try {
|
||||
await openhumanUpdateSearchSettings({ engine: next });
|
||||
const refreshed = await openhumanGetSearchSettings();
|
||||
setSettings(refreshed.result);
|
||||
setStatus({ kind: 'saved' });
|
||||
} catch (err) {
|
||||
setSettings(previous);
|
||||
setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
};
|
||||
|
||||
const persistKey = async (engine: 'parallel' | 'brave', rawKey: string) => {
|
||||
if (!settings) return;
|
||||
setStatus({ kind: 'saving' });
|
||||
try {
|
||||
await openhumanUpdateSearchSettings(
|
||||
engine === 'parallel' ? { parallel_api_key: rawKey } : { brave_api_key: rawKey }
|
||||
);
|
||||
const refreshed = await openhumanGetSearchSettings();
|
||||
setSettings(refreshed.result);
|
||||
if (engine === 'parallel') setParallelKey('');
|
||||
else setBraveKey('');
|
||||
setStatus({ kind: 'saved' });
|
||||
} catch (err) {
|
||||
setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
};
|
||||
|
||||
const isConfigured = (engine: SearchEngineId): boolean => {
|
||||
if (!settings) return false;
|
||||
if (engine === 'managed') return true;
|
||||
if (engine === 'parallel') return settings.parallel_configured;
|
||||
if (engine === 'brave') return settings.brave_configured;
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title={t('settings.search.title')}
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
|
||||
{t('settings.search.description')}
|
||||
</p>
|
||||
|
||||
{status.kind === 'loading' && (
|
||||
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings && (
|
||||
<>
|
||||
<div
|
||||
className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden"
|
||||
role="radiogroup"
|
||||
aria-label={t('settings.search.engineAria')}>
|
||||
{ENGINES.map((opt, idx) => {
|
||||
const selected = opt.id === selectedEngine;
|
||||
const configured = isConfigured(opt.id);
|
||||
const blocked = opt.requiresKey && !configured && selected;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => void persistEngine(opt.id)}
|
||||
className={`w-full flex items-start gap-3 px-4 py-3 text-left transition-colors focus:outline-none focus-visible:bg-primary-50 dark:focus-visible:bg-primary-900/30 ${
|
||||
idx !== 0 ? 'border-t border-neutral-100 dark:border-neutral-800' : ''
|
||||
} ${
|
||||
selected
|
||||
? 'bg-primary-50 dark:bg-primary-500/10'
|
||||
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/60'
|
||||
}`}>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{opt.label}
|
||||
</span>
|
||||
{opt.requiresKey && (
|
||||
<span
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider ${
|
||||
configured
|
||||
? 'bg-sage-100 text-sage-700 dark:bg-sage-900/40 dark:text-sage-200'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
|
||||
}`}>
|
||||
{configured
|
||||
? t('settings.search.statusConfigured')
|
||||
: t('settings.search.statusNeedsKey')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{opt.description}
|
||||
</span>
|
||||
{blocked && (
|
||||
<span className="block mt-1 text-[11px] text-amber-700 dark:text-amber-300">
|
||||
{t('settings.search.fallbackToManaged')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{selected && (
|
||||
<svg
|
||||
className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* BYO API keys */}
|
||||
<div className="space-y-3">
|
||||
<KeyEditor
|
||||
label={t('settings.search.parallelKeyLabel')}
|
||||
placeholder={
|
||||
settings.parallel_configured
|
||||
? t('settings.search.placeholderStored')
|
||||
: t('settings.search.placeholderParallel')
|
||||
}
|
||||
show={showParallel}
|
||||
onToggleShow={() => setShowParallel(s => !s)}
|
||||
value={parallelKey}
|
||||
onChange={setParallelKey}
|
||||
onSave={() => void persistKey('parallel', parallelKey)}
|
||||
onClear={() => void persistKey('parallel', '')}
|
||||
configured={settings.parallel_configured}
|
||||
docUrl="https://parallel.ai/"
|
||||
t={t}
|
||||
/>
|
||||
<KeyEditor
|
||||
label={t('settings.search.braveKeyLabel')}
|
||||
placeholder={
|
||||
settings.brave_configured
|
||||
? t('settings.search.placeholderStored')
|
||||
: t('settings.search.placeholderBrave')
|
||||
}
|
||||
show={showBrave}
|
||||
onToggleShow={() => setShowBrave(s => !s)}
|
||||
value={braveKey}
|
||||
onChange={setBraveKey}
|
||||
onSave={() => void persistKey('brave', braveKey)}
|
||||
onClear={() => void persistKey('brave', '')}
|
||||
configured={settings.brave_configured}
|
||||
docUrl="https://brave.com/search/api/"
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="text-xs min-h-[1rem] text-stone-500 dark:text-neutral-400">
|
||||
{status.kind === 'saving' && t('settings.search.statusSaving')}
|
||||
{status.kind === 'saved' && t('settings.search.statusSaved')}
|
||||
{status.kind === 'error' && (
|
||||
<span className="text-coral-600 dark:text-coral-300">
|
||||
{t('settings.search.statusError')}: {status.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface KeyEditorProps {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
show: boolean;
|
||||
onToggleShow: () => void;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onSave: () => void;
|
||||
onClear: () => void;
|
||||
configured: boolean;
|
||||
docUrl: string;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const KeyEditor = ({
|
||||
label,
|
||||
placeholder,
|
||||
show,
|
||||
onToggleShow,
|
||||
value,
|
||||
onChange,
|
||||
onSave,
|
||||
onClear,
|
||||
configured,
|
||||
docUrl,
|
||||
t,
|
||||
}: KeyEditorProps) => (
|
||||
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs font-semibold text-stone-700 dark:text-neutral-200">{label}</label>
|
||||
<a
|
||||
href={docUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-primary-500 hover:underline">
|
||||
{t('settings.search.getApiKey')} ↗
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type={show ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 min-w-0 px-2 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleShow}
|
||||
className="px-2 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 text-xs text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800">
|
||||
{show ? t('settings.search.hide') : t('settings.search.show')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={value.trim().length === 0}
|
||||
className="px-3 py-1.5 rounded-md bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium disabled:opacity-50">
|
||||
{t('settings.search.save')}
|
||||
</button>
|
||||
{configured && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="px-2 py-1.5 rounded-md border border-coral-200 dark:border-coral-500/30 text-xs text-coral-600 dark:text-coral-300 hover:bg-coral-50 dark:hover:bg-coral-500/10">
|
||||
{t('settings.search.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SearchPanel;
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
openhumanHeartbeatSettingsSet,
|
||||
openhumanHeartbeatTickNow,
|
||||
} from '../../../../utils/tauriCommands/heartbeat';
|
||||
import AIPanel from '../AIPanel';
|
||||
import AIPanel, { BackgroundLoopControls } from '../AIPanel';
|
||||
|
||||
vi.mock('../../../../services/api/aiSettingsApi', () => ({
|
||||
ALL_WORKLOADS: [
|
||||
@@ -725,7 +725,14 @@ describe('AIPanel', () => {
|
||||
});
|
||||
|
||||
it('renders background loop diagnostics with newest spend row and budget math', async () => {
|
||||
renderWithProviders(<AIPanel />);
|
||||
// BackgroundLoopControls was moved out of AIPanel into standalone panels.
|
||||
renderWithProviders(
|
||||
<BackgroundLoopControls
|
||||
view="all"
|
||||
routing={baseSettings.routing}
|
||||
cloudProviders={baseSettings.cloudProviders}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Background loops')).toBeInTheDocument());
|
||||
|
||||
@@ -776,7 +783,14 @@ describe('AIPanel', () => {
|
||||
return { result: { settings: currentSettings }, logs: [] };
|
||||
});
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
// BackgroundLoopControls was moved out of AIPanel into standalone panels.
|
||||
renderWithProviders(
|
||||
<BackgroundLoopControls
|
||||
view="all"
|
||||
routing={baseSettings.routing}
|
||||
cloudProviders={baseSettings.cloudProviders}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText('Heartbeat controls')).toBeInTheDocument());
|
||||
|
||||
const clickToggle = async (label: string, expectedPatch: Record<string, unknown>) => {
|
||||
@@ -835,7 +849,14 @@ describe('AIPanel', () => {
|
||||
vi.mocked(openhumanHeartbeatSettingsGet).mockRejectedValueOnce(new Error('heartbeat offline'));
|
||||
vi.mocked(openhumanHeartbeatTickNow).mockRejectedValueOnce(new Error('tick failed'));
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
// BackgroundLoopControls was moved out of AIPanel into standalone panels.
|
||||
renderWithProviders(
|
||||
<BackgroundLoopControls
|
||||
view="all"
|
||||
routing={baseSettings.routing}
|
||||
cloudProviders={baseSettings.cloudProviders}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('heartbeat offline')).toBeInTheDocument());
|
||||
expect(screen.getByText('Heartbeat controls unavailable.')).toBeInTheDocument();
|
||||
|
||||
@@ -33,6 +33,13 @@ vi.mock('../../../../utils/tauriCommands', () => ({
|
||||
|
||||
vi.mock('../../../../utils/openUrl', () => ({ openUrl: hoisted.mockOpenUrl }));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
// AboutPanel calls invoke<string>('core_rpc_url') in a useEffect.
|
||||
// Return a resolved Promise so .then() doesn't throw.
|
||||
invoke: vi.fn(() => Promise.resolve(null)),
|
||||
isTauri: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
listen: vi.fn((event: string, handler: (event: { payload: string }) => void) => {
|
||||
if (event === 'app-update:status') {
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('AutonomyPanel', () => {
|
||||
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
|
||||
const input = await screen.findByDisplayValue('20');
|
||||
fireEvent.change(input, { target: { value: '0' } });
|
||||
await screen.findByText(/Must be an integer between 1 and 10,000/i);
|
||||
await screen.findByText(/Must be a positive integer/i);
|
||||
expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('AutonomyPanel', () => {
|
||||
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
|
||||
const input = await screen.findByDisplayValue('20');
|
||||
fireEvent.change(input, { target: { value } });
|
||||
await screen.findByText(/Must be an integer between 1 and 10,000/i);
|
||||
await screen.findByText(/Must be a positive integer/i);
|
||||
expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
|
||||
@@ -113,8 +113,8 @@ describe('DeveloperOptionsPanel — CoreModeBadge', () => {
|
||||
|
||||
expect(screen.getByText('AI 配置')).toBeInTheDocument();
|
||||
expect(screen.getByText('屏幕感知')).toBeInTheDocument();
|
||||
expect(screen.getByText('消息渠道')).toBeInTheDocument();
|
||||
expect(screen.getByText('配置 Telegram/Discord 认证模式和默认渠道路由')).toBeInTheDocument();
|
||||
// The messaging tile was removed; composio replaced it as a single destination.
|
||||
expect(screen.getByText('Composio')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CHANNEL_ICONS } from '../MessagingPanel';
|
||||
|
||||
describe('MessagingPanel CHANNEL_ICONS', () => {
|
||||
it('includes icons for every shipped channel slug', () => {
|
||||
// The backend `channels::controllers::definitions::all_channel_definitions`
|
||||
// emits these icon slugs; the Messaging panel must render an emoji for
|
||||
// each or the channel row gets a blank gap. Pin them by key so a renamed
|
||||
// slug on either side fails this test instead of a silent visual break.
|
||||
expect(CHANNEL_ICONS.telegram).toBe('✈️');
|
||||
expect(CHANNEL_ICONS.discord).toBe('🎮');
|
||||
expect(CHANNEL_ICONS.web).toBe('🌐');
|
||||
});
|
||||
|
||||
it('includes Lark/Feishu and DingTalk icons (#2048)', () => {
|
||||
// Regression for #2048 — adding the channel definitions without the
|
||||
// matching icon entries produced a blank chip next to each channel in
|
||||
// the Messaging settings panel.
|
||||
expect(CHANNEL_ICONS.lark).toBe('🪶');
|
||||
expect(CHANNEL_ICONS.dingtalk).toBe('🔔');
|
||||
});
|
||||
|
||||
it('has no duplicate emoji values (icons remain visually distinct)', () => {
|
||||
// Two channels sharing the same emoji would make their rows visually
|
||||
// indistinguishable in the panel. Asserting uniqueness here catches
|
||||
// the easy copy-paste mistake at test time.
|
||||
const values = Object.values(CHANNEL_ICONS);
|
||||
const unique = new Set(values);
|
||||
expect(unique.size).toBe(values.length);
|
||||
});
|
||||
});
|
||||
@@ -136,8 +136,11 @@ describe('HumanPage — speak-replies localStorage persistence', () => {
|
||||
expect(
|
||||
screen.getByRole('status', { name: /researcher subagent running/i })
|
||||
).toBeInTheDocument();
|
||||
// The bubble renders only the label; activity moved to the title tooltip.
|
||||
expect(screen.getByText('Researcher')).toBeInTheDocument();
|
||||
expect(screen.getByText('Iteration 1/3')).toBeInTheDocument();
|
||||
// Activity is in the title attribute of the bubble, not visible body text.
|
||||
const bubble = screen.getByTestId('sub-mascot-bubble');
|
||||
expect(bubble).toHaveAttribute('title', expect.stringContaining('Iteration 1/3'));
|
||||
});
|
||||
|
||||
it('renders a custom GIF mascot when one is configured', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type ComponentType, type FC, useMemo } from 'react';
|
||||
|
||||
import type { MascotFace } from './Ghosty';
|
||||
import type { MascotColor } from './mascotPalette';
|
||||
import { FrameProvider } from './yellow/frameContext';
|
||||
import { FrameProvider, StaticFrameProvider } from './yellow/frameContext';
|
||||
import type { MascotProps as YellowMascotInnerProps } from './yellow/MascotCharacter';
|
||||
import { YellowMascotIdle } from './yellow/MascotIdle';
|
||||
import { YellowMascotTalking } from './yellow/MascotTalking';
|
||||
@@ -21,6 +21,10 @@ export interface YellowMascotProps {
|
||||
compactArmShading?: boolean;
|
||||
/** Mascot color palette. Defaults to yellow. */
|
||||
mascotColor?: MascotColor;
|
||||
/** Render a static (non-animated) pose. Skips the rAF tick used by
|
||||
* the default animated FrameProvider so decorative instances
|
||||
* (e.g. subagent indicators) don't churn frames. */
|
||||
static?: boolean;
|
||||
}
|
||||
|
||||
const FPS = 30;
|
||||
@@ -94,6 +98,7 @@ export const YellowMascot: FC<YellowMascotProps> = ({
|
||||
groundShadowOpacity,
|
||||
compactArmShading,
|
||||
mascotColor = 'yellow',
|
||||
static: isStatic = false,
|
||||
}) => {
|
||||
const { Component, inputProps } = useMemo(() => {
|
||||
const variant = variantForFace(face, arm, { mascotColor });
|
||||
@@ -122,13 +127,18 @@ export const YellowMascot: FC<YellowMascotProps> = ({
|
||||
<style>{`
|
||||
.mascot-yellow-host svg { width: 100% !important; height: 100% !important; }
|
||||
`}</style>
|
||||
<FrameProvider
|
||||
fps={FPS}
|
||||
width={CANVAS}
|
||||
height={CANVAS}
|
||||
durationInFrames={face === 'sleep' ? FPS * 600 : DURATION_FRAMES}>
|
||||
<Component {...inputProps} />
|
||||
</FrameProvider>
|
||||
{(() => {
|
||||
const Provider = isStatic ? StaticFrameProvider : FrameProvider;
|
||||
return (
|
||||
<Provider
|
||||
fps={FPS}
|
||||
width={CANVAS}
|
||||
height={CANVAS}
|
||||
durationInFrames={face === 'sleep' ? FPS * 600 : DURATION_FRAMES}>
|
||||
<Component {...inputProps} />
|
||||
</Provider>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -82,3 +82,28 @@ export const FrameProvider: FC<FrameProviderProps> = ({
|
||||
</FrameConfigContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Static variant of {@link FrameProvider} — pins the frame at 0 and never
|
||||
* schedules a requestAnimationFrame. Use this for decorative mascot
|
||||
* instances (e.g. small subagent indicators) where motion would be
|
||||
* distracting and the per-frame rAF cost across N mascots is wasteful.
|
||||
*/
|
||||
export const StaticFrameProvider: FC<FrameProviderProps> = ({
|
||||
fps,
|
||||
width,
|
||||
height,
|
||||
durationInFrames,
|
||||
children,
|
||||
}) => {
|
||||
const config = useMemo<FrameConfig>(
|
||||
() => ({ fps, width, height, durationInFrames }),
|
||||
[fps, width, height, durationInFrames]
|
||||
);
|
||||
|
||||
return (
|
||||
<FrameConfigContext.Provider value={config}>
|
||||
<FrameContext.Provider value={0}>{children}</FrameContext.Provider>
|
||||
</FrameConfigContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -322,6 +322,8 @@ describe('MicComposer', () => {
|
||||
|
||||
// ── Device selector (showDeviceSelector) ─────────────────────────────────
|
||||
|
||||
// ── Device selector: gear FAB + portaled menu (replaced <select> combobox) ──
|
||||
|
||||
it('enumerates devices on mount when showDeviceSelector is true', async () => {
|
||||
const enumerateDevicesMock = vi.fn().mockResolvedValue([
|
||||
{ kind: 'audioinput', deviceId: 'dev1', label: 'Built-in Mic' },
|
||||
@@ -337,11 +339,15 @@ describe('MicComposer', () => {
|
||||
render(<MicComposer disabled={false} onSubmit={vi.fn()} showDeviceSelector />);
|
||||
|
||||
await waitFor(() => expect(enumerateDevicesMock).toHaveBeenCalled());
|
||||
expect(await screen.findByRole('combobox', { name: /microphone device/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Built-in Mic')).toBeInTheDocument();
|
||||
expect(screen.getByText('USB Headset')).toBeInTheDocument();
|
||||
// Video input must not appear
|
||||
expect(screen.queryByText('Camera')).not.toBeInTheDocument();
|
||||
// The gear FAB is shown when devices.length > 1.
|
||||
const gearBtn = await screen.findByLabelText(/Microphone device/i);
|
||||
expect(gearBtn).toBeInTheDocument();
|
||||
// Open the menu to see device items.
|
||||
fireEvent.click(gearBtn);
|
||||
expect(await screen.findByRole('menuitemradio', { name: /Built-in Mic/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitemradio', { name: /USB Headset/i })).toBeInTheDocument();
|
||||
// Video input must not appear.
|
||||
expect(screen.queryByRole('menuitemradio', { name: /Camera/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the selector when showDeviceSelector is false (default)', async () => {
|
||||
@@ -358,14 +364,12 @@ describe('MicComposer', () => {
|
||||
render(<MicComposer disabled={false} onSubmit={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('combobox', { name: /microphone device/i })
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Microphone device/i)).not.toBeInTheDocument();
|
||||
expect(enumerateDevicesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the selector disabled when only one device is available', async () => {
|
||||
it('does not show the gear FAB when only one device is available', async () => {
|
||||
const enumerateDevicesMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ kind: 'audioinput', deviceId: 'dev1', label: 'Built-in Mic' }]);
|
||||
@@ -377,9 +381,9 @@ describe('MicComposer', () => {
|
||||
|
||||
render(<MicComposer disabled={false} onSubmit={vi.fn()} showDeviceSelector />);
|
||||
|
||||
const select = await screen.findByRole('combobox', { name: /microphone device/i });
|
||||
expect(select).toBeInTheDocument();
|
||||
expect(select).toBeDisabled();
|
||||
await waitFor(() => expect(enumerateDevicesMock).toHaveBeenCalled());
|
||||
// With only one device the gear FAB is not rendered at all.
|
||||
expect(screen.queryByLabelText(/Microphone device/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to "Microphone N" label when browser hides labels before permission', async () => {
|
||||
@@ -395,9 +399,14 @@ describe('MicComposer', () => {
|
||||
|
||||
render(<MicComposer disabled={false} onSubmit={vi.fn()} showDeviceSelector />);
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('combobox')).toBeInTheDocument());
|
||||
expect(screen.getByText('Microphone 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Microphone 2')).toBeInTheDocument();
|
||||
// Gear FAB appears when there are >1 devices.
|
||||
const gearBtn = await screen.findByLabelText(/Microphone device/i);
|
||||
expect(gearBtn).toBeInTheDocument();
|
||||
fireEvent.click(gearBtn);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('menuitemradio', { name: /Microphone 1/i })).toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByRole('menuitemradio', { name: /Microphone 2/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes the selected deviceId as an exact constraint to getUserMedia', async () => {
|
||||
@@ -414,9 +423,11 @@ describe('MicComposer', () => {
|
||||
|
||||
render(<MicComposer disabled={false} onSubmit={vi.fn()} showDeviceSelector />);
|
||||
|
||||
// Wait for the selector to appear and pick the second device
|
||||
const select = await screen.findByRole('combobox', { name: /microphone device/i });
|
||||
fireEvent.change(select, { target: { value: 'dev2' } });
|
||||
// Open the gear menu and pick the second device.
|
||||
const gearBtn = await screen.findByLabelText(/Microphone device/i);
|
||||
fireEvent.click(gearBtn);
|
||||
const usbOption = await screen.findByRole('menuitemradio', { name: /USB Headset/i });
|
||||
fireEvent.click(usbOption);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
|
||||
await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled());
|
||||
@@ -448,19 +459,30 @@ describe('MicComposer', () => {
|
||||
|
||||
render(<MicComposer disabled={false} onSubmit={vi.fn()} showDeviceSelector />);
|
||||
|
||||
// Mount enumerate ran — labels are blank placeholders
|
||||
await waitFor(() => expect(screen.queryByRole('combobox')).toBeInTheDocument());
|
||||
expect(screen.getByText('Microphone 1')).toBeInTheDocument();
|
||||
// Mount enumerate ran — labels are blank placeholders; gear FAB visible.
|
||||
const gearBtn = await screen.findByLabelText(/Microphone device/i);
|
||||
expect(gearBtn).toBeInTheDocument();
|
||||
|
||||
// Start recording → triggers the post-permission refresh
|
||||
// Start recording → triggers the post-permission enumerate refresh.
|
||||
fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument()
|
||||
);
|
||||
// enumerateDevices should have been called again (post-permission refresh).
|
||||
await waitFor(() => expect(enumerateDevicesMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
// After permission, real labels should now be visible
|
||||
await waitFor(() => expect(screen.getByText('Built-in Mic')).toBeInTheDocument());
|
||||
expect(screen.getByText('USB Headset')).toBeInTheDocument();
|
||||
// Stop recording so the gear button is re-enabled (disabled while recording).
|
||||
fireEvent.click(screen.getByRole('button', { name: /stop recording and send/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /start recording/i })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
// Open the menu to verify real labels are now shown.
|
||||
fireEvent.click(gearBtn);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('menuitemradio', { name: /Built-in Mic/i })).toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByRole('menuitemradio', { name: /USB Headset/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles enumerateDevices throwing gracefully (no crash, selector hidden)', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import debug from 'debug';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { transcribeWithFactory } from './voice/sttClient';
|
||||
@@ -43,6 +44,9 @@ export interface MicComposerProps {
|
||||
language?: string;
|
||||
/** Show a microphone device selector beneath the button. Defaults to false. */
|
||||
showDeviceSelector?: boolean;
|
||||
/** When provided, renders a keyboard FAB next to the gear that switches the
|
||||
* surrounding composer back to text input. */
|
||||
onSwitchToText?: () => void;
|
||||
}
|
||||
|
||||
type RecordingState = 'idle' | 'recording' | 'transcribing';
|
||||
@@ -66,11 +70,15 @@ export function MicComposer({
|
||||
onError,
|
||||
language = 'en',
|
||||
showDeviceSelector = false,
|
||||
onSwitchToText,
|
||||
}: MicComposerProps) {
|
||||
const { t } = useT();
|
||||
const [state, setState] = useState<RecordingState>('idle');
|
||||
const [devices, setDevices] = useState<AudioInputDevice[]>([]);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('');
|
||||
const [deviceMenuOpen, setDeviceMenuOpen] = useState(false);
|
||||
const gearButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [menuAnchor, setMenuAnchor] = useState<{ top: number; left: number } | null>(null);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
@@ -430,23 +438,11 @@ export function MicComposer({
|
||||
? t('mic.waitingForAgent')
|
||||
: t('mic.tapAndSpeak');
|
||||
|
||||
const showDeviceMenuFab = showDeviceSelector && devices.length > 1;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{showDeviceSelector && devices.length > 0 && (
|
||||
<select
|
||||
aria-label="Microphone device"
|
||||
value={selectedDeviceId}
|
||||
onChange={e => setSelectedDeviceId(e.target.value)}
|
||||
disabled={state !== 'idle' || devices.length <= 1}
|
||||
className="text-xs text-stone-600 dark:text-neutral-300 bg-stone-100 dark:bg-neutral-800 border border-stone-200 dark:border-neutral-700 rounded px-2 py-1 max-w-[220px] truncate disabled:opacity-50">
|
||||
{devices.map(d => (
|
||||
<option key={d.deviceId} value={d.deviceId}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="relative flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isRecording ? t('mic.stopRecording') : t('mic.startRecording')}
|
||||
@@ -489,6 +485,128 @@ export function MicComposer({
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{showDeviceMenuFab && (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={gearButtonRef}
|
||||
type="button"
|
||||
aria-label={t('mic.deviceSelector') || 'Microphone device'}
|
||||
aria-expanded={deviceMenuOpen}
|
||||
onClick={() => {
|
||||
const rect = gearButtonRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
setMenuAnchor({ top: rect.bottom + 8, left: rect.left + rect.width / 2 });
|
||||
}
|
||||
setDeviceMenuOpen(open => !open);
|
||||
}}
|
||||
disabled={state !== 'idle'}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 hover:border-stone-300 dark:hover:border-neutral-600 transition-colors shadow-soft disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{deviceMenuOpen &&
|
||||
menuAnchor &&
|
||||
createPortal(
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
style={{ zIndex: 99998 }}
|
||||
onClick={() => setDeviceMenuOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
role="menu"
|
||||
aria-label={t('mic.deviceSelector') || 'Microphone device'}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: menuAnchor.top,
|
||||
left: menuAnchor.left,
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 99999,
|
||||
}}
|
||||
className="w-64 rounded-xl border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-soft py-1">
|
||||
{devices.map(d => {
|
||||
const selected = d.deviceId === selectedDeviceId;
|
||||
return (
|
||||
<button
|
||||
key={d.deviceId}
|
||||
role="menuitemradio"
|
||||
aria-checked={selected}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedDeviceId(d.deviceId);
|
||||
setDeviceMenuOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${
|
||||
selected
|
||||
? 'bg-primary-50 dark:bg-primary-900/20 text-stone-900 dark:text-neutral-100'
|
||||
: 'text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800'
|
||||
}`}>
|
||||
<span className="flex-1 min-w-0 truncate">{d.label}</span>
|
||||
{selected && (
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-primary-500 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{onSwitchToText && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('chat.switchToText')}
|
||||
title={t('chat.switchToText')}
|
||||
onClick={onSwitchToText}
|
||||
disabled={state !== 'idle'}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 hover:border-stone-300 dark:hover:border-neutral-600 transition-colors shadow-soft disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden>
|
||||
<rect x="2" y="6" width="20" height="12" rx="2" ry="2" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-stone-500 dark:text-neutral-400 select-none">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,17 +39,21 @@ describe('subMascotModelsFromTimeline', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses child tool calls, completion, and failure as activity bubbles', () => {
|
||||
const [running, success, error] = subMascotModelsFromTimeline([
|
||||
it('uses child tool calls as activity for running subagents', () => {
|
||||
// subMascotModelsFromTimeline now filters to status === 'running' only,
|
||||
// so success/error entries are excluded from the rendered strip.
|
||||
const [running] = subMascotModelsFromTimeline([
|
||||
subagentEntry({
|
||||
id: 'thread-1:subagent:sub-1:code_executor',
|
||||
name: 'subagent:code_executor',
|
||||
status: 'running',
|
||||
subagent: {
|
||||
taskId: 'sub-1',
|
||||
agentId: 'code_executor',
|
||||
toolCalls: [{ callId: 'call-1', toolName: 'read_file', status: 'running' }],
|
||||
},
|
||||
}),
|
||||
// success and error entries are filtered out — only running ones appear.
|
||||
subagentEntry({
|
||||
id: 'thread-1:subagent:sub-2:researcher',
|
||||
status: 'success',
|
||||
@@ -65,15 +69,26 @@ describe('subMascotModelsFromTimeline', () => {
|
||||
|
||||
expect(running?.activity).toBe('Using Read File');
|
||||
expect(running?.face).toBe('thinking');
|
||||
expect(success?.activity).toBe('Completed 512 chars');
|
||||
expect(success?.face).toBe('happy');
|
||||
expect(error?.activity).toBe('Needs attention');
|
||||
expect(error?.face).toBe('concerned');
|
||||
// success and error are filtered out — only 1 model returned.
|
||||
expect(
|
||||
subMascotModelsFromTimeline([
|
||||
subagentEntry({
|
||||
status: 'success',
|
||||
subagent: { taskId: 'sub-2', agentId: 'researcher', outputChars: 512, toolCalls: [] },
|
||||
}),
|
||||
subagentEntry({
|
||||
status: 'error',
|
||||
subagent: { taskId: 'sub-3', agentId: 'critic', toolCalls: [] },
|
||||
}),
|
||||
])
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<SubMascotLayer />', () => {
|
||||
it('renders multiple colored sub-mascots with running, success, and failed states', () => {
|
||||
it('renders only running sub-mascots (success/error are filtered out)', () => {
|
||||
// The strip now only shows actively-running subagents; completed/failed
|
||||
// ones are dropped so they don't crowd the bottom of the mascot stage.
|
||||
render(
|
||||
<SubMascotLayer
|
||||
entries={[
|
||||
@@ -81,38 +96,44 @@ describe('<SubMascotLayer />', () => {
|
||||
subagentEntry({
|
||||
id: 'thread-1:subagent:sub-2:planner',
|
||||
name: 'subagent:planner',
|
||||
status: 'success',
|
||||
subagent: { taskId: 'sub-2', agentId: 'planner', outputChars: 90, toolCalls: [] },
|
||||
status: 'running',
|
||||
subagent: { taskId: 'sub-2', agentId: 'planner', toolCalls: [] },
|
||||
}),
|
||||
subagentEntry({
|
||||
id: 'thread-1:subagent:sub-3:critic',
|
||||
name: 'subagent:critic',
|
||||
status: 'success',
|
||||
subagent: { taskId: 'sub-3', agentId: 'critic', outputChars: 90, toolCalls: [] },
|
||||
}),
|
||||
subagentEntry({
|
||||
id: 'thread-1:subagent:sub-4:auditor',
|
||||
name: 'subagent:auditor',
|
||||
status: 'error',
|
||||
subagent: { taskId: 'sub-3', agentId: 'critic', toolCalls: [] },
|
||||
subagent: { taskId: 'sub-4', agentId: 'auditor', toolCalls: [] },
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
// Only the two running entries should render.
|
||||
const mascots = screen.getAllByTestId('sub-mascot');
|
||||
expect(mascots).toHaveLength(3);
|
||||
expect(mascots).toHaveLength(2);
|
||||
expect(screen.getByRole('status', { name: /researcher subagent running/i })).toHaveAttribute(
|
||||
'data-status',
|
||||
'running'
|
||||
);
|
||||
expect(screen.getByRole('status', { name: /planner subagent success/i })).toHaveAttribute(
|
||||
expect(screen.getByRole('status', { name: /planner subagent running/i })).toHaveAttribute(
|
||||
'data-status',
|
||||
'success'
|
||||
);
|
||||
expect(screen.getByRole('status', { name: /critic subagent error/i })).toHaveAttribute(
|
||||
'data-status',
|
||||
'error'
|
||||
'running'
|
||||
);
|
||||
// success and error mascots are not rendered.
|
||||
expect(screen.queryByRole('status', { name: /critic subagent/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('status', { name: /auditor subagent/i })).not.toBeInTheDocument();
|
||||
|
||||
// Bubbles show the label text; activity is in the title attribute.
|
||||
const bubbles = screen.getAllByTestId('sub-mascot-bubble');
|
||||
expect(within(bubbles[0]!).getByText('Researcher')).toBeInTheDocument();
|
||||
expect(within(bubbles[1]!).getByText('Completed 90 chars')).toBeInTheDocument();
|
||||
expect(within(bubbles[2]!).getByText('Needs attention')).toBeInTheDocument();
|
||||
expect(within(bubbles[1]!).getByText('Planner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when no subagent rows are present', () => {
|
||||
|
||||
@@ -2,20 +2,20 @@ import debug from 'debug';
|
||||
import { type FC, useMemo } from 'react';
|
||||
|
||||
import type { ToolTimelineEntry, ToolTimelineEntryStatus } from '../../store/chatRuntimeSlice';
|
||||
import { Ghosty, type MascotFace } from './Mascot';
|
||||
import { type MascotFace, YellowMascot } from './Mascot';
|
||||
import type { MascotColor } from './Mascot/mascotPalette';
|
||||
|
||||
const subMascotLog = debug('human:sub-mascots');
|
||||
|
||||
const MAX_SUB_MASCOTS = 5;
|
||||
const ACTIVITY_LIMIT = 74;
|
||||
|
||||
const SUB_MASCOT_COLORS = [
|
||||
'#4A83DD',
|
||||
'#5C9B75',
|
||||
'#D9854B',
|
||||
'#B8657A',
|
||||
'#6E7BBD',
|
||||
'#4A9A9A',
|
||||
const SUB_MASCOT_COLORS: readonly MascotColor[] = [
|
||||
'yellow',
|
||||
'green',
|
||||
'navy',
|
||||
'burgundy',
|
||||
'black',
|
||||
] as const;
|
||||
|
||||
const POSITIONS = [
|
||||
@@ -33,7 +33,7 @@ export interface SubMascotModel {
|
||||
status: ToolTimelineEntryStatus;
|
||||
face: MascotFace;
|
||||
activity: string;
|
||||
color: string;
|
||||
color: MascotColor;
|
||||
position: (typeof POSITIONS)[number];
|
||||
}
|
||||
|
||||
@@ -108,7 +108,15 @@ function activityForEntry(entry: ToolTimelineEntry): string {
|
||||
|
||||
export function subMascotModelsFromTimeline(entries: ToolTimelineEntry[]): SubMascotModel[] {
|
||||
return entries
|
||||
.filter(entry => entry.subagent && entry.name.startsWith('subagent:'))
|
||||
.filter(
|
||||
entry =>
|
||||
entry.subagent &&
|
||||
entry.name.startsWith('subagent:') &&
|
||||
// Once a subagent's task is done (success or error), drop it from the
|
||||
// strip rather than letting completed mascots linger and crowd the
|
||||
// bottom. Only actively-running subagents are surfaced.
|
||||
entry.status === 'running'
|
||||
)
|
||||
.slice(-MAX_SUB_MASCOTS)
|
||||
.map((entry, index) => {
|
||||
const subagent = entry.subagent!;
|
||||
@@ -140,48 +148,36 @@ export const SubMascotLayer: FC<SubMascotLayerProps> = ({ entries }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex justify-center"
|
||||
data-testid="sub-mascot-layer"
|
||||
aria-live="polite">
|
||||
{models.map(model => (
|
||||
<div
|
||||
key={model.id}
|
||||
role="status"
|
||||
aria-label={`${model.label} subagent ${model.status}`}
|
||||
data-testid="sub-mascot"
|
||||
data-status={model.status}
|
||||
className="absolute w-[clamp(78px,18%,128px)]"
|
||||
style={{
|
||||
left: model.position.left,
|
||||
top: model.position.top,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}>
|
||||
<div className="flex items-end justify-center gap-3 px-3 pb-2 max-w-full overflow-x-auto">
|
||||
{models.map(model => (
|
||||
<div
|
||||
className={[
|
||||
'relative transition-opacity duration-500',
|
||||
model.status === 'running' ? 'opacity-100' : 'opacity-85',
|
||||
].join(' ')}>
|
||||
<div className="drop-shadow-[0_10px_20px_rgba(15,23,42,0.22)]">
|
||||
<Ghosty
|
||||
size="100%"
|
||||
idPrefix={`sub-mascot-${model.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`}
|
||||
bodyColor={model.color}
|
||||
face={model.face}
|
||||
/>
|
||||
</div>
|
||||
key={model.id}
|
||||
role="status"
|
||||
aria-label={`${model.label} subagent ${model.status}`}
|
||||
data-testid="sub-mascot"
|
||||
data-status={model.status}
|
||||
className="flex flex-col items-center w-[72px] flex-shrink-0">
|
||||
<div
|
||||
className="absolute left-1/2 top-[78%] w-[min(168px,42vw)] -translate-x-1/2 rounded-lg border border-white/70 bg-white/90 px-2 py-1 text-center text-[11px] leading-tight text-stone-700 shadow-soft backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/90 dark:text-neutral-100"
|
||||
data-testid="sub-mascot-bubble">
|
||||
<div className="truncate font-medium">{model.label}</div>
|
||||
<div
|
||||
className="mt-0.5 overflow-hidden text-[10px] text-stone-500 dark:text-neutral-300"
|
||||
style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||
{model.activity}
|
||||
className={[
|
||||
'relative w-[56px] h-[56px] transition-opacity duration-500',
|
||||
model.status === 'running' ? 'opacity-100' : 'opacity-75',
|
||||
].join(' ')}>
|
||||
<div className="drop-shadow-[0_6px_12px_rgba(15,23,42,0.18)]">
|
||||
<YellowMascot size="100%" mascotColor={model.color} face={model.face} static />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 max-w-[88px] rounded-md border border-white/70 bg-white/85 px-1.5 py-0.5 text-center text-[9px] leading-tight text-stone-600 shadow-soft backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/85 dark:text-neutral-200"
|
||||
data-testid="sub-mascot-bubble"
|
||||
title={`${model.label} — ${model.activity}`}>
|
||||
<div className="truncate font-medium">{model.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export function formatComposioToolError(raw: string | null | undefined): string
|
||||
|
||||
export function formatTriggerLabel(
|
||||
slug: string | null | undefined,
|
||||
opts?: { overrides?: Record<string, string> }
|
||||
opts?: { overrides?: Record<string, string>; toolkit?: string | null }
|
||||
): string {
|
||||
if (!slug) return '';
|
||||
if (opts?.overrides && Object.prototype.hasOwnProperty.call(opts.overrides, slug)) {
|
||||
@@ -64,6 +64,38 @@ export function formatTriggerLabel(
|
||||
}
|
||||
}
|
||||
|
||||
// Strip remaining leading toolkit tokens when the caller supplies the
|
||||
// toolkit slug/name — keeps the label focused on the *event* part.
|
||||
// e.g. with toolkit='googlecalendar' or 'Google Calendar':
|
||||
// GOOGLE CALENDAR EVENT CREATED -> EVENT CREATED
|
||||
if (opts?.toolkit && tokens.length > 1) {
|
||||
const toolkitTokens = opts.toolkit
|
||||
.toUpperCase()
|
||||
.split(/[\s_]+/)
|
||||
.filter(t => t.length > 0);
|
||||
// Build a virtual concatenation of the toolkit ("GOOGLECALENDAR") so we
|
||||
// can also drop a single-glued token like 'GOOGLECALENDAR' that maps to
|
||||
// multiple display words.
|
||||
const toolkitGlued = toolkitTokens.join('');
|
||||
|
||||
// Drop a single-token gluing first.
|
||||
if (tokens[0].toUpperCase() === toolkitGlued && tokens.length > 1) {
|
||||
tokens.shift();
|
||||
}
|
||||
|
||||
// Then drop consecutive matching tokens, stopping when something else
|
||||
// appears (so we don't accidentally swallow a real event word).
|
||||
let i = 0;
|
||||
while (
|
||||
i < toolkitTokens.length &&
|
||||
tokens.length > 1 &&
|
||||
tokens[0].toUpperCase() === toolkitTokens[i]
|
||||
) {
|
||||
tokens.shift();
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return tokens
|
||||
.map(token => {
|
||||
if (token.toUpperCase() === 'GITHUB') return 'GitHub';
|
||||
|
||||
@@ -423,6 +423,76 @@ const ar1: TranslationMap = {
|
||||
'channels.mcp.title': 'MCP Servers',
|
||||
'channels.mcp.description':
|
||||
'Browse and manage Model Context Protocol servers that extend the AI with new tools.',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
'settings.about.connectionModeCloud': 'Cloud',
|
||||
'settings.about.connectionModeUnset': 'Not selected',
|
||||
'settings.about.serverUrl': 'Server URL',
|
||||
'settings.about.serverUrlUnavailable': 'Unavailable',
|
||||
'settings.about.connectionHelperLocal':
|
||||
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
|
||||
'settings.about.connectionHelperCloud':
|
||||
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
|
||||
'settings.heartbeat.title': 'Heartbeat & loops',
|
||||
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
|
||||
'settings.ledgerUsage.title': 'Usage ledger',
|
||||
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
|
||||
'settings.search.title': 'Search engine',
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
'No key configured — search will fall back to Managed until a key is saved.',
|
||||
'settings.search.getApiKey': 'Get API key',
|
||||
'settings.search.save': 'Save',
|
||||
'settings.search.clear': 'Clear',
|
||||
'settings.search.show': 'Show',
|
||||
'settings.search.hide': 'Hide',
|
||||
'settings.search.statusSaving': 'Saving…',
|
||||
'settings.search.statusSaved': 'Saved.',
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'mcp.alphaBadge': 'Alpha',
|
||||
'mcp.alphaBannerText':
|
||||
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
|
||||
'mcp.toolList.noTools': 'No tools available.',
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'autonomy.title': 'Agent autonomy',
|
||||
'autonomy.maxActionsLabel': 'Max actions per hour',
|
||||
'autonomy.maxActionsHelp':
|
||||
'Maximum tool actions an agent can run per rolling hour. New value applies to your next chat. Cron jobs and channel listeners keep their current limit until you restart OpenHuman.',
|
||||
'autonomy.statusSaving': 'Saving…',
|
||||
'autonomy.statusSaved': 'Saved.',
|
||||
'autonomy.statusFailed': 'Failed',
|
||||
'autonomy.unlimitedNote': 'Unlimited — rate limiting disabled.',
|
||||
'autonomy.invalidIntegerMsg':
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
};
|
||||
|
||||
export default ar1;
|
||||
|
||||
@@ -413,6 +413,7 @@ const ar2: TranslationMap = {
|
||||
'devOptions.menuComposioTriggers': 'Integration Triggers',
|
||||
'devOptions.menuComposioTriggersDesc':
|
||||
'Configure AI triage settings for Composio integration triggers',
|
||||
'mic.deviceSelector': 'Microphone device',
|
||||
};
|
||||
|
||||
export default ar2;
|
||||
|
||||
@@ -401,6 +401,17 @@ const ar4: TranslationMap = {
|
||||
'pages.settings.account.migration': 'استيراد من مساعد آخر',
|
||||
'pages.settings.account.migrationDesc':
|
||||
'انقل الذاكرة والملاحظات من OpenClaw (وقريبًا Hermes) إلى مساحة العمل هذه.',
|
||||
'composio.connect.scope.read': 'Read',
|
||||
'composio.connect.scope.readHint': 'Allow the agent to read data from this connection.',
|
||||
'composio.connect.scope.write': 'Write',
|
||||
'composio.connect.scope.writeHint':
|
||||
'Allow the agent to create or modify data through this connection.',
|
||||
'composio.connect.scope.admin': 'Admin',
|
||||
'composio.connect.scope.adminHint':
|
||||
'Allow the agent to manage settings, permissions, or destructive actions.',
|
||||
'pages.settings.composioSection.title': 'Composio',
|
||||
'pages.settings.composioSection.description':
|
||||
'Routing, triggers, and history for integrations powered by Composio.',
|
||||
};
|
||||
|
||||
export default ar4;
|
||||
|
||||
@@ -505,6 +505,13 @@ const ar5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'Config file',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP client selector',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Routing mode, integration triggers, and trigger history archive.',
|
||||
'settings.appearance.tabBarHeading': 'Bottom tab bar',
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'When off, labels only appear on hover or for the active tab.',
|
||||
};
|
||||
|
||||
export default ar5;
|
||||
|
||||
@@ -432,6 +432,76 @@ const bn1: TranslationMap = {
|
||||
'channels.mcp.title': 'MCP Servers',
|
||||
'channels.mcp.description':
|
||||
'Browse and manage Model Context Protocol servers that extend the AI with new tools.',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
'settings.about.connectionModeCloud': 'Cloud',
|
||||
'settings.about.connectionModeUnset': 'Not selected',
|
||||
'settings.about.serverUrl': 'Server URL',
|
||||
'settings.about.serverUrlUnavailable': 'Unavailable',
|
||||
'settings.about.connectionHelperLocal':
|
||||
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
|
||||
'settings.about.connectionHelperCloud':
|
||||
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
|
||||
'settings.heartbeat.title': 'Heartbeat & loops',
|
||||
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
|
||||
'settings.ledgerUsage.title': 'Usage ledger',
|
||||
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
|
||||
'settings.search.title': 'Search engine',
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
'No key configured — search will fall back to Managed until a key is saved.',
|
||||
'settings.search.getApiKey': 'Get API key',
|
||||
'settings.search.save': 'Save',
|
||||
'settings.search.clear': 'Clear',
|
||||
'settings.search.show': 'Show',
|
||||
'settings.search.hide': 'Hide',
|
||||
'settings.search.statusSaving': 'Saving…',
|
||||
'settings.search.statusSaved': 'Saved.',
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'mcp.alphaBadge': 'Alpha',
|
||||
'mcp.alphaBannerText':
|
||||
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
|
||||
'mcp.toolList.noTools': 'No tools available.',
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'autonomy.title': 'Agent autonomy',
|
||||
'autonomy.maxActionsLabel': 'Max actions per hour',
|
||||
'autonomy.maxActionsHelp':
|
||||
'Maximum tool actions an agent can run per rolling hour. New value applies to your next chat. Cron jobs and channel listeners keep their current limit until you restart OpenHuman.',
|
||||
'autonomy.statusSaving': 'Saving…',
|
||||
'autonomy.statusSaved': 'Saved.',
|
||||
'autonomy.statusFailed': 'Failed',
|
||||
'autonomy.unlimitedNote': 'Unlimited — rate limiting disabled.',
|
||||
'autonomy.invalidIntegerMsg':
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
};
|
||||
|
||||
export default bn1;
|
||||
|
||||
@@ -424,6 +424,7 @@ const bn2: TranslationMap = {
|
||||
'devOptions.menuComposioTriggers': 'Integration Triggers',
|
||||
'devOptions.menuComposioTriggersDesc':
|
||||
'Configure AI triage settings for Composio integration triggers',
|
||||
'mic.deviceSelector': 'Microphone device',
|
||||
};
|
||||
|
||||
export default bn2;
|
||||
|
||||
@@ -403,6 +403,17 @@ const bn4: TranslationMap = {
|
||||
'pages.settings.account.migration': 'অন্য সহকারী থেকে আমদানি করুন',
|
||||
'pages.settings.account.migrationDesc':
|
||||
'OpenClaw (এবং শীঘ্রই Hermes) থেকে মেমরি ও নোট এই ওয়ার্কস্পেসে স্থানান্তর করুন।',
|
||||
'composio.connect.scope.read': 'Read',
|
||||
'composio.connect.scope.readHint': 'Allow the agent to read data from this connection.',
|
||||
'composio.connect.scope.write': 'Write',
|
||||
'composio.connect.scope.writeHint':
|
||||
'Allow the agent to create or modify data through this connection.',
|
||||
'composio.connect.scope.admin': 'Admin',
|
||||
'composio.connect.scope.adminHint':
|
||||
'Allow the agent to manage settings, permissions, or destructive actions.',
|
||||
'pages.settings.composioSection.title': 'Composio',
|
||||
'pages.settings.composioSection.description':
|
||||
'Routing, triggers, and history for integrations powered by Composio.',
|
||||
};
|
||||
|
||||
export default bn4;
|
||||
|
||||
@@ -511,6 +511,13 @@ const bn5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'Config file',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP client selector',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Routing mode, integration triggers, and trigger history archive.',
|
||||
'settings.appearance.tabBarHeading': 'Bottom tab bar',
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'When off, labels only appear on hover or for the active tab.',
|
||||
};
|
||||
|
||||
export default bn5;
|
||||
|
||||
@@ -444,6 +444,76 @@ const de1: TranslationMap = {
|
||||
'channels.mcp.title': 'MCP-Server',
|
||||
'channels.mcp.description':
|
||||
'Durchsuche und verwalte Model Context Protocol-Server, die die KI um neue Tools erweitern.',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
'settings.about.connectionModeCloud': 'Cloud',
|
||||
'settings.about.connectionModeUnset': 'Not selected',
|
||||
'settings.about.serverUrl': 'Server URL',
|
||||
'settings.about.serverUrlUnavailable': 'Unavailable',
|
||||
'settings.about.connectionHelperLocal':
|
||||
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
|
||||
'settings.about.connectionHelperCloud':
|
||||
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
|
||||
'settings.heartbeat.title': 'Heartbeat & loops',
|
||||
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
|
||||
'settings.ledgerUsage.title': 'Usage ledger',
|
||||
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
|
||||
'settings.search.title': 'Search engine',
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
'No key configured — search will fall back to Managed until a key is saved.',
|
||||
'settings.search.getApiKey': 'Get API key',
|
||||
'settings.search.save': 'Save',
|
||||
'settings.search.clear': 'Clear',
|
||||
'settings.search.show': 'Show',
|
||||
'settings.search.hide': 'Hide',
|
||||
'settings.search.statusSaving': 'Saving…',
|
||||
'settings.search.statusSaved': 'Saved.',
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'mcp.alphaBadge': 'Alpha',
|
||||
'mcp.alphaBannerText':
|
||||
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
|
||||
'mcp.toolList.noTools': 'No tools available.',
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'autonomy.title': 'Agent autonomy',
|
||||
'autonomy.maxActionsLabel': 'Max actions per hour',
|
||||
'autonomy.maxActionsHelp':
|
||||
'Maximum tool actions an agent can run per rolling hour. New value applies to your next chat. Cron jobs and channel listeners keep their current limit until you restart OpenHuman.',
|
||||
'autonomy.statusSaving': 'Saving…',
|
||||
'autonomy.statusSaved': 'Saved.',
|
||||
'autonomy.statusFailed': 'Failed',
|
||||
'autonomy.unlimitedNote': 'Unlimited — rate limiting disabled.',
|
||||
'autonomy.invalidIntegerMsg':
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
};
|
||||
|
||||
export default de1;
|
||||
|
||||
@@ -434,6 +434,7 @@ const de2: TranslationMap = {
|
||||
'devOptions.menuComposioTriggers': 'Integrationsauslöser',
|
||||
'devOptions.menuComposioTriggersDesc':
|
||||
'Konfiguriere KI-Triage-Einstellungen für Composio-Integrationsauslöser',
|
||||
'mic.deviceSelector': 'Microphone device',
|
||||
};
|
||||
|
||||
export default de2;
|
||||
|
||||
@@ -408,6 +408,17 @@ const de4: TranslationMap = {
|
||||
'settings.billing.subscription.paymentConfirmed': 'Zahlung bestätigt',
|
||||
'settings.billing.subscription.perMonth': 'Pro Monat',
|
||||
'settings.billing.subscription.popular': 'Beliebt',
|
||||
'composio.connect.scope.read': 'Read',
|
||||
'composio.connect.scope.readHint': 'Allow the agent to read data from this connection.',
|
||||
'composio.connect.scope.write': 'Write',
|
||||
'composio.connect.scope.writeHint':
|
||||
'Allow the agent to create or modify data through this connection.',
|
||||
'composio.connect.scope.admin': 'Admin',
|
||||
'composio.connect.scope.adminHint':
|
||||
'Allow the agent to manage settings, permissions, or destructive actions.',
|
||||
'pages.settings.composioSection.title': 'Composio',
|
||||
'pages.settings.composioSection.description':
|
||||
'Routing, triggers, and history for integrations powered by Composio.',
|
||||
};
|
||||
|
||||
export default de4;
|
||||
|
||||
@@ -533,6 +533,13 @@ const de5: TranslationMap = {
|
||||
'settings.mascot.colorYellow': 'Gelb',
|
||||
'settings.mascot.libraryUnavailable': 'OpenHuman Bibliothek nicht verfügbar',
|
||||
'settings.mascot.title': 'OpenHuman',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Routing mode, integration triggers, and trigger history archive.',
|
||||
'settings.appearance.tabBarHeading': 'Bottom tab bar',
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'When off, labels only appear on hover or for the active tab.',
|
||||
};
|
||||
|
||||
export default de5;
|
||||
|
||||
@@ -204,7 +204,12 @@ const en1: TranslationMap = {
|
||||
'skills.available': 'Available',
|
||||
'skills.addAccount': 'Add Account',
|
||||
'skills.channels': 'Channels',
|
||||
'skills.integrations': 'Integrations',
|
||||
'skills.integrations': 'Composio Integrations',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'memory.title': 'Memory',
|
||||
'memory.search': 'Search memories...',
|
||||
'memory.noResults': 'No memories found',
|
||||
@@ -420,6 +425,71 @@ const en1: TranslationMap = {
|
||||
'settings.about.releases': 'Releases',
|
||||
'settings.about.releasesDesc': 'Browse release notes and earlier builds on GitHub.',
|
||||
'settings.about.openReleases': 'Open GitHub releases',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
'settings.about.connectionModeCloud': 'Cloud',
|
||||
'settings.about.connectionModeUnset': 'Not selected',
|
||||
'settings.about.serverUrl': 'Server URL',
|
||||
'settings.about.serverUrlUnavailable': 'Unavailable',
|
||||
'settings.about.connectionHelperLocal':
|
||||
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
|
||||
'settings.about.connectionHelperCloud':
|
||||
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
|
||||
'settings.heartbeat.title': 'Heartbeat & loops',
|
||||
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
|
||||
'settings.ledgerUsage.title': 'Usage ledger',
|
||||
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
|
||||
'settings.search.title': 'Search engine',
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
'No key configured — search will fall back to Managed until a key is saved.',
|
||||
'settings.search.getApiKey': 'Get API key',
|
||||
'settings.search.save': 'Save',
|
||||
'settings.search.clear': 'Clear',
|
||||
'settings.search.show': 'Show',
|
||||
'settings.search.hide': 'Hide',
|
||||
'settings.search.statusSaving': 'Saving…',
|
||||
'settings.search.statusSaved': 'Saved.',
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'mcp.alphaBadge': 'Alpha',
|
||||
'mcp.alphaBannerText':
|
||||
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
|
||||
'mcp.toolList.noTools': 'No tools available.',
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'autonomy.title': 'Agent autonomy',
|
||||
'autonomy.maxActionsLabel': 'Max actions per hour',
|
||||
'autonomy.maxActionsHelp':
|
||||
'Maximum tool actions an agent can run per rolling hour. New value applies to your next chat. Cron jobs and channel listeners keep their current limit until you restart OpenHuman.',
|
||||
'autonomy.statusSaving': 'Saving…',
|
||||
'autonomy.statusSaved': 'Saved.',
|
||||
'autonomy.statusFailed': 'Failed',
|
||||
'autonomy.unlimitedNote': 'Unlimited — rate limiting disabled.',
|
||||
'autonomy.invalidIntegerMsg':
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
'settings.ai.overview': 'AI System Overview',
|
||||
// Settings menu: Appearance + Mascot (#2225)
|
||||
'settings.appearance': 'Appearance',
|
||||
|
||||
@@ -348,6 +348,7 @@ const en2: TranslationMap = {
|
||||
'mic.tapAndSpeak': 'Tap and speak',
|
||||
'mic.stopRecording': 'Stop recording and send',
|
||||
'mic.startRecording': 'Start recording',
|
||||
'mic.deviceSelector': 'Microphone device',
|
||||
'token.usageLimitReached': 'Usage limit reached',
|
||||
'token.approachingLimit': 'Approaching limit',
|
||||
'token.planClickForDetails': 'plan - click for details',
|
||||
|
||||
@@ -43,6 +43,14 @@ const en4: TranslationMap = {
|
||||
'composio.connect.retryConnection': 'Retry connection',
|
||||
'composio.connect.scopeLoadError': "Couldn't load scope preferences: {msg}",
|
||||
'composio.connect.scopeSaveError': "Couldn't save {key} scope: {msg}",
|
||||
'composio.connect.scope.read': 'Read',
|
||||
'composio.connect.scope.readHint': 'Allow the agent to read data from this connection.',
|
||||
'composio.connect.scope.write': 'Write',
|
||||
'composio.connect.scope.writeHint':
|
||||
'Allow the agent to create or modify data through this connection.',
|
||||
'composio.connect.scope.admin': 'Admin',
|
||||
'composio.connect.scope.adminHint':
|
||||
'Allow the agent to manage settings, permissions, or destructive actions.',
|
||||
'composio.connect.subdomainInvalid':
|
||||
'Enter the short subdomain only (e.g. "acme"), not the full URL. It should contain only letters, numbers, and hyphens.',
|
||||
'composio.connect.subdomainRequired': 'Please enter your Atlassian subdomain to continue.',
|
||||
@@ -194,6 +202,9 @@ const en4: TranslationMap = {
|
||||
'pages.settings.aiSection.description':
|
||||
'Language model providers, local Ollama, and voice (STT / TTS).',
|
||||
'pages.settings.aiSection.title': 'AI',
|
||||
'pages.settings.composioSection.title': 'Composio',
|
||||
'pages.settings.composioSection.description':
|
||||
'Routing, triggers, and history for integrations powered by Composio.',
|
||||
'pages.settings.features.desktopCompanion': 'Desktop Companion',
|
||||
'pages.settings.features.desktopCompanionDesc':
|
||||
'Voice assistant with screen awareness — listens, sees, speaks, points',
|
||||
|
||||
@@ -183,6 +183,9 @@ const en5: TranslationMap = {
|
||||
'settings.developerMenu.localModelDebug.title': 'Local Model Debug',
|
||||
'settings.developerMenu.localModelDebug.desc':
|
||||
'Ollama config, asset downloads, model tests, and diagnostics',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Routing mode, integration triggers, and trigger history archive.',
|
||||
'settings.developerMenu.webhooks.title': 'Webhooks',
|
||||
'settings.developerMenu.webhooks.desc':
|
||||
'Inspect runtime webhook registrations and captured request logs',
|
||||
@@ -476,6 +479,10 @@ const en5: TranslationMap = {
|
||||
'settings.appearance.modeSystemDesc': 'Follow your OS appearance setting.',
|
||||
'settings.appearance.helperText':
|
||||
'Dark mode switches the entire app — chat, settings, panels — to a dim palette. "Match system" follows your OS appearance and updates live.',
|
||||
'settings.appearance.tabBarHeading': 'Bottom tab bar',
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'When off, labels only appear on hover or for the active tab.',
|
||||
'settings.mascot.characterPreview': 'Preview',
|
||||
'settings.mascot.characterStates': 'states',
|
||||
'settings.mascot.characterVisemes': 'visemes',
|
||||
|
||||
@@ -444,6 +444,76 @@ const es1: TranslationMap = {
|
||||
'channels.mcp.title': 'MCP Servers',
|
||||
'channels.mcp.description':
|
||||
'Browse and manage Model Context Protocol servers that extend the AI with new tools.',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
'settings.about.connectionModeCloud': 'Cloud',
|
||||
'settings.about.connectionModeUnset': 'Not selected',
|
||||
'settings.about.serverUrl': 'Server URL',
|
||||
'settings.about.serverUrlUnavailable': 'Unavailable',
|
||||
'settings.about.connectionHelperLocal':
|
||||
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
|
||||
'settings.about.connectionHelperCloud':
|
||||
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
|
||||
'settings.heartbeat.title': 'Heartbeat & loops',
|
||||
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
|
||||
'settings.ledgerUsage.title': 'Usage ledger',
|
||||
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
|
||||
'settings.search.title': 'Search engine',
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
'No key configured — search will fall back to Managed until a key is saved.',
|
||||
'settings.search.getApiKey': 'Get API key',
|
||||
'settings.search.save': 'Save',
|
||||
'settings.search.clear': 'Clear',
|
||||
'settings.search.show': 'Show',
|
||||
'settings.search.hide': 'Hide',
|
||||
'settings.search.statusSaving': 'Saving…',
|
||||
'settings.search.statusSaved': 'Saved.',
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'mcp.alphaBadge': 'Alpha',
|
||||
'mcp.alphaBannerText':
|
||||
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
|
||||
'mcp.toolList.noTools': 'No tools available.',
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'autonomy.title': 'Agent autonomy',
|
||||
'autonomy.maxActionsLabel': 'Max actions per hour',
|
||||
'autonomy.maxActionsHelp':
|
||||
'Maximum tool actions an agent can run per rolling hour. New value applies to your next chat. Cron jobs and channel listeners keep their current limit until you restart OpenHuman.',
|
||||
'autonomy.statusSaving': 'Saving…',
|
||||
'autonomy.statusSaved': 'Saved.',
|
||||
'autonomy.statusFailed': 'Failed',
|
||||
'autonomy.unlimitedNote': 'Unlimited — rate limiting disabled.',
|
||||
'autonomy.invalidIntegerMsg':
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
};
|
||||
|
||||
export default es1;
|
||||
|
||||
@@ -427,6 +427,7 @@ const es2: TranslationMap = {
|
||||
'devOptions.menuComposioTriggers': 'Integration Triggers',
|
||||
'devOptions.menuComposioTriggersDesc':
|
||||
'Configure AI triage settings for Composio integration triggers',
|
||||
'mic.deviceSelector': 'Microphone device',
|
||||
};
|
||||
|
||||
export default es2;
|
||||
|
||||
@@ -406,6 +406,17 @@ const es4: TranslationMap = {
|
||||
'pages.settings.account.migration': 'Importar desde otro asistente',
|
||||
'pages.settings.account.migrationDesc':
|
||||
'Migra memoria y notas desde OpenClaw (y pronto Hermes) a este espacio de trabajo.',
|
||||
'composio.connect.scope.read': 'Read',
|
||||
'composio.connect.scope.readHint': 'Allow the agent to read data from this connection.',
|
||||
'composio.connect.scope.write': 'Write',
|
||||
'composio.connect.scope.writeHint':
|
||||
'Allow the agent to create or modify data through this connection.',
|
||||
'composio.connect.scope.admin': 'Admin',
|
||||
'composio.connect.scope.adminHint':
|
||||
'Allow the agent to manage settings, permissions, or destructive actions.',
|
||||
'pages.settings.composioSection.title': 'Composio',
|
||||
'pages.settings.composioSection.description':
|
||||
'Routing, triggers, and history for integrations powered by Composio.',
|
||||
};
|
||||
|
||||
export default es4;
|
||||
|
||||
@@ -517,6 +517,13 @@ const es5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'Config file',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP client selector',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Routing mode, integration triggers, and trigger history archive.',
|
||||
'settings.appearance.tabBarHeading': 'Bottom tab bar',
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'When off, labels only appear on hover or for the active tab.',
|
||||
};
|
||||
|
||||
export default es5;
|
||||
|
||||
@@ -446,6 +446,76 @@ const fr1: TranslationMap = {
|
||||
'channels.mcp.title': 'MCP Servers',
|
||||
'channels.mcp.description':
|
||||
'Browse and manage Model Context Protocol servers that extend the AI with new tools.',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
'settings.about.connectionModeCloud': 'Cloud',
|
||||
'settings.about.connectionModeUnset': 'Not selected',
|
||||
'settings.about.serverUrl': 'Server URL',
|
||||
'settings.about.serverUrlUnavailable': 'Unavailable',
|
||||
'settings.about.connectionHelperLocal':
|
||||
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
|
||||
'settings.about.connectionHelperCloud':
|
||||
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
|
||||
'settings.heartbeat.title': 'Heartbeat & loops',
|
||||
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
|
||||
'settings.ledgerUsage.title': 'Usage ledger',
|
||||
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
|
||||
'settings.search.title': 'Search engine',
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
'No key configured — search will fall back to Managed until a key is saved.',
|
||||
'settings.search.getApiKey': 'Get API key',
|
||||
'settings.search.save': 'Save',
|
||||
'settings.search.clear': 'Clear',
|
||||
'settings.search.show': 'Show',
|
||||
'settings.search.hide': 'Hide',
|
||||
'settings.search.statusSaving': 'Saving…',
|
||||
'settings.search.statusSaved': 'Saved.',
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'mcp.alphaBadge': 'Alpha',
|
||||
'mcp.alphaBannerText':
|
||||
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
|
||||
'mcp.toolList.noTools': 'No tools available.',
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'autonomy.title': 'Agent autonomy',
|
||||
'autonomy.maxActionsLabel': 'Max actions per hour',
|
||||
'autonomy.maxActionsHelp':
|
||||
'Maximum tool actions an agent can run per rolling hour. New value applies to your next chat. Cron jobs and channel listeners keep their current limit until you restart OpenHuman.',
|
||||
'autonomy.statusSaving': 'Saving…',
|
||||
'autonomy.statusSaved': 'Saved.',
|
||||
'autonomy.statusFailed': 'Failed',
|
||||
'autonomy.unlimitedNote': 'Unlimited — rate limiting disabled.',
|
||||
'autonomy.invalidIntegerMsg':
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
};
|
||||
|
||||
export default fr1;
|
||||
|
||||
@@ -429,6 +429,7 @@ const fr2: TranslationMap = {
|
||||
'devOptions.menuComposioTriggers': 'Integration Triggers',
|
||||
'devOptions.menuComposioTriggersDesc':
|
||||
'Configure AI triage settings for Composio integration triggers',
|
||||
'mic.deviceSelector': 'Microphone device',
|
||||
};
|
||||
|
||||
export default fr2;
|
||||
|
||||
@@ -405,6 +405,17 @@ const fr4: TranslationMap = {
|
||||
'pages.settings.account.migration': 'Importer depuis un autre assistant',
|
||||
'pages.settings.account.migrationDesc':
|
||||
'Migrez la mémoire et les notes depuis OpenClaw (et bientôt Hermes) vers cet espace de travail.',
|
||||
'composio.connect.scope.read': 'Read',
|
||||
'composio.connect.scope.readHint': 'Allow the agent to read data from this connection.',
|
||||
'composio.connect.scope.write': 'Write',
|
||||
'composio.connect.scope.writeHint':
|
||||
'Allow the agent to create or modify data through this connection.',
|
||||
'composio.connect.scope.admin': 'Admin',
|
||||
'composio.connect.scope.adminHint':
|
||||
'Allow the agent to manage settings, permissions, or destructive actions.',
|
||||
'pages.settings.composioSection.title': 'Composio',
|
||||
'pages.settings.composioSection.description':
|
||||
'Routing, triggers, and history for integrations powered by Composio.',
|
||||
};
|
||||
|
||||
export default fr4;
|
||||
|
||||
@@ -521,6 +521,13 @@ const fr5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'Config file',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP client selector',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Routing mode, integration triggers, and trigger history archive.',
|
||||
'settings.appearance.tabBarHeading': 'Bottom tab bar',
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'When off, labels only appear on hover or for the active tab.',
|
||||
};
|
||||
|
||||
export default fr5;
|
||||
|
||||
@@ -429,6 +429,76 @@ const hi1: TranslationMap = {
|
||||
'channels.mcp.title': 'MCP Servers',
|
||||
'channels.mcp.description':
|
||||
'Browse and manage Model Context Protocol servers that extend the AI with new tools.',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
'settings.about.connectionModeCloud': 'Cloud',
|
||||
'settings.about.connectionModeUnset': 'Not selected',
|
||||
'settings.about.serverUrl': 'Server URL',
|
||||
'settings.about.serverUrlUnavailable': 'Unavailable',
|
||||
'settings.about.connectionHelperLocal':
|
||||
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
|
||||
'settings.about.connectionHelperCloud':
|
||||
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
|
||||
'settings.heartbeat.title': 'Heartbeat & loops',
|
||||
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
|
||||
'settings.ledgerUsage.title': 'Usage ledger',
|
||||
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
|
||||
'settings.search.title': 'Search engine',
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
'No key configured — search will fall back to Managed until a key is saved.',
|
||||
'settings.search.getApiKey': 'Get API key',
|
||||
'settings.search.save': 'Save',
|
||||
'settings.search.clear': 'Clear',
|
||||
'settings.search.show': 'Show',
|
||||
'settings.search.hide': 'Hide',
|
||||
'settings.search.statusSaving': 'Saving…',
|
||||
'settings.search.statusSaved': 'Saved.',
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'mcp.alphaBadge': 'Alpha',
|
||||
'mcp.alphaBannerText':
|
||||
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
|
||||
'mcp.toolList.noTools': 'No tools available.',
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'autonomy.title': 'Agent autonomy',
|
||||
'autonomy.maxActionsLabel': 'Max actions per hour',
|
||||
'autonomy.maxActionsHelp':
|
||||
'Maximum tool actions an agent can run per rolling hour. New value applies to your next chat. Cron jobs and channel listeners keep their current limit until you restart OpenHuman.',
|
||||
'autonomy.statusSaving': 'Saving…',
|
||||
'autonomy.statusSaved': 'Saved.',
|
||||
'autonomy.statusFailed': 'Failed',
|
||||
'autonomy.unlimitedNote': 'Unlimited — rate limiting disabled.',
|
||||
'autonomy.invalidIntegerMsg':
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
};
|
||||
|
||||
export default hi1;
|
||||
|
||||
@@ -421,6 +421,7 @@ const hi2: TranslationMap = {
|
||||
'devOptions.menuComposioTriggers': 'Integration Triggers',
|
||||
'devOptions.menuComposioTriggersDesc':
|
||||
'Configure AI triage settings for Composio integration triggers',
|
||||
'mic.deviceSelector': 'Microphone device',
|
||||
};
|
||||
|
||||
export default hi2;
|
||||
|
||||
@@ -404,6 +404,17 @@ const hi4: TranslationMap = {
|
||||
'pages.settings.account.migration': 'किसी अन्य असिस्टेंट से इम्पोर्ट करें',
|
||||
'pages.settings.account.migrationDesc':
|
||||
'OpenClaw (और जल्द ही Hermes) से मेमोरी और नोट्स इस वर्कस्पेस में माइग्रेट करें।',
|
||||
'composio.connect.scope.read': 'Read',
|
||||
'composio.connect.scope.readHint': 'Allow the agent to read data from this connection.',
|
||||
'composio.connect.scope.write': 'Write',
|
||||
'composio.connect.scope.writeHint':
|
||||
'Allow the agent to create or modify data through this connection.',
|
||||
'composio.connect.scope.admin': 'Admin',
|
||||
'composio.connect.scope.adminHint':
|
||||
'Allow the agent to manage settings, permissions, or destructive actions.',
|
||||
'pages.settings.composioSection.title': 'Composio',
|
||||
'pages.settings.composioSection.description':
|
||||
'Routing, triggers, and history for integrations powered by Composio.',
|
||||
};
|
||||
|
||||
export default hi4;
|
||||
|
||||
@@ -513,6 +513,13 @@ const hi5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'Config file',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP client selector',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Routing mode, integration triggers, and trigger history archive.',
|
||||
'settings.appearance.tabBarHeading': 'Bottom tab bar',
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'When off, labels only appear on hover or for the active tab.',
|
||||
};
|
||||
|
||||
export default hi5;
|
||||
|
||||
@@ -435,6 +435,76 @@ const id1: TranslationMap = {
|
||||
'channels.mcp.title': 'MCP Servers',
|
||||
'channels.mcp.description':
|
||||
'Browse and manage Model Context Protocol servers that extend the AI with new tools.',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
'settings.about.connectionModeCloud': 'Cloud',
|
||||
'settings.about.connectionModeUnset': 'Not selected',
|
||||
'settings.about.serverUrl': 'Server URL',
|
||||
'settings.about.serverUrlUnavailable': 'Unavailable',
|
||||
'settings.about.connectionHelperLocal':
|
||||
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
|
||||
'settings.about.connectionHelperCloud':
|
||||
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
|
||||
'settings.heartbeat.title': 'Heartbeat & loops',
|
||||
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
|
||||
'settings.ledgerUsage.title': 'Usage ledger',
|
||||
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
|
||||
'settings.search.title': 'Search engine',
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
'No key configured — search will fall back to Managed until a key is saved.',
|
||||
'settings.search.getApiKey': 'Get API key',
|
||||
'settings.search.save': 'Save',
|
||||
'settings.search.clear': 'Clear',
|
||||
'settings.search.show': 'Show',
|
||||
'settings.search.hide': 'Hide',
|
||||
'settings.search.statusSaving': 'Saving…',
|
||||
'settings.search.statusSaved': 'Saved.',
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'mcp.alphaBadge': 'Alpha',
|
||||
'mcp.alphaBannerText':
|
||||
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
|
||||
'mcp.toolList.noTools': 'No tools available.',
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'autonomy.title': 'Agent autonomy',
|
||||
'autonomy.maxActionsLabel': 'Max actions per hour',
|
||||
'autonomy.maxActionsHelp':
|
||||
'Maximum tool actions an agent can run per rolling hour. New value applies to your next chat. Cron jobs and channel listeners keep their current limit until you restart OpenHuman.',
|
||||
'autonomy.statusSaving': 'Saving…',
|
||||
'autonomy.statusSaved': 'Saved.',
|
||||
'autonomy.statusFailed': 'Failed',
|
||||
'autonomy.unlimitedNote': 'Unlimited — rate limiting disabled.',
|
||||
'autonomy.invalidIntegerMsg':
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
};
|
||||
|
||||
export default id1;
|
||||
|
||||
@@ -421,6 +421,7 @@ const id2: TranslationMap = {
|
||||
'devOptions.menuComposioTriggers': 'Pemicu Integrasi',
|
||||
'devOptions.menuComposioTriggersDesc':
|
||||
'Konfigurasikan pengaturan triase AI untuk pemicu integrasi Composio',
|
||||
'mic.deviceSelector': 'Microphone device',
|
||||
};
|
||||
|
||||
export default id2;
|
||||
|
||||
@@ -405,6 +405,17 @@ const id4: TranslationMap = {
|
||||
'pages.settings.account.migration': 'Impor dari asisten lain',
|
||||
'pages.settings.account.migrationDesc':
|
||||
'Migrasikan memori dan catatan dari OpenClaw (dan, segera, Hermes) ke ruang kerja ini.',
|
||||
'composio.connect.scope.read': 'Read',
|
||||
'composio.connect.scope.readHint': 'Allow the agent to read data from this connection.',
|
||||
'composio.connect.scope.write': 'Write',
|
||||
'composio.connect.scope.writeHint':
|
||||
'Allow the agent to create or modify data through this connection.',
|
||||
'composio.connect.scope.admin': 'Admin',
|
||||
'composio.connect.scope.adminHint':
|
||||
'Allow the agent to manage settings, permissions, or destructive actions.',
|
||||
'pages.settings.composioSection.title': 'Composio',
|
||||
'pages.settings.composioSection.description':
|
||||
'Routing, triggers, and history for integrations powered by Composio.',
|
||||
};
|
||||
|
||||
export default id4;
|
||||
|
||||
@@ -514,6 +514,13 @@ const id5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'File konfigurasi',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'Pemilih klien MCP',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Routing mode, integration triggers, and trigger history archive.',
|
||||
'settings.appearance.tabBarHeading': 'Bottom tab bar',
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'When off, labels only appear on hover or for the active tab.',
|
||||
};
|
||||
|
||||
export default id5;
|
||||
|
||||
@@ -439,6 +439,76 @@ const it1: TranslationMap = {
|
||||
'channels.mcp.title': 'MCP Servers',
|
||||
'channels.mcp.description':
|
||||
'Browse and manage Model Context Protocol servers that extend the AI with new tools.',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
'settings.about.connectionModeCloud': 'Cloud',
|
||||
'settings.about.connectionModeUnset': 'Not selected',
|
||||
'settings.about.serverUrl': 'Server URL',
|
||||
'settings.about.serverUrlUnavailable': 'Unavailable',
|
||||
'settings.about.connectionHelperLocal':
|
||||
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
|
||||
'settings.about.connectionHelperCloud':
|
||||
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
|
||||
'settings.heartbeat.title': 'Heartbeat & loops',
|
||||
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
|
||||
'settings.ledgerUsage.title': 'Usage ledger',
|
||||
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
|
||||
'settings.search.title': 'Search engine',
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
'No key configured — search will fall back to Managed until a key is saved.',
|
||||
'settings.search.getApiKey': 'Get API key',
|
||||
'settings.search.save': 'Save',
|
||||
'settings.search.clear': 'Clear',
|
||||
'settings.search.show': 'Show',
|
||||
'settings.search.hide': 'Hide',
|
||||
'settings.search.statusSaving': 'Saving…',
|
||||
'settings.search.statusSaved': 'Saved.',
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'mcp.alphaBadge': 'Alpha',
|
||||
'mcp.alphaBannerText':
|
||||
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
|
||||
'mcp.toolList.noTools': 'No tools available.',
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'autonomy.title': 'Agent autonomy',
|
||||
'autonomy.maxActionsLabel': 'Max actions per hour',
|
||||
'autonomy.maxActionsHelp':
|
||||
'Maximum tool actions an agent can run per rolling hour. New value applies to your next chat. Cron jobs and channel listeners keep their current limit until you restart OpenHuman.',
|
||||
'autonomy.statusSaving': 'Saving…',
|
||||
'autonomy.statusSaved': 'Saved.',
|
||||
'autonomy.statusFailed': 'Failed',
|
||||
'autonomy.unlimitedNote': 'Unlimited — rate limiting disabled.',
|
||||
'autonomy.invalidIntegerMsg':
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
};
|
||||
|
||||
export default it1;
|
||||
|
||||
@@ -424,6 +424,7 @@ const it2: TranslationMap = {
|
||||
'devOptions.menuComposioTriggers': 'Integration Triggers',
|
||||
'devOptions.menuComposioTriggersDesc':
|
||||
'Configure AI triage settings for Composio integration triggers',
|
||||
'mic.deviceSelector': 'Microphone device',
|
||||
};
|
||||
|
||||
export default it2;
|
||||
|
||||
@@ -406,6 +406,17 @@ const it4: TranslationMap = {
|
||||
'pages.settings.account.migration': 'Importa da un altro assistente',
|
||||
'pages.settings.account.migrationDesc':
|
||||
'Migra memoria e note da OpenClaw (e presto Hermes) in questo spazio di lavoro.',
|
||||
'composio.connect.scope.read': 'Read',
|
||||
'composio.connect.scope.readHint': 'Allow the agent to read data from this connection.',
|
||||
'composio.connect.scope.write': 'Write',
|
||||
'composio.connect.scope.writeHint':
|
||||
'Allow the agent to create or modify data through this connection.',
|
||||
'composio.connect.scope.admin': 'Admin',
|
||||
'composio.connect.scope.adminHint':
|
||||
'Allow the agent to manage settings, permissions, or destructive actions.',
|
||||
'pages.settings.composioSection.title': 'Composio',
|
||||
'pages.settings.composioSection.description':
|
||||
'Routing, triggers, and history for integrations powered by Composio.',
|
||||
};
|
||||
|
||||
export default it4;
|
||||
|
||||
@@ -518,6 +518,13 @@ const it5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'Config file',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP client selector',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Routing mode, integration triggers, and trigger history archive.',
|
||||
'settings.appearance.tabBarHeading': 'Bottom tab bar',
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'When off, labels only appear on hover or for the active tab.',
|
||||
};
|
||||
|
||||
export default it5;
|
||||
|
||||
@@ -384,5 +384,123 @@ const ko1: TranslationMap = {
|
||||
'settings.about.releasesDesc': 'GitHub에서 릴리스 노트와 이전 빌드를 찾아보세요.',
|
||||
'settings.about.openReleases': 'GitHub 릴리스 열기',
|
||||
'settings.ai.overview': 'AI 시스템 개요',
|
||||
'migration.title': 'Import from another assistant',
|
||||
'migration.description':
|
||||
'Migrate memory and notes from another local assistant into this workspace. Start with a Preview to see exactly what would change, then Apply to copy the data over. Your current memory is backed up first.',
|
||||
'migration.vendorLabel': 'Source vendor',
|
||||
'migration.sourceLabel': 'Source workspace path (optional)',
|
||||
'migration.sourcePlaceholder': 'Leave blank to auto-detect (e.g. ~/.openclaw/workspace)',
|
||||
'migration.sourceHint':
|
||||
"Defaults to the vendor's standard location when blank. Set an explicit path if you've moved the workspace elsewhere.",
|
||||
'migration.previewAction': 'Preview',
|
||||
'migration.previewRunning': 'Previewing…',
|
||||
'migration.applyAction': 'Apply import',
|
||||
'migration.applyRunning': 'Importing…',
|
||||
'migration.applyDisclaimer':
|
||||
'Apply is unlocked after a successful Preview of the same source. Existing memory is backed up before any import.',
|
||||
'migration.reportTitlePreview': 'Preview — nothing imported yet',
|
||||
'migration.reportTitleApplied': 'Import complete',
|
||||
'migration.report.source': 'Source workspace',
|
||||
'migration.report.target': 'Target workspace',
|
||||
'migration.report.fromSqlite': 'From SQLite (brain.db)',
|
||||
'migration.report.fromMarkdown': 'From Markdown',
|
||||
'migration.report.imported': 'Imported',
|
||||
'migration.report.skippedUnchanged': 'Skipped (unchanged)',
|
||||
'migration.report.renamedConflicts': 'Renamed on conflict',
|
||||
'migration.report.warnings': 'Warnings',
|
||||
'migration.report.previewHint':
|
||||
'No data has been imported yet. Click Apply import to copy it over.',
|
||||
'migration.report.appliedHint':
|
||||
'Imported entries are now in your memory. Re-run Preview if you want to compare again.',
|
||||
'migration.hermesComingSoonPrefix': 'Hermes importer is on the roadmap — see ',
|
||||
'migration.hermesComingSoonSuffix':
|
||||
' for context. Pick OpenClaw to migrate today; Hermes lands in a follow-up.',
|
||||
'migration.hermesLinkText': '#1440',
|
||||
'migration.confirmImport.singular':
|
||||
'Import {count} entry into the current workspace?\n\nSource: {source}\nTarget: {target}\n\nExisting memory will be backed up before the import runs.',
|
||||
'migration.confirmImport.plural':
|
||||
'Import {count} entries into the current workspace?\n\nSource: {source}\nTarget: {target}\n\nExisting memory will be backed up before the import runs.',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
'settings.about.connectionModeCloud': 'Cloud',
|
||||
'settings.about.connectionModeUnset': 'Not selected',
|
||||
'settings.about.serverUrl': 'Server URL',
|
||||
'settings.about.serverUrlUnavailable': 'Unavailable',
|
||||
'settings.about.connectionHelperLocal':
|
||||
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
|
||||
'settings.about.connectionHelperCloud':
|
||||
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
|
||||
'settings.heartbeat.title': 'Heartbeat & loops',
|
||||
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
|
||||
'settings.ledgerUsage.title': 'Usage ledger',
|
||||
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
|
||||
'settings.search.title': 'Search engine',
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
'No key configured — search will fall back to Managed until a key is saved.',
|
||||
'settings.search.getApiKey': 'Get API key',
|
||||
'settings.search.save': 'Save',
|
||||
'settings.search.clear': 'Clear',
|
||||
'settings.search.show': 'Show',
|
||||
'settings.search.hide': 'Hide',
|
||||
'settings.search.statusSaving': 'Saving…',
|
||||
'settings.search.statusSaved': 'Saved.',
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.appearance': 'Appearance',
|
||||
'settings.appearanceDesc': 'Pick light, dark, or match your system theme',
|
||||
'settings.mascot': 'Mascot',
|
||||
'settings.mascotDesc': 'Pick the mascot color used across the app',
|
||||
'channels.authMode.managed_dm': 'Login with OpenHuman',
|
||||
'channels.authMode.oauth': 'OAuth Sign-in',
|
||||
'channels.authMode.bot_token': 'Use your own Bot Token',
|
||||
'channels.authMode.api_key': 'Use your own API Key',
|
||||
'channels.fieldRequired': '{field} is required',
|
||||
'channels.mcp.title': 'MCP Servers',
|
||||
'channels.mcp.description':
|
||||
'Browse and manage Model Context Protocol servers that extend the AI with new tools.',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'mcp.alphaBadge': 'Alpha',
|
||||
'mcp.alphaBannerText':
|
||||
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
|
||||
'mcp.toolList.noTools': 'No tools available.',
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'autonomy.title': 'Agent autonomy',
|
||||
'autonomy.maxActionsLabel': 'Max actions per hour',
|
||||
'autonomy.maxActionsHelp':
|
||||
'Maximum tool actions an agent can run per rolling hour. New value applies to your next chat. Cron jobs and channel listeners keep their current limit until you restart OpenHuman.',
|
||||
'autonomy.statusSaving': 'Saving…',
|
||||
'autonomy.statusSaved': 'Saved.',
|
||||
'autonomy.statusFailed': 'Failed',
|
||||
'autonomy.unlimitedNote': 'Unlimited — rate limiting disabled.',
|
||||
'autonomy.invalidIntegerMsg':
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
};
|
||||
export default ko1;
|
||||
|
||||
@@ -370,6 +370,50 @@ const ko2: TranslationMap = {
|
||||
'insights.relationships': '관계',
|
||||
'insights.skills': '스킬',
|
||||
'insights.opinions': '의견',
|
||||
'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434',
|
||||
'localModel.ollamaServer.label': 'Ollama Server URL',
|
||||
'localModel.ollamaServer.modelCount': 'models',
|
||||
'localModel.ollamaServer.placeholder': 'http://localhost:11434',
|
||||
'localModel.ollamaServer.reachable': 'Reachable',
|
||||
'localModel.ollamaServer.resetButton': 'Reset to default',
|
||||
'localModel.ollamaServer.saveButton': 'Save',
|
||||
'localModel.ollamaServer.testButton': 'Test Connection',
|
||||
'localModel.ollamaServer.unreachable': 'Unreachable',
|
||||
'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL',
|
||||
'mic.deviceSelector': 'Microphone device',
|
||||
'devOptions.menuAi': 'AI Configuration',
|
||||
'devOptions.menuAiDesc': 'Cloud providers, local Ollama models, and per-workload routing',
|
||||
'devOptions.menuScreenAware': 'Screen Awareness',
|
||||
'devOptions.menuScreenAwareDesc':
|
||||
'Screen capture permissions, monitoring policy, and session controls',
|
||||
'devOptions.menuMessaging': 'Messaging Channels',
|
||||
'devOptions.menuMessagingDesc':
|
||||
'Configure Telegram/Discord auth modes and default channel routing',
|
||||
'devOptions.menuTools': 'Tools',
|
||||
'devOptions.menuToolsDesc': 'Enable or disable capabilities OpenHuman can use on your behalf',
|
||||
'devOptions.menuAgentChat': 'Agent Chat',
|
||||
'devOptions.menuAgentChatDesc': 'Test agent conversation with model and temperature overrides',
|
||||
'devOptions.menuCronJobs': 'Cron Jobs',
|
||||
'devOptions.menuCronJobsDesc': 'View and configure scheduled jobs for runtime skills',
|
||||
'devOptions.menuLocalModelDebug': 'Local Model Debug',
|
||||
'devOptions.menuLocalModelDebugDesc':
|
||||
'Ollama config, asset downloads, model tests, and diagnostics',
|
||||
'devOptions.menuWebhooksDebug': 'Webhooks',
|
||||
'devOptions.menuWebhooksDebugDesc':
|
||||
'Inspect runtime webhook registrations and captured request logs',
|
||||
'devOptions.menuIntelligence': 'Intelligence',
|
||||
'devOptions.menuIntelligenceDesc': 'Memory workspace, subconscious engine, dreams, and settings',
|
||||
'devOptions.menuNotificationRouting': 'Notification Routing',
|
||||
'devOptions.menuNotificationRoutingDesc':
|
||||
'AI importance scoring and orchestrator escalation for integration alerts',
|
||||
'devOptions.menuComposeIOTriggers': 'ComposeIO Triggers',
|
||||
'devOptions.menuComposeIOTriggersDesc': 'View ComposeIO trigger history and archive',
|
||||
'devOptions.menuComposioRouting': 'Composio Routing (Direct Mode)',
|
||||
'devOptions.menuComposioRoutingDesc':
|
||||
'Bring your own Composio API key and route calls directly to backend.composio.dev',
|
||||
'devOptions.menuComposioTriggers': 'Integration Triggers',
|
||||
'devOptions.menuComposioTriggersDesc':
|
||||
'Configure AI triage settings for Composio integration triggers',
|
||||
};
|
||||
|
||||
export default ko2;
|
||||
|
||||
@@ -379,5 +379,29 @@ const ko3: TranslationMap = {
|
||||
'channels.telegram.savedRestartRequired':
|
||||
'채널이 저장되었습니다. 활성화하려면 앱을 다시 시작하세요.',
|
||||
'channels.web.alwaysAvailable': '항상 사용 가능',
|
||||
'channels.discord.displayName': 'Discord',
|
||||
'channels.discord.description': 'Send and receive messages via Discord.',
|
||||
'channels.discord.authMode.bot_token.description': 'Provide your own Discord bot token.',
|
||||
'channels.discord.authMode.oauth.description':
|
||||
'Install the OpenHuman bot to your Discord server via OAuth.',
|
||||
'channels.discord.authMode.managed_dm.description':
|
||||
'Link your personal Discord account to the OpenHuman bot.',
|
||||
'channels.discord.fields.bot_token.label': 'Bot Token',
|
||||
'channels.discord.fields.bot_token.placeholder': 'Your Discord bot token',
|
||||
'channels.discord.fields.guild_id.label': 'Server (Guild) ID',
|
||||
'channels.discord.fields.guild_id.placeholder': 'Optional: restrict to a specific server',
|
||||
'channels.telegram.displayName': 'Telegram',
|
||||
'channels.telegram.description': 'Send and receive messages via Telegram.',
|
||||
'channels.telegram.authMode.managed_dm.description':
|
||||
'Message the OpenHuman Telegram bot directly.',
|
||||
'channels.telegram.authMode.bot_token.description':
|
||||
'Provide your own Telegram Bot token from @BotFather.',
|
||||
'channels.telegram.fields.bot_token.label': 'Bot Token',
|
||||
'channels.telegram.fields.bot_token.placeholder': '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
|
||||
'channels.telegram.fields.allowed_users.label': 'Allowed Users',
|
||||
'channels.telegram.fields.allowed_users.placeholder': 'Comma-separated Telegram usernames',
|
||||
'channels.web.displayName': 'Web',
|
||||
'channels.web.description': 'Chat via the built-in web UI.',
|
||||
'channels.web.authMode.managed_dm.description': 'Use the embedded web chat — no setup required.',
|
||||
};
|
||||
export default ko3;
|
||||
|
||||
@@ -365,6 +365,57 @@ const ko4: TranslationMap = {
|
||||
'settings.billing.subscription.paymentConfirmed': '결제 확인됨',
|
||||
'settings.billing.subscription.perMonth': '월별',
|
||||
'settings.billing.subscription.popular': '인기',
|
||||
'composio.connect.scope.read': 'Read',
|
||||
'composio.connect.scope.readHint': 'Allow the agent to read data from this connection.',
|
||||
'composio.connect.scope.write': 'Write',
|
||||
'composio.connect.scope.writeHint':
|
||||
'Allow the agent to create or modify data through this connection.',
|
||||
'composio.connect.scope.admin': 'Admin',
|
||||
'composio.connect.scope.adminHint':
|
||||
'Allow the agent to manage settings, permissions, or destructive actions.',
|
||||
'composio.connect.dynamicsOrgNameLabel': 'Dynamics 365 Organization Name',
|
||||
'composio.connect.dynamicsOrgNameHint':
|
||||
'For example, "myorg" for myorg.crm.dynamics.com. Enter the short org name only, not the full URL.',
|
||||
'composio.connect.needsFieldsPrefix': 'To connect',
|
||||
'composio.connect.needsFieldsSuffix':
|
||||
'we need a bit more information. Fill in the missing fields below and try again.',
|
||||
'composio.connect.requiredFieldEmpty': 'This field is required.',
|
||||
'composio.connect.wabaIdHint':
|
||||
'Find it via GET /me/businesses then GET /{business_id}/owned_whatsapp_business_accounts using your Meta access token.',
|
||||
'onboarding.contextGathering.coreAlive': 'Core is reachable — first launch can take a minute.',
|
||||
'onboarding.contextGathering.coreAliveProbing': 'Checking core connection…',
|
||||
'onboarding.contextGathering.coreUnreachable':
|
||||
'Core is not responding. You can continue and try again later.',
|
||||
'onboarding.contextGathering.stillWorkingDesc':
|
||||
'First launch can take 30–60 seconds while we warm up your local model and tools. You can continue to chat at any time — profile build keeps running in the background.',
|
||||
'onboarding.contextGathering.stillWorkingTitle': 'Still working on your profile…',
|
||||
'overlay.ariaCompanion': 'Companion active',
|
||||
'overlay.companion.error': 'Error',
|
||||
'overlay.companion.listening': 'Listening…',
|
||||
'overlay.companion.pointing': 'Pointing…',
|
||||
'overlay.companion.speaking': 'Speaking…',
|
||||
'overlay.companion.thinking': 'Thinking…',
|
||||
'pages.settings.account.migration': 'Import from another assistant',
|
||||
'pages.settings.account.migrationDesc':
|
||||
'Migrate memory and notes from OpenClaw (or, soon, Hermes) into this workspace.',
|
||||
'pages.settings.composioSection.title': 'Composio',
|
||||
'pages.settings.composioSection.description':
|
||||
'Routing, triggers, and history for integrations powered by Composio.',
|
||||
'pages.settings.features.desktopCompanion': 'Desktop Companion',
|
||||
'pages.settings.features.desktopCompanionDesc':
|
||||
'Voice assistant with screen awareness — listens, sees, speaks, points',
|
||||
'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer <your key>',
|
||||
'settings.ai.openAiCompat.authHeaderLabel': 'Auth header',
|
||||
'settings.ai.openAiCompat.baseUrlLabel': 'Base URL',
|
||||
'settings.ai.openAiCompat.baseUrlUnavailable': 'Unavailable',
|
||||
'settings.ai.openAiCompat.clearKey': 'Clear key',
|
||||
'settings.ai.openAiCompat.description':
|
||||
"Point local harnesses at this /v1 server to route through the providers configured below. Authentication uses a stable key you set here, not the app's internal core bearer.",
|
||||
'settings.ai.openAiCompat.keyConfigured': 'Key configured',
|
||||
'settings.ai.openAiCompat.keyRequired': 'Key required',
|
||||
'settings.ai.openAiCompat.rotateKey': 'Rotate key',
|
||||
'settings.ai.openAiCompat.setKey': 'Set key',
|
||||
'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint',
|
||||
};
|
||||
|
||||
export default ko4;
|
||||
|
||||
@@ -473,6 +473,53 @@ const ko5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'Config file',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP client selector',
|
||||
'settings.developerMenu.ai.title': 'AI Configuration',
|
||||
'settings.developerMenu.ai.desc':
|
||||
'Cloud providers, local Ollama models, and per-workload routing',
|
||||
'settings.developerMenu.screenAwareness.title': 'Screen Awareness',
|
||||
'settings.developerMenu.screenAwareness.desc':
|
||||
'Screen capture permissions, monitoring policy, and session controls',
|
||||
'settings.developerMenu.messagingChannels.title': 'Messaging Channels',
|
||||
'settings.developerMenu.messagingChannels.desc':
|
||||
'Configure Telegram/Discord auth modes and default channel routing',
|
||||
'settings.developerMenu.tools.title': 'Tools',
|
||||
'settings.developerMenu.tools.desc':
|
||||
'Enable or disable capabilities OpenHuman can use on your behalf',
|
||||
'settings.developerMenu.agentChat.title': 'Agent Chat',
|
||||
'settings.developerMenu.agentChat.desc':
|
||||
'Test agent conversation with model and temperature overrides',
|
||||
'settings.developerMenu.cronJobs.title': 'Cron Jobs',
|
||||
'settings.developerMenu.cronJobs.desc': 'View and configure scheduled jobs for runtime skills',
|
||||
'settings.developerMenu.localModelDebug.title': 'Local Model Debug',
|
||||
'settings.developerMenu.localModelDebug.desc':
|
||||
'Ollama config, asset downloads, model tests, and diagnostics',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Routing mode, integration triggers, and trigger history archive.',
|
||||
'settings.developerMenu.webhooks.title': 'Webhooks',
|
||||
'settings.developerMenu.webhooks.desc':
|
||||
'Inspect runtime webhook registrations and captured request logs',
|
||||
'settings.developerMenu.intelligence.title': 'Intelligence',
|
||||
'settings.developerMenu.intelligence.desc':
|
||||
'Memory workspace, subconscious engine, dreams, and settings',
|
||||
'settings.developerMenu.notificationRouting.title': 'Notification Routing',
|
||||
'settings.developerMenu.notificationRouting.desc':
|
||||
'AI importance scoring and orchestrator escalation for integration alerts',
|
||||
'settings.developerMenu.composeioTriggers.title': 'ComposeIO Triggers',
|
||||
'settings.developerMenu.composeioTriggers.desc': 'View ComposeIO trigger history and archive',
|
||||
'settings.developerMenu.composioRouting.title': 'Composio Routing (Direct Mode)',
|
||||
'settings.developerMenu.composioRouting.desc':
|
||||
'Bring your own Composio API key and route calls directly to backend.composio.dev',
|
||||
'settings.developerMenu.integrationTriggers.title': 'Integration Triggers',
|
||||
'settings.developerMenu.integrationTriggers.desc':
|
||||
'Configure AI triage settings for Composio integration triggers',
|
||||
'settings.appearance.menuDesc': 'Pick light, dark, or match your system theme',
|
||||
'settings.mascot.menuTitle': 'Mascot',
|
||||
'settings.mascot.menuDesc': 'Pick the mascot color used across the app',
|
||||
'settings.appearance.tabBarHeading': 'Bottom tab bar',
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'When off, labels only appear on hover or for the active tab.',
|
||||
};
|
||||
|
||||
export default ko5;
|
||||
|
||||
@@ -444,6 +444,76 @@ const pt1: TranslationMap = {
|
||||
'channels.mcp.title': 'MCP Servers',
|
||||
'channels.mcp.description':
|
||||
'Browse and manage Model Context Protocol servers that extend the AI with new tools.',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
'settings.about.connectionModeCloud': 'Cloud',
|
||||
'settings.about.connectionModeUnset': 'Not selected',
|
||||
'settings.about.serverUrl': 'Server URL',
|
||||
'settings.about.serverUrlUnavailable': 'Unavailable',
|
||||
'settings.about.connectionHelperLocal':
|
||||
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
|
||||
'settings.about.connectionHelperCloud':
|
||||
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
|
||||
'settings.heartbeat.title': 'Heartbeat & loops',
|
||||
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
|
||||
'settings.ledgerUsage.title': 'Usage ledger',
|
||||
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
|
||||
'settings.search.title': 'Search engine',
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
'No key configured — search will fall back to Managed until a key is saved.',
|
||||
'settings.search.getApiKey': 'Get API key',
|
||||
'settings.search.save': 'Save',
|
||||
'settings.search.clear': 'Clear',
|
||||
'settings.search.show': 'Show',
|
||||
'settings.search.hide': 'Hide',
|
||||
'settings.search.statusSaving': 'Saving…',
|
||||
'settings.search.statusSaved': 'Saved.',
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'mcp.alphaBadge': 'Alpha',
|
||||
'mcp.alphaBannerText':
|
||||
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
|
||||
'mcp.toolList.noTools': 'No tools available.',
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'autonomy.title': 'Agent autonomy',
|
||||
'autonomy.maxActionsLabel': 'Max actions per hour',
|
||||
'autonomy.maxActionsHelp':
|
||||
'Maximum tool actions an agent can run per rolling hour. New value applies to your next chat. Cron jobs and channel listeners keep their current limit until you restart OpenHuman.',
|
||||
'autonomy.statusSaving': 'Saving…',
|
||||
'autonomy.statusSaved': 'Saved.',
|
||||
'autonomy.statusFailed': 'Failed',
|
||||
'autonomy.unlimitedNote': 'Unlimited — rate limiting disabled.',
|
||||
'autonomy.invalidIntegerMsg':
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
};
|
||||
|
||||
export default pt1;
|
||||
|
||||
@@ -427,6 +427,7 @@ const pt2: TranslationMap = {
|
||||
'devOptions.menuComposioTriggers': 'Integration Triggers',
|
||||
'devOptions.menuComposioTriggersDesc':
|
||||
'Configure AI triage settings for Composio integration triggers',
|
||||
'mic.deviceSelector': 'Microphone device',
|
||||
};
|
||||
|
||||
export default pt2;
|
||||
|
||||
@@ -406,6 +406,17 @@ const pt4: TranslationMap = {
|
||||
'pages.settings.account.migration': 'Importar de outro assistente',
|
||||
'pages.settings.account.migrationDesc':
|
||||
'Migre memória e anotações do OpenClaw (e, em breve, do Hermes) para este espaço de trabalho.',
|
||||
'composio.connect.scope.read': 'Read',
|
||||
'composio.connect.scope.readHint': 'Allow the agent to read data from this connection.',
|
||||
'composio.connect.scope.write': 'Write',
|
||||
'composio.connect.scope.writeHint':
|
||||
'Allow the agent to create or modify data through this connection.',
|
||||
'composio.connect.scope.admin': 'Admin',
|
||||
'composio.connect.scope.adminHint':
|
||||
'Allow the agent to manage settings, permissions, or destructive actions.',
|
||||
'pages.settings.composioSection.title': 'Composio',
|
||||
'pages.settings.composioSection.description':
|
||||
'Routing, triggers, and history for integrations powered by Composio.',
|
||||
};
|
||||
|
||||
export default pt4;
|
||||
|
||||
@@ -518,6 +518,13 @@ const pt5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'Config file',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP client selector',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Routing mode, integration triggers, and trigger history archive.',
|
||||
'settings.appearance.tabBarHeading': 'Bottom tab bar',
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'When off, labels only appear on hover or for the active tab.',
|
||||
};
|
||||
|
||||
export default pt5;
|
||||
|
||||
@@ -434,6 +434,76 @@ const ru1: TranslationMap = {
|
||||
'channels.mcp.title': 'MCP Servers',
|
||||
'channels.mcp.description':
|
||||
'Browse and manage Model Context Protocol servers that extend the AI with new tools.',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
'settings.about.connectionModeCloud': 'Cloud',
|
||||
'settings.about.connectionModeUnset': 'Not selected',
|
||||
'settings.about.serverUrl': 'Server URL',
|
||||
'settings.about.serverUrlUnavailable': 'Unavailable',
|
||||
'settings.about.connectionHelperLocal':
|
||||
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
|
||||
'settings.about.connectionHelperCloud':
|
||||
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
|
||||
'settings.heartbeat.title': 'Heartbeat & loops',
|
||||
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
|
||||
'settings.ledgerUsage.title': 'Usage ledger',
|
||||
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
|
||||
'settings.search.title': 'Search engine',
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
'No key configured — search will fall back to Managed until a key is saved.',
|
||||
'settings.search.getApiKey': 'Get API key',
|
||||
'settings.search.save': 'Save',
|
||||
'settings.search.clear': 'Clear',
|
||||
'settings.search.show': 'Show',
|
||||
'settings.search.hide': 'Hide',
|
||||
'settings.search.statusSaving': 'Saving…',
|
||||
'settings.search.statusSaved': 'Saved.',
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'mcp.alphaBadge': 'Alpha',
|
||||
'mcp.alphaBannerText':
|
||||
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
|
||||
'mcp.toolList.noTools': 'No tools available.',
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'autonomy.title': 'Agent autonomy',
|
||||
'autonomy.maxActionsLabel': 'Max actions per hour',
|
||||
'autonomy.maxActionsHelp':
|
||||
'Maximum tool actions an agent can run per rolling hour. New value applies to your next chat. Cron jobs and channel listeners keep their current limit until you restart OpenHuman.',
|
||||
'autonomy.statusSaving': 'Saving…',
|
||||
'autonomy.statusSaved': 'Saved.',
|
||||
'autonomy.statusFailed': 'Failed',
|
||||
'autonomy.unlimitedNote': 'Unlimited — rate limiting disabled.',
|
||||
'autonomy.invalidIntegerMsg':
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
};
|
||||
|
||||
export default ru1;
|
||||
|
||||
@@ -423,6 +423,7 @@ const ru2: TranslationMap = {
|
||||
'devOptions.menuComposioTriggers': 'Integration Triggers',
|
||||
'devOptions.menuComposioTriggersDesc':
|
||||
'Configure AI triage settings for Composio integration triggers',
|
||||
'mic.deviceSelector': 'Microphone device',
|
||||
};
|
||||
|
||||
export default ru2;
|
||||
|
||||
@@ -403,6 +403,17 @@ const ru4: TranslationMap = {
|
||||
'pages.settings.account.migration': 'Импорт из другого ассистента',
|
||||
'pages.settings.account.migrationDesc':
|
||||
'Перенесите память и заметки из OpenClaw (а вскоре и Hermes) в это рабочее пространство.',
|
||||
'composio.connect.scope.read': 'Read',
|
||||
'composio.connect.scope.readHint': 'Allow the agent to read data from this connection.',
|
||||
'composio.connect.scope.write': 'Write',
|
||||
'composio.connect.scope.writeHint':
|
||||
'Allow the agent to create or modify data through this connection.',
|
||||
'composio.connect.scope.admin': 'Admin',
|
||||
'composio.connect.scope.adminHint':
|
||||
'Allow the agent to manage settings, permissions, or destructive actions.',
|
||||
'pages.settings.composioSection.title': 'Composio',
|
||||
'pages.settings.composioSection.description':
|
||||
'Routing, triggers, and history for integrations powered by Composio.',
|
||||
};
|
||||
|
||||
export default ru4;
|
||||
|
||||
@@ -515,6 +515,13 @@ const ru5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': 'Config file',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP client selector',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Routing mode, integration triggers, and trigger history archive.',
|
||||
'settings.appearance.tabBarHeading': 'Bottom tab bar',
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'When off, labels only appear on hover or for the active tab.',
|
||||
};
|
||||
|
||||
export default ru5;
|
||||
|
||||
@@ -416,6 +416,76 @@ const zhCN1: TranslationMap = {
|
||||
'settings.appearanceDesc': '选择浅色、深色或跟随系统主题',
|
||||
'settings.mascot': '吉祥物',
|
||||
'settings.mascotDesc': '选择应用中使用的吉祥物颜色',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
'settings.about.connectionModeCloud': 'Cloud',
|
||||
'settings.about.connectionModeUnset': 'Not selected',
|
||||
'settings.about.serverUrl': 'Server URL',
|
||||
'settings.about.serverUrlUnavailable': 'Unavailable',
|
||||
'settings.about.connectionHelperLocal':
|
||||
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
|
||||
'settings.about.connectionHelperCloud':
|
||||
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
|
||||
'settings.heartbeat.title': 'Heartbeat & loops',
|
||||
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
|
||||
'settings.ledgerUsage.title': 'Usage ledger',
|
||||
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
|
||||
'settings.search.title': 'Search engine',
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
'No key configured — search will fall back to Managed until a key is saved.',
|
||||
'settings.search.getApiKey': 'Get API key',
|
||||
'settings.search.save': 'Save',
|
||||
'settings.search.clear': 'Clear',
|
||||
'settings.search.show': 'Show',
|
||||
'settings.search.hide': 'Hide',
|
||||
'settings.search.statusSaving': 'Saving…',
|
||||
'settings.search.statusSaved': 'Saved.',
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'mcp.alphaBadge': 'Alpha',
|
||||
'mcp.alphaBannerText':
|
||||
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
|
||||
'mcp.toolList.noTools': 'No tools available.',
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'autonomy.title': 'Agent autonomy',
|
||||
'autonomy.maxActionsLabel': 'Max actions per hour',
|
||||
'autonomy.maxActionsHelp':
|
||||
'Maximum tool actions an agent can run per rolling hour. New value applies to your next chat. Cron jobs and channel listeners keep their current limit until you restart OpenHuman.',
|
||||
'autonomy.statusSaving': 'Saving…',
|
||||
'autonomy.statusSaved': 'Saved.',
|
||||
'autonomy.statusFailed': 'Failed',
|
||||
'autonomy.unlimitedNote': 'Unlimited — rate limiting disabled.',
|
||||
'autonomy.invalidIntegerMsg':
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
};
|
||||
|
||||
export default zhCN1;
|
||||
|
||||
@@ -397,6 +397,7 @@ const zhCN2: TranslationMap = {
|
||||
'使用你自己的 Composio API 密钥,将调用直接路由到 backend.composio.dev',
|
||||
'devOptions.menuComposioTriggers': '集成触发器',
|
||||
'devOptions.menuComposioTriggersDesc': '为 Composio 集成触发器配置 AI 分级设置',
|
||||
'mic.deviceSelector': 'Microphone device',
|
||||
};
|
||||
|
||||
export default zhCN2;
|
||||
|
||||
@@ -397,6 +397,17 @@ const zhCN4: TranslationMap = {
|
||||
'pages.settings.account.migration': '从其他助手导入',
|
||||
'pages.settings.account.migrationDesc':
|
||||
'将 OpenClaw(即将支持 Hermes)的记忆和笔记迁移到此工作区。',
|
||||
'composio.connect.scope.read': 'Read',
|
||||
'composio.connect.scope.readHint': 'Allow the agent to read data from this connection.',
|
||||
'composio.connect.scope.write': 'Write',
|
||||
'composio.connect.scope.writeHint':
|
||||
'Allow the agent to create or modify data through this connection.',
|
||||
'composio.connect.scope.admin': 'Admin',
|
||||
'composio.connect.scope.adminHint':
|
||||
'Allow the agent to manage settings, permissions, or destructive actions.',
|
||||
'pages.settings.composioSection.title': 'Composio',
|
||||
'pages.settings.composioSection.description':
|
||||
'Routing, triggers, and history for integrations powered by Composio.',
|
||||
};
|
||||
|
||||
export default zhCN4;
|
||||
|
||||
@@ -485,6 +485,13 @@ const zhCN5: TranslationMap = {
|
||||
'settings.mcpServer.clientZed': 'Zed',
|
||||
'settings.mcpServer.configFilePath': '配置文件',
|
||||
'settings.mcpServer.clientSelectorAriaLabel': 'MCP 客户端选择器',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Routing mode, integration triggers, and trigger history archive.',
|
||||
'settings.appearance.tabBarHeading': 'Bottom tab bar',
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'When off, labels only appear on hover or for the active tab.',
|
||||
};
|
||||
|
||||
export default zhCN5;
|
||||
|
||||
+90
-1
@@ -226,7 +226,12 @@ const en: TranslationMap = {
|
||||
'skills.available': 'Available',
|
||||
'skills.addAccount': 'Add Account',
|
||||
'skills.channels': 'Channels',
|
||||
'skills.integrations': 'Integrations',
|
||||
'skills.integrations': 'Composio Integrations',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
|
||||
// Intelligence / Memory
|
||||
'memory.title': 'Memory',
|
||||
@@ -486,6 +491,71 @@ const en: TranslationMap = {
|
||||
'settings.about.releases': 'Releases',
|
||||
'settings.about.releasesDesc': 'Browse release notes and earlier builds on GitHub.',
|
||||
'settings.about.openReleases': 'Open GitHub releases',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
'settings.about.connectionModeCloud': 'Cloud',
|
||||
'settings.about.connectionModeUnset': 'Not selected',
|
||||
'settings.about.serverUrl': 'Server URL',
|
||||
'settings.about.serverUrlUnavailable': 'Unavailable',
|
||||
'settings.about.connectionHelperLocal':
|
||||
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
|
||||
'settings.about.connectionHelperCloud':
|
||||
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
|
||||
'settings.heartbeat.title': 'Heartbeat & loops',
|
||||
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
|
||||
'settings.ledgerUsage.title': 'Usage ledger',
|
||||
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
|
||||
'settings.search.title': 'Search engine',
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
'No key configured — search will fall back to Managed until a key is saved.',
|
||||
'settings.search.getApiKey': 'Get API key',
|
||||
'settings.search.save': 'Save',
|
||||
'settings.search.clear': 'Clear',
|
||||
'settings.search.show': 'Show',
|
||||
'settings.search.hide': 'Hide',
|
||||
'settings.search.statusSaving': 'Saving…',
|
||||
'settings.search.statusSaved': 'Saved.',
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'mcp.alphaBadge': 'Alpha',
|
||||
'mcp.alphaBannerText':
|
||||
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
|
||||
'mcp.toolList.noTools': 'No tools available.',
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'autonomy.title': 'Agent autonomy',
|
||||
'autonomy.maxActionsLabel': 'Max actions per hour',
|
||||
'autonomy.maxActionsHelp':
|
||||
'Maximum tool actions an agent can run per rolling hour. New value applies to your next chat. Cron jobs and channel listeners keep their current limit until you restart OpenHuman.',
|
||||
'autonomy.statusSaving': 'Saving…',
|
||||
'autonomy.statusSaved': 'Saved.',
|
||||
'autonomy.statusFailed': 'Failed',
|
||||
'autonomy.unlimitedNote': 'Unlimited — rate limiting disabled.',
|
||||
'autonomy.invalidIntegerMsg':
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
|
||||
// Settings: AI
|
||||
'settings.ai.overview': 'AI System Overview',
|
||||
@@ -871,6 +941,7 @@ const en: TranslationMap = {
|
||||
'mic.tapAndSpeak': 'Tap and speak',
|
||||
'mic.stopRecording': 'Stop recording and send',
|
||||
'mic.startRecording': 'Start recording',
|
||||
'mic.deviceSelector': 'Microphone device',
|
||||
|
||||
// Token
|
||||
'token.usageLimitReached': 'Usage limit reached',
|
||||
@@ -1450,6 +1521,14 @@ const en: TranslationMap = {
|
||||
'composio.connect.retryConnection': 'Retry connection',
|
||||
'composio.connect.scopeLoadError': "Couldn't load scope preferences: {msg}",
|
||||
'composio.connect.scopeSaveError': "Couldn't save {key} scope: {msg}",
|
||||
'composio.connect.scope.read': 'Read',
|
||||
'composio.connect.scope.readHint': 'Allow the agent to read data from this connection.',
|
||||
'composio.connect.scope.write': 'Write',
|
||||
'composio.connect.scope.writeHint':
|
||||
'Allow the agent to create or modify data through this connection.',
|
||||
'composio.connect.scope.admin': 'Admin',
|
||||
'composio.connect.scope.adminHint':
|
||||
'Allow the agent to manage settings, permissions, or destructive actions.',
|
||||
'composio.connect.subdomainInvalid':
|
||||
'Enter the short subdomain only (e.g. "acme"), not the full URL. It should contain only letters, numbers, and hyphens.',
|
||||
'composio.connect.subdomainRequired': 'Please enter your Atlassian subdomain to continue.',
|
||||
@@ -1596,6 +1675,12 @@ const en: TranslationMap = {
|
||||
'pages.settings.aiSection.description':
|
||||
'Language model providers, local Ollama, and voice (STT / TTS).',
|
||||
'pages.settings.aiSection.title': 'AI',
|
||||
'pages.settings.composioSection.title': 'Composio',
|
||||
'pages.settings.composioSection.description':
|
||||
'Routing, triggers, and history for integrations powered by Composio.',
|
||||
'settings.developerMenu.composio.title': 'Composio',
|
||||
'settings.developerMenu.composio.desc':
|
||||
'Routing mode, integration triggers, and trigger history archive.',
|
||||
'pages.settings.features.desktopCompanion': 'Desktop Companion',
|
||||
'pages.settings.features.desktopCompanionDesc':
|
||||
'Voice assistant with screen awareness — listens, sees, speaks, points',
|
||||
@@ -2054,6 +2139,10 @@ const en: TranslationMap = {
|
||||
'settings.appearance.modeSystemDesc': 'Follow your OS appearance setting.',
|
||||
'settings.appearance.helperText':
|
||||
'Dark mode switches the entire app — chat, settings, panels — to a dim palette. "Match system" follows your OS appearance and updates live.',
|
||||
'settings.appearance.tabBarHeading': 'Bottom tab bar',
|
||||
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
|
||||
'settings.appearance.tabBarAlwaysShowLabelsDesc':
|
||||
'When off, labels only appear on hover or for the active tab.',
|
||||
'settings.mascot.active': 'Active',
|
||||
'settings.mascot.characterDesc': 'Choose your OpenHuman character.',
|
||||
'settings.mascot.characterHeading': 'Character',
|
||||
|
||||
@@ -170,14 +170,19 @@ function formatAgentProfileAgentLabel(agentId: string): string {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsProps = {}) => {
|
||||
const Conversations = ({
|
||||
variant = 'page',
|
||||
composer: composerProp = 'text',
|
||||
}: ConversationsProps = {}) => {
|
||||
const [composerOverride, setComposerOverride] = useState<'mic-cloud' | 'text' | null>(null);
|
||||
const composer = composerOverride ?? composerProp;
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { threads, selectedThreadId, messages, isLoadingMessages, messagesError, activeThreadId } =
|
||||
useAppSelector(state => state.thread);
|
||||
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [showSidebar, setShowSidebar] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
||||
const [inputMode, setInputMode] = useState<InputMode>('text');
|
||||
@@ -1843,15 +1848,18 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
)}
|
||||
|
||||
{composer === 'mic-cloud' ? (
|
||||
<MicComposer
|
||||
// Without `!selectedThreadId`, a mic submit before a thread is
|
||||
// ready hits `handleSendMessage`'s early return and the
|
||||
// transcript is silently dropped — the user spoke into the void.
|
||||
disabled={composerInteractionBlocked || isSending || !selectedThreadId}
|
||||
onSubmit={text => handleSendMessage(text)}
|
||||
onError={message => setSendError(chatSendError('voice_transcription', message))}
|
||||
showDeviceSelector
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3 py-1">
|
||||
<MicComposer
|
||||
// Without `!selectedThreadId`, a mic submit before a thread is
|
||||
// ready hits `handleSendMessage`'s early return and the
|
||||
// transcript is silently dropped — the user spoke into the void.
|
||||
disabled={composerInteractionBlocked || isSending || !selectedThreadId}
|
||||
onSubmit={text => handleSendMessage(text)}
|
||||
onError={message => setSendError(chatSendError('voice_transcription', message))}
|
||||
showDeviceSelector
|
||||
onSwitchToText={() => setComposerOverride('text')}
|
||||
/>
|
||||
</div>
|
||||
) : inputMode === 'text' ? (
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="relative flex flex-1 items-center justify-center rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 transition-all focus-within:border-primary-500/50 focus-within:ring-1 focus-within:ring-primary-500/50">
|
||||
@@ -1881,6 +1889,28 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
/>
|
||||
{/* Voice input mic hidden per #717 (inputMode='voice' path retained). */}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('mic.startRecording')}
|
||||
title={t('mic.startRecording')}
|
||||
onClick={() => setComposerOverride('mic-cloud')}
|
||||
disabled={composerInteractionBlocked || isSending}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-500 dark:text-neutral-400 hover:text-primary-500 dark:hover:text-primary-400 hover:border-primary-300 dark:hover:border-primary-700 transition-colors flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 10v2a7 7 0 01-14 0v-2M12 19v4m-4 0h8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
data-testid="send-message-button"
|
||||
aria-label={t('chat.send')}
|
||||
|
||||
@@ -69,7 +69,7 @@ const Notifications = () => {
|
||||
{/* Core-bridge notifications — system events */}
|
||||
<div
|
||||
data-testid="system-events-section"
|
||||
className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden">
|
||||
className="max-w-2xl mx-auto bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-stone-100 dark:border-neutral-800 px-4 py-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-stone-900 dark:text-neutral-100">
|
||||
|
||||
@@ -16,18 +16,20 @@ import ComposioTriagePanel from '../components/settings/panels/ComposioTriagePan
|
||||
import CronJobsPanel from '../components/settings/panels/CronJobsPanel';
|
||||
import DeveloperOptionsPanel from '../components/settings/panels/DeveloperOptionsPanel';
|
||||
import DevicesPanel from '../components/settings/panels/DevicesPanel';
|
||||
import HeartbeatPanel from '../components/settings/panels/HeartbeatPanel';
|
||||
import LedgerUsagePanel from '../components/settings/panels/LedgerUsagePanel';
|
||||
import LocalModelDebugPanel from '../components/settings/panels/LocalModelDebugPanel';
|
||||
import MascotPanel from '../components/settings/panels/MascotPanel';
|
||||
import McpServerPanel from '../components/settings/panels/McpServerPanel';
|
||||
import MemoryDataPanel from '../components/settings/panels/MemoryDataPanel';
|
||||
import MemoryDebugPanel from '../components/settings/panels/MemoryDebugPanel';
|
||||
import MessagingPanel from '../components/settings/panels/MessagingPanel';
|
||||
import MigrationPanel from '../components/settings/panels/MigrationPanel';
|
||||
import NotificationsTabbedPanel from '../components/settings/panels/NotificationsTabbedPanel';
|
||||
import PrivacyPanel from '../components/settings/panels/PrivacyPanel';
|
||||
import RecoveryPhrasePanel from '../components/settings/panels/RecoveryPhrasePanel';
|
||||
import ScreenAwarenessDebugPanel from '../components/settings/panels/ScreenAwarenessDebugPanel';
|
||||
import ScreenIntelligencePanel from '../components/settings/panels/ScreenIntelligencePanel';
|
||||
import SearchPanel from '../components/settings/panels/SearchPanel';
|
||||
import TeamInvitesPanel from '../components/settings/panels/TeamInvitesPanel';
|
||||
import TeamManagementPanel from '../components/settings/panels/TeamManagementPanel';
|
||||
import TeamMembersPanel from '../components/settings/panels/TeamMembersPanel';
|
||||
@@ -161,19 +163,26 @@ const VoiceIcon = (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const WrappedSettingsPage = ({ children }: { children: ReactNode }) => {
|
||||
const WrappedSettingsPage = ({
|
||||
children,
|
||||
maxWidthClass = 'max-w-lg',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
maxWidthClass?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="p-4 pt-6">
|
||||
<div className="max-w-lg mx-auto bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 overflow-hidden">
|
||||
<div
|
||||
className={`${maxWidthClass} mx-auto bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 overflow-hidden`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function wrapSettingsPage(element: ReactNode) {
|
||||
function wrapSettingsPage(element: ReactNode, opts?: { maxWidthClass?: string }) {
|
||||
return (
|
||||
<WrappedSettingsPage>
|
||||
<WrappedSettingsPage maxWidthClass={opts?.maxWidthClass}>
|
||||
{element}
|
||||
<div className="border-t border-stone-100 dark:border-neutral-800 px-4 py-3 text-center text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
Beta build - v{APP_VERSION}
|
||||
@@ -270,6 +279,58 @@ const Settings = () => {
|
||||
route: 'voice',
|
||||
icon: VoiceIcon,
|
||||
},
|
||||
{
|
||||
id: 'agent-chat',
|
||||
title: t('settings.developerMenu.agentChat.title'),
|
||||
description: t('settings.developerMenu.agentChat.desc'),
|
||||
route: 'agent-chat',
|
||||
icon: LlmIcon,
|
||||
},
|
||||
{
|
||||
id: 'autonomy',
|
||||
title: t('settings.developerMenu.autonomy.title'),
|
||||
description: t('settings.developerMenu.autonomy.desc'),
|
||||
route: 'autonomy',
|
||||
icon: LlmIcon,
|
||||
},
|
||||
{
|
||||
id: 'local-model-debug',
|
||||
title: t('settings.developerMenu.localModelDebug.title'),
|
||||
description: t('settings.developerMenu.localModelDebug.desc'),
|
||||
route: 'local-model-debug',
|
||||
icon: LlmIcon,
|
||||
},
|
||||
{
|
||||
id: 'heartbeat',
|
||||
title: t('settings.heartbeat.title'),
|
||||
description: t('settings.heartbeat.desc'),
|
||||
route: 'heartbeat',
|
||||
icon: LlmIcon,
|
||||
},
|
||||
{
|
||||
id: 'ledger-usage',
|
||||
title: t('settings.ledgerUsage.title'),
|
||||
description: t('settings.ledgerUsage.desc'),
|
||||
route: 'ledger-usage',
|
||||
icon: LlmIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const composioSettingsItems = [
|
||||
{
|
||||
id: 'composio-routing',
|
||||
title: t('settings.developerMenu.composioRouting.title'),
|
||||
description: t('settings.developerMenu.composioRouting.desc'),
|
||||
route: 'composio-routing',
|
||||
icon: ToolsIcon,
|
||||
},
|
||||
{
|
||||
id: 'webhooks-triggers',
|
||||
title: t('settings.developerMenu.composeioTriggers.title'),
|
||||
description: t('settings.developerMenu.composeioTriggers.desc'),
|
||||
route: 'webhooks-triggers',
|
||||
icon: ToolsIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -307,6 +368,16 @@ const Settings = () => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="composio"
|
||||
element={wrapSettingsPage(
|
||||
<SettingsSectionPage
|
||||
title={t('pages.settings.composioSection.title')}
|
||||
description={t('pages.settings.composioSection.description')}
|
||||
items={composioSettingsItems}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* Account & Billing leaf panels */}
|
||||
<Route path="recovery-phrase" element={wrapSettingsPage(<RecoveryPhrasePanel />)} />
|
||||
<Route path="team" element={wrapSettingsPage(<TeamPanel />)} />
|
||||
@@ -329,7 +400,6 @@ const Settings = () => {
|
||||
<Route path="screen-intelligence" element={wrapSettingsPage(<ScreenIntelligencePanel />)} />
|
||||
<Route path="autocomplete" element={wrapSettingsPage(<AutocompletePanel />)} />
|
||||
<Route path="voice" element={wrapSettingsPage(<VoicePanel />)} />
|
||||
<Route path="messaging" element={wrapSettingsPage(<MessagingPanel />)} />
|
||||
<Route path="notifications" element={wrapSettingsPage(<NotificationsTabbedPanel />)} />
|
||||
<Route path="mascot" element={wrapSettingsPage(<MascotPanel />)} />
|
||||
<Route path="appearance" element={wrapSettingsPage(<AppearancePanel />)} />
|
||||
@@ -346,7 +416,16 @@ const Settings = () => {
|
||||
path="notification-routing"
|
||||
element={<Navigate to="/settings/notifications#routing" replace />}
|
||||
/>
|
||||
<Route path="llm" element={wrapSettingsPage(<AIPanel />)} />
|
||||
<Route path="llm" element={wrapSettingsPage(<AIPanel />, { maxWidthClass: 'max-w-4xl' })} />
|
||||
<Route
|
||||
path="heartbeat"
|
||||
element={wrapSettingsPage(<HeartbeatPanel />, { maxWidthClass: 'max-w-4xl' })}
|
||||
/>
|
||||
<Route
|
||||
path="ledger-usage"
|
||||
element={wrapSettingsPage(<LedgerUsagePanel />, { maxWidthClass: 'max-w-4xl' })}
|
||||
/>
|
||||
<Route path="search" element={wrapSettingsPage(<SearchPanel />)} />
|
||||
<Route path="agent-chat" element={wrapSettingsPage(<AgentChatPanel />)} />
|
||||
<Route path="cron-jobs" element={wrapSettingsPage(<CronJobsPanel />)} />
|
||||
<Route
|
||||
|
||||
+134
-49
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import ChannelSetupModal from '../components/channels/ChannelSetupModal';
|
||||
import McpServersTab from '../components/channels/mcp/McpServersTab';
|
||||
import ComposioConnectModal from '../components/composio/ComposioConnectModal';
|
||||
import {
|
||||
composioToolkitMeta,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
KNOWN_COMPOSIO_TOOLKITS,
|
||||
} from '../components/composio/toolkitMeta';
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import PillTabBar from '../components/PillTabBar';
|
||||
import AutocompleteSetupModal from '../components/skills/AutocompleteSetupModal';
|
||||
import CreateSkillModal from '../components/skills/CreateSkillModal';
|
||||
import InstallSkillDialog from '../components/skills/InstallSkillDialog';
|
||||
@@ -35,8 +37,10 @@ import { useAgentReadyComposioToolkits, useComposioIntegrations } from '../lib/c
|
||||
import { canonicalizeComposioToolkitSlug } from '../lib/composio/toolkitSlug';
|
||||
import { type ComposioConnection, deriveComposioState } from '../lib/composio/types';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { channelConnectionsApi } from '../services/api/channelConnectionsApi';
|
||||
import { skillsApi, type SkillSummary } from '../services/api/skillsApi';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import { setDefaultMessagingChannel } from '../store/channelConnectionsSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import type { ChannelConnectionStatus, ChannelDefinition, ChannelType } from '../types/channels';
|
||||
import type { ToastNotification } from '../types/intelligence';
|
||||
import { IS_DEV } from '../utils/config';
|
||||
@@ -321,10 +325,35 @@ interface SkillItem {
|
||||
|
||||
// ─── Main Skills Page ──────────────────────────────────────────────────────────
|
||||
|
||||
type ConnectionsTab = 'channels' | 'composio' | 'mcp';
|
||||
|
||||
export default function Skills() {
|
||||
const { t } = useT();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<ConnectionsTab>('composio');
|
||||
const dispatch = useAppDispatch();
|
||||
const [defaultChannelBusy, setDefaultChannelBusy] = useState<ChannelType | null>(null);
|
||||
const handleSetDefaultChannel = useCallback(
|
||||
async (channel: ChannelType) => {
|
||||
// Single-flight: ignore re-entries while a write is in progress so two
|
||||
// back-to-back clicks can't interleave (would leave UI + persisted
|
||||
// preference disagreeing on which channel won).
|
||||
if (defaultChannelBusy !== null) return;
|
||||
setDefaultChannelBusy(channel);
|
||||
try {
|
||||
// Persist first, then dispatch — on failure the UI keeps the previous
|
||||
// selection and the user sees no false-positive flicker.
|
||||
await channelConnectionsApi.updatePreferences(channel);
|
||||
dispatch(setDefaultMessagingChannel(channel));
|
||||
} catch (err) {
|
||||
console.warn('[skills] default channel persist failed:', err);
|
||||
} finally {
|
||||
setDefaultChannelBusy(null);
|
||||
}
|
||||
},
|
||||
[dispatch, defaultChannelBusy]
|
||||
);
|
||||
const { definitions: channelDefs } = useChannelDefinitions();
|
||||
const channelConnections = useAppSelector(state => state.channelConnections);
|
||||
|
||||
@@ -864,9 +893,18 @@ export default function Skills() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PillTabBar<ConnectionsTab>
|
||||
selected={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{ value: 'composio', label: t('skills.tabs.composio') },
|
||||
{ value: 'channels', label: t('skills.tabs.channels') },
|
||||
{ value: 'mcp', label: t('skills.tabs.mcp') },
|
||||
]}
|
||||
/>
|
||||
{
|
||||
<>
|
||||
{channelsGroup && (
|
||||
{activeTab === 'channels' && channelsGroup && (
|
||||
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 shadow-soft animate-fade-up">
|
||||
<div className="px-1 pb-3 pt-1">
|
||||
<h2
|
||||
@@ -899,60 +937,107 @@ export default function Skills() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-stone-100 dark:border-neutral-800">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400 mb-2">
|
||||
{t('channels.defaultMessaging')}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{channelDefs.map(def => {
|
||||
const channelId = def.id as ChannelType;
|
||||
const selected = channelConnections.defaultMessagingChannel === channelId;
|
||||
return (
|
||||
<button
|
||||
key={channelId}
|
||||
type="button"
|
||||
onClick={() => void handleSetDefaultChannel(channelId)}
|
||||
disabled={defaultChannelBusy !== null}
|
||||
className={`rounded-lg border px-3 py-2 text-xs font-medium transition-colors ${
|
||||
selected
|
||||
? 'border-primary-500/60 bg-primary-50 dark:bg-primary-500/10 text-primary-600 dark:text-primary-300'
|
||||
: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700'
|
||||
}`}>
|
||||
{def.display_name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* <MeetingBotsCard onToast={addToast} /> */}
|
||||
|
||||
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 shadow-soft animate-fade-up">
|
||||
<div className="px-1 pb-3 pt-1">
|
||||
<h2
|
||||
className="text-sm font-semibold text-stone-900 dark:text-neutral-100"
|
||||
data-walkthrough="skills-grid">
|
||||
{t('skills.integrations')}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-[11px] leading-relaxed text-stone-500 dark:text-neutral-400">
|
||||
{t('skills.available')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3 px-1 pb-3">
|
||||
<SkillSearchBar value={searchQuery} onChange={setSearchQuery} />
|
||||
<SkillCategoryFilter
|
||||
categories={availableCategories}
|
||||
selected={selectedCategory}
|
||||
onChange={setSelectedCategory}
|
||||
/>
|
||||
</div>
|
||||
{composioSortedEntries.length > 0 ? (
|
||||
<div
|
||||
className="grid gap-2 sm:gap-3"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))' }}>
|
||||
{composioSortedEntries.map(({ meta, connection }) => (
|
||||
<div key={meta.slug} data-testid={`skill-row-composio-${meta.slug}`}>
|
||||
<ComposioConnectorTile
|
||||
meta={meta}
|
||||
connection={connection}
|
||||
hasComposioError={Boolean(composioError)}
|
||||
isAgentReady={
|
||||
agentReadyLoading ||
|
||||
Boolean(agentReadyError) ||
|
||||
agentReadyToolkits.has(meta.slug)
|
||||
}
|
||||
testId={`skill-install-composio-${meta.slug}`}
|
||||
onOpen={() => setComposioModalToolkit(meta)}
|
||||
onRetryGlobal={() => void refreshComposio()}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{activeTab === 'composio' && (
|
||||
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 shadow-soft animate-fade-up">
|
||||
<div className="px-1 pb-3 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2
|
||||
className="text-sm font-semibold text-stone-900 dark:text-neutral-100"
|
||||
data-walkthrough="skills-grid">
|
||||
{t('skills.integrations')}
|
||||
</h2>
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300 border border-primary-100 dark:border-primary-800/50">
|
||||
Powered by Composio
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] leading-relaxed text-stone-500 dark:text-neutral-400">
|
||||
{t('skills.integrationsSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-1 py-4 text-center text-xs text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.noResults')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3 px-1 pb-3">
|
||||
<SkillSearchBar value={searchQuery} onChange={setSearchQuery} />
|
||||
<SkillCategoryFilter
|
||||
categories={availableCategories}
|
||||
selected={selectedCategory}
|
||||
onChange={setSelectedCategory}
|
||||
/>
|
||||
</div>
|
||||
{composioSortedEntries.length > 0 ? (
|
||||
<div
|
||||
className="grid gap-2 sm:gap-3"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))' }}>
|
||||
{composioSortedEntries.map(({ meta, connection }) => (
|
||||
<div key={meta.slug} data-testid={`skill-row-composio-${meta.slug}`}>
|
||||
<ComposioConnectorTile
|
||||
meta={meta}
|
||||
connection={connection}
|
||||
hasComposioError={Boolean(composioError)}
|
||||
isAgentReady={
|
||||
agentReadyLoading ||
|
||||
Boolean(agentReadyError) ||
|
||||
agentReadyToolkits.has(meta.slug)
|
||||
}
|
||||
testId={`skill-install-composio-${meta.slug}`}
|
||||
onOpen={() => setComposioModalToolkit(meta)}
|
||||
onRetryGlobal={() => void refreshComposio()}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-1 py-4 text-center text-xs text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.noResults')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{otherGroups.map(group => renderGroup(group))}
|
||||
{activeTab === 'composio' && otherGroups.map(group => renderGroup(group))}
|
||||
|
||||
{activeTab === 'mcp' && (
|
||||
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 shadow-soft animate-fade-up">
|
||||
<div className="pb-3">
|
||||
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('channels.mcp.title')}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-[11px] leading-relaxed text-stone-500 dark:text-neutral-400">
|
||||
{t('channels.mcp.description')}
|
||||
</p>
|
||||
</div>
|
||||
<McpServersTab />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ComposioTriagePanel from '../components/settings/panels/ComposioTriagePanel';
|
||||
import ComposeioTriggerHistory from '../components/webhooks/ComposeioTriggerHistory';
|
||||
import { useComposeioTriggerHistory } from '../hooks/useComposeioTriggerHistory';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
@@ -81,6 +82,12 @@ export default function Webhooks() {
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 p-6">
|
||||
<ComposeioTriggerHistory entries={entries} />
|
||||
</div>
|
||||
|
||||
{/* Triage settings merged in from the former Integration Triggers
|
||||
page so all Composio trigger config lives in one place. */}
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 overflow-hidden">
|
||||
<ComposioTriagePanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -197,6 +197,15 @@ async function renderConversations(preload: Record<string, unknown> = {}) {
|
||||
return store;
|
||||
}
|
||||
|
||||
/** Click the sidebar toggle so the thread list becomes visible.
|
||||
* The sidebar starts hidden (showSidebar=false) in this PR. */
|
||||
async function openSidebar() {
|
||||
const toggleBtn = screen.getByTitle('Show sidebar');
|
||||
await act(async () => {
|
||||
fireEvent.click(toggleBtn);
|
||||
});
|
||||
}
|
||||
|
||||
// Default empty state
|
||||
const emptyThreadState = {
|
||||
threads: [],
|
||||
@@ -301,6 +310,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Sidebar is hidden by default — open it first.
|
||||
await openSidebar();
|
||||
|
||||
// The "Threads" header is always rendered in page mode (sidebar guard removed)
|
||||
expect(screen.getByText('Threads')).toBeInTheDocument();
|
||||
});
|
||||
@@ -311,6 +323,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Sidebar is hidden by default — open it first.
|
||||
await openSidebar();
|
||||
|
||||
expect(screen.getByText('No threads yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -328,6 +343,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Sidebar is hidden by default — open it first.
|
||||
await openSidebar();
|
||||
|
||||
// Wait for loadThreads to complete and the thread list to render.
|
||||
// Use getAllByText because the title may appear in both the sidebar list
|
||||
// and the conversation header (both are rendered).
|
||||
@@ -436,6 +454,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Sidebar is hidden by default — open it first.
|
||||
await openSidebar();
|
||||
|
||||
// The sidebar "New thread" button has title="New thread"
|
||||
const newThreadBtn = screen.getByTitle('New thread');
|
||||
await act(async () => {
|
||||
@@ -478,6 +499,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Sidebar is hidden by default — open it first.
|
||||
await openSidebar();
|
||||
|
||||
// Wait for the thread to appear in the sidebar
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Deletable Thread').length).toBeGreaterThan(0);
|
||||
@@ -1026,6 +1050,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Sidebar is hidden by default — open it first.
|
||||
await openSidebar();
|
||||
|
||||
// All four tabs must be present regardless of thread count.
|
||||
expect(screen.getByRole('tab', { name: 'All' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Work' })).toBeInTheDocument();
|
||||
@@ -1038,6 +1065,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Sidebar is hidden by default — open it first.
|
||||
await openSidebar();
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'All' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tab', { name: 'Work' })).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
@@ -1047,6 +1077,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Sidebar is hidden by default — open it first.
|
||||
await openSidebar();
|
||||
|
||||
expect(screen.getByText('No threads yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1055,6 +1088,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Sidebar is hidden by default — open it first.
|
||||
await openSidebar();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Work' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1071,6 +1107,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Sidebar is hidden by default — open it first.
|
||||
await openSidebar();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Workers' }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import '../../test/mockDefaultSkillStatusHooks';
|
||||
import { renderWithProviders } from '../../test/test-utils';
|
||||
@@ -61,9 +61,16 @@ vi.mock('../../lib/composio/hooks', () => ({
|
||||
}));
|
||||
|
||||
describe('Skills page — Channels grid', () => {
|
||||
beforeEach(() => {
|
||||
// The default tab is 'composio'; click 'Channels' to reveal the Channels card.
|
||||
});
|
||||
|
||||
it('renders configured channels as tiles in a dedicated card and opens the setup modal on click', async () => {
|
||||
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
|
||||
|
||||
// Switch to the Channels tab to make the Channels card visible.
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Channels' }));
|
||||
|
||||
const channelsHeading = screen.getByRole('heading', { name: 'Channels' });
|
||||
expect(channelsHeading).toBeInTheDocument();
|
||||
|
||||
@@ -127,6 +134,8 @@ describe('Skills page — Channels grid', () => {
|
||||
};
|
||||
|
||||
renderWithProviders(<Skills />, { initialEntries: ['/skills'], preloadedState });
|
||||
// Switch to the Channels tab so the Channels card is visible.
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Channels' }));
|
||||
const channelsCard = screen
|
||||
.getByRole('heading', { name: 'Channels' })
|
||||
.closest('.rounded-2xl');
|
||||
@@ -140,7 +149,8 @@ describe('Skills page — Channels grid', () => {
|
||||
it('does not surface a Channels chip in the category filter inside the Integrations card', () => {
|
||||
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
|
||||
|
||||
const integrationsHeading = screen.getByRole('heading', { name: 'Integrations' });
|
||||
// The composio tab is active by default — Composio Integrations card is visible.
|
||||
const integrationsHeading = screen.getByRole('heading', { name: 'Composio Integrations' });
|
||||
const integrationsCard = integrationsHeading.closest('.rounded-2xl');
|
||||
expect(integrationsCard).not.toBeNull();
|
||||
const filterTabs = within(integrationsCard as HTMLElement)
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('Skills page — Composio catalog fallback', () => {
|
||||
it('shows known composio integrations in the integrations icon grid when the live toolkit list is empty', () => {
|
||||
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Integrations' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Composio Integrations' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Discord')).toBeInTheDocument();
|
||||
expect(screen.getByText('Google Calendar')).toBeInTheDocument();
|
||||
expect(screen.getByText('Google Drive')).toBeInTheDocument();
|
||||
@@ -75,7 +75,7 @@ describe('Skills page — Composio catalog fallback', () => {
|
||||
// missing Composio Zoom tile even though the Meeting bots card also
|
||||
// renders a "Zoom" entry on the same page.
|
||||
const integrationsSection = screen
|
||||
.getByRole('heading', { name: 'Integrations' })
|
||||
.getByRole('heading', { name: 'Composio Integrations' })
|
||||
.closest('.rounded-2xl');
|
||||
expect(integrationsSection).not.toBeNull();
|
||||
expect(within(integrationsSection as HTMLElement).getByText('Zoom')).toBeInTheDocument();
|
||||
@@ -91,7 +91,7 @@ describe('Skills page — Composio catalog fallback', () => {
|
||||
expect(screen.getByText('Backend unavailable')).toBeInTheDocument();
|
||||
|
||||
const integrationsSection = screen
|
||||
.getByRole('heading', { name: 'Integrations' })
|
||||
.getByRole('heading', { name: 'Composio Integrations' })
|
||||
.closest('.rounded-2xl');
|
||||
expect(integrationsSection).not.toBeNull();
|
||||
const gmailTile = within(integrationsSection as HTMLElement).getByRole('button', {
|
||||
@@ -113,7 +113,7 @@ describe('Skills page — Composio catalog fallback', () => {
|
||||
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
|
||||
|
||||
const integrationsSection = screen
|
||||
.getByRole('heading', { name: 'Integrations' })
|
||||
.getByRole('heading', { name: 'Composio Integrations' })
|
||||
.closest('.rounded-2xl');
|
||||
expect(integrationsSection).not.toBeNull();
|
||||
const gmailTile = within(integrationsSection as HTMLElement).getByRole('button', {
|
||||
@@ -141,7 +141,7 @@ describe('Skills page — Composio catalog fallback', () => {
|
||||
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
|
||||
|
||||
const integrationsSection = screen
|
||||
.getByRole('heading', { name: 'Integrations' })
|
||||
.getByRole('heading', { name: 'Composio Integrations' })
|
||||
.closest('.rounded-2xl');
|
||||
expect(integrationsSection).not.toBeNull();
|
||||
// No Preview badges anywhere in the integrations grid. The
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user