feat: prod-test 2026-05-23 — composer, settings reorg, MCP/search, alpha banners (#2554)

This commit is contained in:
Steven Enamakel
2026-05-23 19:36:31 -07:00
committed by GitHub
parent cf600a93ff
commit 85e84210db
115 changed files with 3922 additions and 1270 deletions
+6 -1
View File
@@ -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()
);
});
+14 -4
View File
@@ -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"
+436 -412
View File
@@ -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);
});
});
+4 -1
View File
@@ -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', () => {
+18 -8
View File
@@ -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>
);
};
+47 -25
View File
@@ -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 () => {
+133 -15
View File
@@ -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>
+40 -19
View File
@@ -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', () => {
+41 -45
View File
@@ -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>
);
};
+33 -1
View File
@@ -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';
+70
View File
@@ -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 OpenHumans 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;
+1
View File
@@ -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;
+11
View File
@@ -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;
+7
View File
@@ -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;
+70
View File
@@ -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 OpenHumans 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;
+1
View File
@@ -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;
+11
View File
@@ -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;
+7
View File
@@ -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;
+70
View File
@@ -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 OpenHumans 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;
+1
View File
@@ -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;
+11
View File
@@ -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;
+7
View File
@@ -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;
+71 -1
View File
@@ -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 OpenHumans 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',
+1
View File
@@ -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',
+11
View File
@@ -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',
+7
View File
@@ -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',
+70
View File
@@ -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 OpenHumans 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;
+1
View File
@@ -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;
+11
View File
@@ -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;
+7
View File
@@ -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;
+70
View File
@@ -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 OpenHumans 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;
+1
View File
@@ -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;
+11
View File
@@ -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;
+7
View File
@@ -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;
+70
View File
@@ -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 OpenHumans 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;
+1
View File
@@ -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;
+11
View File
@@ -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;
+7
View File
@@ -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;
+70
View File
@@ -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 OpenHumans 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;
+1
View File
@@ -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;
+11
View File
@@ -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;
+7
View File
@@ -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;
+70
View File
@@ -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 OpenHumans 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;
+1
View File
@@ -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;
+11
View File
@@ -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;
+7
View File
@@ -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;
+118
View File
@@ -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 OpenHumans 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;
+44
View File
@@ -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;
+24
View File
@@ -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;
+51
View File
@@ -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 3060 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;
+47
View File
@@ -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;
+70
View File
@@ -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 OpenHumans 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;
+1
View File
@@ -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;
+11
View File
@@ -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;
+7
View File
@@ -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;
+70
View File
@@ -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 OpenHumans 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;
+1
View File
@@ -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;
+11
View File
@@ -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;
+7
View File
@@ -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;
+70
View File
@@ -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 OpenHumans 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;
+1
View File
@@ -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;
+11
View File
@@ -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;
+7
View File
@@ -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
View File
@@ -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 OpenHumans 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',
+41 -11
View File
@@ -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')}
+1 -1
View File
@@ -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">
+86 -7
View File
@@ -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
View File
@@ -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>
+7
View File
@@ -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