diff --git a/app/src/agentworld/theme/AgentWorldThemeBridge.tsx b/app/src/agentworld/theme/AgentWorldThemeBridge.tsx deleted file mode 100644 index 147bf149c..000000000 --- a/app/src/agentworld/theme/AgentWorldThemeBridge.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/** - * AgentWorldThemeBridge — maps OpenHuman design tokens to tiny.place CSS variables. - * - * Injects a `; -} diff --git a/app/src/assets/icons/GoogleIcon.tsx b/app/src/assets/icons/GoogleIcon.tsx deleted file mode 100644 index 04625d371..000000000 --- a/app/src/assets/icons/GoogleIcon.tsx +++ /dev/null @@ -1,24 +0,0 @@ -const GoogleIcon = ({ className = 'w-5 h-5' }: { className?: string }) => { - return ( - - - - - - - ); -}; - -export default GoogleIcon; diff --git a/app/src/components/ConnectionBadge.tsx b/app/src/components/ConnectionBadge.tsx deleted file mode 100644 index 119eb66bd..000000000 --- a/app/src/components/ConnectionBadge.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/** - * ConnectionBadge — small pill badge rendered on connection cards. - * - * Two kinds: - * - "Messaging" — shown for iMessage, Telegram, WhatsApp channel cards - * - "Composio" — shown for cards backed by the Composio toolkit (kind === 'composio') - */ -import { useT } from '../lib/i18n/I18nContext'; - -const MESSAGING_IDS = ['imessage', 'telegram', 'whatsapp'] as const; -type MessagingId = (typeof MESSAGING_IDS)[number]; - -export function isMessagingId(id: string): id is MessagingId { - return (MESSAGING_IDS as readonly string[]).includes(id); -} - -interface ConnectionBadgeProps { - /** 'composio' | 'messaging' */ - kind: 'composio' | 'messaging'; -} - -export default function ConnectionBadge({ kind }: ConnectionBadgeProps) { - const { t } = useT(); - if (kind === 'composio') { - return ( - - {t('app.connectionBadge.composio')} - - ); - } - return ( - - {t('app.connectionBadge.messaging')} - - ); -} diff --git a/app/src/components/LottieAnimation.tsx b/app/src/components/LottieAnimation.tsx deleted file mode 100644 index af4dbc247..000000000 --- a/app/src/components/LottieAnimation.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useLottie } from 'lottie-react'; -import { useEffect, useState } from 'react'; - -interface LottieAnimationProps { - src: string; - className?: string; - height?: number; - width?: number; -} - -const LottieAnimation = ({ - src, - className = '', - height = 200, - width = 200, -}: LottieAnimationProps) => { - const [animationData, setAnimationData] = useState(null); - - useEffect(() => { - fetch(src) - .then(response => response.json()) - .then(data => setAnimationData(data)) - .catch(error => console.error('Failed to load Lottie animation:', error)); - }, [src]); - - const options = { animationData, loop: true, autoplay: true }; - - const { View } = useLottie(options, { height, width }); - - if (!animationData) { - return
; - } - - return
{View}
; -}; - -export default LottieAnimation; diff --git a/app/src/components/accounts/RespondQueuePanel.tsx b/app/src/components/accounts/RespondQueuePanel.tsx deleted file mode 100644 index 42f8294b0..000000000 --- a/app/src/components/accounts/RespondQueuePanel.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useT } from '../../lib/i18n/I18nContext'; -import type { RespondQueueItem } from '../../types/providerSurfaces'; -import { openUrl } from '../../utils/openUrl'; -import Button from '../ui/Button'; - -interface RespondQueuePanelProps { - items: RespondQueueItem[]; - count: number; - status: 'idle' | 'loading' | 'succeeded' | 'failed'; - error: string | null; - onRefresh: () => void; -} - -function relativeTime(iso: string): string { - const ts = new Date(iso).getTime(); - if (!Number.isFinite(ts)) return 'unknown time'; - const deltaMs = Date.now() - ts; - if (deltaMs < 60_000) return 'just now'; - const mins = Math.floor(deltaMs / 60_000); - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - return `${days}d ago`; -} - -function queueTitle(item: RespondQueueItem): string { - return item.title || item.senderName || item.eventKind || item.provider; -} - -export default function RespondQueuePanel({ - items, - count, - status, - error, - onRefresh, -}: RespondQueuePanelProps) { - const { t } = useT(); - return ( - - ); -} diff --git a/app/src/components/chat/CycleUsagePill.tsx b/app/src/components/chat/CycleUsagePill.tsx deleted file mode 100644 index 9ad28d644..000000000 --- a/app/src/components/chat/CycleUsagePill.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { LimitPill } from '../../features/conversations/components/LimitPill'; -import { formatResetTime } from '../../features/conversations/utils/format'; -import { useUsageState } from '../../hooks/useUsageState'; -import { useT } from '../../lib/i18n/I18nContext'; - -/** - * Self-contained cycle usage indicator for the composer toolbar. - * Shows "CYCLE ——— 0%" pill with a hover tooltip showing spend/remaining. - * Uses useUsageState() internally — no props needed. - */ -export default function CycleUsagePill() { - const { t } = useT(); - const { usagePct, teamUsage, isLoading } = useUsageState(); - - if (!isLoading && !teamUsage) return null; - - return ( -
- {teamUsage ? ( - - ) : ( - {t('common.loading')} - )} - {teamUsage && ( -
-
-
- {t('chat.cycleSpent')} - - ${(teamUsage.cycleSpentUsd ?? 0).toFixed(2)} / $ - {(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)} - -
-
- {t('chat.cycleRemaining')} - - ${(teamUsage.remainingUsd ?? 0).toFixed(2)} {t('chat.left')} - {teamUsage.cycleEndsAt && ( - - — {t('chat.resets')} {formatResetTime(teamUsage.cycleEndsAt)} - - )} - -
-
-
- )} -
- ); -} diff --git a/app/src/components/chat/TokenUsagePill.tsx b/app/src/components/chat/TokenUsagePill.tsx deleted file mode 100644 index d6c8842d2..000000000 --- a/app/src/components/chat/TokenUsagePill.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { useUsageState } from '../../hooks/useUsageState'; -import { useT } from '../../lib/i18n/I18nContext'; -import { useAppSelector } from '../../store/hooks'; -import { BILLING_DASHBOARD_URL } from '../../utils/links'; -import { openUrl } from '../../utils/openUrl'; - -function formatTokens(n: number): string { - if (n < 1000) return String(n); - if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}K`; - return `${(n / 1_000_000).toFixed(1)}M`; -} - -interface PillSeverity { - bg: string; - text: string; - ring: string; - label: string; -} - -function severityFromPct(pct: number): PillSeverity { - if (pct >= 0.9) { - return { - bg: 'bg-coral-50 dark:bg-coral-500/15', - text: 'text-coral-700 dark:text-coral-300', - ring: 'ring-coral-200 dark:ring-coral-500/30', - label: `${Math.round(pct * 100)}%`, - }; - } - if (pct >= 0.7) { - return { - bg: 'bg-amber-50 dark:bg-amber-500/15', - text: 'text-amber-700 dark:text-amber-300', - ring: 'ring-amber-200 dark:ring-amber-500/30', - label: `${Math.round(pct * 100)}%`, - }; - } - return { - bg: 'bg-sage-50 dark:bg-sage-500/15', - text: 'text-sage-700 dark:text-sage-300', - ring: 'ring-sage-200 dark:ring-sage-500/30', - label: `${Math.round(pct * 100)}%`, - }; -} - -const TokenUsagePill = () => { - const { t } = useT(); - const sessionTokens = useAppSelector(state => state.chatRuntime.sessionTokenUsage); - const { usagePct, isAtLimit, isNearLimit, currentTier, teamUsage } = useUsageState(); - - const totalTokens = sessionTokens.inputTokens + sessionTokens.outputTokens; - const showSessionCounter = totalTokens > 0; - - const planSeverity = severityFromPct(usagePct); - const showPlanPill = teamUsage !== null; - - const planTitle = (() => { - if (isAtLimit) return t('token.usageLimitReached'); - if (isNearLimit) return t('token.approachingLimit'); - return `${currentTier.toLowerCase()} ${t('token.planClickForDetails')}`; - })(); - - if (!showSessionCounter && !showPlanPill) return null; - - return ( -
- {showSessionCounter ? ( - - - - - {formatTokens(totalTokens)} - - ) : null} - {showPlanPill ? ( - - ) : null} -
- ); -}; - -export default TokenUsagePill; diff --git a/app/src/components/chat/__tests__/ChatComposer.test.tsx b/app/src/components/chat/__tests__/ChatComposer.test.tsx index c8084e3fd..c1ec075d6 100644 --- a/app/src/components/chat/__tests__/ChatComposer.test.tsx +++ b/app/src/components/chat/__tests__/ChatComposer.test.tsx @@ -6,7 +6,6 @@ import type { Attachment } from '../../../lib/attachments'; import ChatComposer, { type ChatComposerProps } from '../ChatComposer'; vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); -vi.mock('../CycleUsagePill', () => ({ default: () =>
})); function makeAttachment(overrides: Partial = {}): Attachment { const blob = new Blob([new Uint8Array(256)], { type: 'image/png' }); diff --git a/app/src/components/oauth/OAuthLoginSection.tsx b/app/src/components/oauth/OAuthLoginSection.tsx deleted file mode 100644 index fa9695f94..000000000 --- a/app/src/components/oauth/OAuthLoginSection.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useT } from '../../lib/i18n/I18nContext'; -import OAuthProviderButton from './OAuthProviderButton'; -import { oauthProviderConfigs } from './providerConfigs'; - -interface OAuthLoginSectionProps { - className?: string; - disabled?: boolean; - showTelegram?: boolean; -} - -const OAuthLoginSection = ({ className = '', disabled = false }: OAuthLoginSectionProps) => { - const { t } = useT(); - return ( -
-
-

- {t('oauth.login.continueWith')} -

-
- {oauthProviderConfigs.map(provider => ( - - ))} -
-
-
- ); -}; - -export default OAuthLoginSection; diff --git a/app/src/components/settings/panels/billing/BillingHistoryTab.tsx b/app/src/components/settings/panels/billing/BillingHistoryTab.tsx deleted file mode 100644 index 3e57c5cd9..000000000 --- a/app/src/components/settings/panels/billing/BillingHistoryTab.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useT } from '../../../../lib/i18n/I18nContext'; -import type { CreditTransaction } from '../../../../services/api/creditsApi'; -import Button from '../../../ui/Button'; - -interface BillingHistoryTabProps { - hasActive: boolean; - onManageSubscription: () => void; - transactionRows: CreditTransaction[]; -} - -export default function BillingHistoryTab({ - hasActive, - onManageSubscription, - transactionRows, -}: BillingHistoryTabProps) { - const { t } = useT(); - return ( -
-
-

- {t('settings.billing.history.title')} -

-

{t('settings.billing.history.desc')}

-
- {hasActive && ( - - )} -
-
-
- {transactionRows.length > 0 ? ( -
- {transactionRows.map(transaction => { - const isEarn = transaction.type === 'EARN'; - return ( -
-
-

- {transaction.action} -

-

- {new Date(transaction.createdAt).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - })} -

-
-
{transaction.type}
-
- {isEarn ? '+' : '-'}${Math.abs(transaction.amountUsd).toFixed(2)} -
-
- - {t('settings.billing.history.posted')} - -
-
- ); - })} -
- ) : ( -
- {t('settings.billing.history.empty')} -
- )} -
-
- ); -} diff --git a/app/src/components/settings/panels/billing/BillingPaymentsTab.tsx b/app/src/components/settings/panels/billing/BillingPaymentsTab.tsx deleted file mode 100644 index b21229c7f..000000000 --- a/app/src/components/settings/panels/billing/BillingPaymentsTab.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import type { - AutoRechargeSettings, - CreditBalance, - SavedCard, -} from '../../../../services/api/creditsApi'; -import AutoRechargeSection from './AutoRechargeSection'; -import PayAsYouGoCard from './PayAsYouGoCard'; - -interface BillingPaymentsTabProps { - arAmount: number; - arDirty: boolean; - arError: string | null; - arLoading: boolean; - arSaving: boolean; - arSettings: AutoRechargeSettings | null; - arThreshold: number; - arWeeklyLimit: number; - cards: SavedCard[]; - cardsLoading: boolean; - confirmDeleteId: string | null; - creditBalance: CreditBalance | null; - deletingCardId: string | null; - isLoadingCredits: boolean; - isToppingUp: boolean; - onAddCard: () => void; - onArSave: () => void; - onArToggle: () => void; - onDeleteCard: (paymentMethodId: string) => void; - onSetDefault: (paymentMethodId: string) => void; - onTopUp: (amountUsd: number) => void; - setArAmount: (value: number) => void; - setArThreshold: (value: number) => void; - setArWeeklyLimit: (value: number) => void; - setConfirmDeleteId: (value: string | null) => void; - settingDefaultId: string | null; -} - -export default function BillingPaymentsTab({ - arAmount, - arDirty, - arError, - arLoading, - arSaving, - arSettings, - arThreshold, - arWeeklyLimit, - cards, - cardsLoading, - confirmDeleteId, - creditBalance, - deletingCardId, - isLoadingCredits, - isToppingUp, - onAddCard, - onArSave, - onArToggle, - onDeleteCard, - onSetDefault, - onTopUp, - setArAmount, - setArThreshold, - setArWeeklyLimit, - setConfirmDeleteId, - settingDefaultId, -}: BillingPaymentsTabProps) { - return ( - <> -
- -
- - - - ); -} diff --git a/app/src/components/settings/panels/billing/BillingPlansTab.tsx b/app/src/components/settings/panels/billing/BillingPlansTab.tsx deleted file mode 100644 index e6a3f1f3a..000000000 --- a/app/src/components/settings/panels/billing/BillingPlansTab.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { PlanTier } from '../../../../types/api'; -import SubscriptionPlans from './SubscriptionPlans'; - -interface BillingPlansTabProps { - billingInterval: 'monthly' | 'annual'; - currentTier: PlanTier; - isPurchasing: boolean; - onUpgrade: (tier: PlanTier) => void; - paymentConfirmed: boolean; - paymentMethod: 'card' | 'crypto'; - purchasingTier: PlanTier | null; - setBillingInterval: (value: 'monthly' | 'annual') => void; - setPaymentMethod: (value: 'card' | 'crypto') => void; -} - -export default function BillingPlansTab({ - billingInterval, - currentTier, - isPurchasing, - onUpgrade, - paymentConfirmed, - paymentMethod, - purchasingTier, - setBillingInterval, - setPaymentMethod, -}: BillingPlansTabProps) { - return ( - - ); -} diff --git a/app/src/components/skills/SkillResourceTree.tsx b/app/src/components/skills/SkillResourceTree.tsx deleted file mode 100644 index a126ab3ba..000000000 --- a/app/src/components/skills/SkillResourceTree.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/** - * SkillResourceTree - * ----------------- - * - * Groups a flat list of skill resource paths by their top-level directory - * (`scripts/`, `references/`, `assets/`) with a catch-all "Other" bucket so - * anything unexpected still renders. Items are rendered as clickable rows in - * JetBrains Mono for path clarity. Selected item uses primary-50 background. - */ -import debug from 'debug'; -import { useMemo } from 'react'; - -import { useT } from '../../lib/i18n/I18nContext'; - -const log = debug('skills:resource-tree'); - -interface Props { - resources: string[]; - selectedPath: string | null; - onSelect: (path: string) => void; -} - -interface ResourceGroup { - key: string; - items: string[]; -} - -const KNOWN_GROUPS: Array<{ prefix: string; key: string }> = [ - { prefix: 'scripts/', key: 'scripts' }, - { prefix: 'references/', key: 'references' }, - { prefix: 'assets/', key: 'assets' }, - { prefix: 'templates/', key: 'templates' }, - { prefix: 'examples/', key: 'examples' }, - { prefix: 'prompts/', key: 'prompts' }, -]; - -function groupResources(resources: string[]): ResourceGroup[] { - const buckets = new Map(); - for (const known of KNOWN_GROUPS) { - buckets.set(known.key, { key: known.key, items: [] }); - } - const other: ResourceGroup = { key: 'other', items: [] }; - - for (const resource of resources) { - let matched = false; - for (const known of KNOWN_GROUPS) { - if (resource.startsWith(known.prefix)) { - buckets.get(known.key)!.items.push(resource); - matched = true; - break; - } - } - if (!matched) { - other.items.push(resource); - } - } - - for (const bucket of buckets.values()) { - bucket.items.sort((a, b) => a.localeCompare(b)); - } - other.items.sort((a, b) => a.localeCompare(b)); - - const result: ResourceGroup[] = []; - for (const known of KNOWN_GROUPS) { - const bucket = buckets.get(known.key)!; - if (bucket.items.length > 0) { - result.push(bucket); - } - } - if (other.items.length > 0) { - result.push(other); - } - return result; -} - -const GROUP_LABEL_KEYS: Record = { - scripts: 'skills.resource.tree.scripts', - references: 'skills.resource.tree.references', - assets: 'skills.resource.tree.assets', - templates: 'skills.resource.tree.templates', - examples: 'skills.resource.tree.examples', - prompts: 'skills.resource.tree.prompts', - other: 'skills.resource.tree.other', -}; - -export default function SkillResourceTree({ resources, selectedPath, onSelect }: Props) { - const { t } = useT(); - const groups = useMemo(() => groupResources(resources), [resources]); - - if (groups.length === 0) { - return ( -

- {t('skills.resource.tree.empty')} -

- ); - } - - return ( -
- {groups.map(group => ( -
-
-

- {t(GROUP_LABEL_KEYS[group.key] ?? group.key)} -

- - {group.items.length} - -
-
    - {group.items.map(path => { - const isSelected = selectedPath === path; - return ( -
  • - -
  • - ); - })} -
-
- ))} -
- ); -} diff --git a/app/src/hooks/useBackendUrl.test.ts b/app/src/hooks/useBackendUrl.test.ts deleted file mode 100644 index 89838a04a..000000000 --- a/app/src/hooks/useBackendUrl.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mockGetBackendUrl = vi.fn(); - -vi.mock('../services/backendUrl', () => ({ getBackendUrl: () => mockGetBackendUrl() })); - -describe('useBackendUrl', () => { - beforeEach(() => { - vi.resetModules(); - mockGetBackendUrl.mockReset(); - }); - - it('returns the resolved core-derived backend URL after the async lookup settles', async () => { - mockGetBackendUrl.mockResolvedValue('https://api.example.com'); - - const { useBackendUrl } = await import('./useBackendUrl'); - const { result } = renderHook(() => useBackendUrl()); - - expect(result.current).toBeNull(); - await waitFor(() => expect(result.current).toBe('https://api.example.com')); - }); - - it('stays null when resolution fails so callers do not fall back to a hardcoded host', async () => { - mockGetBackendUrl.mockRejectedValue(new Error('rpc unreachable')); - - const { useBackendUrl } = await import('./useBackendUrl'); - const { result } = renderHook(() => useBackendUrl()); - - await waitFor(() => expect(mockGetBackendUrl).toHaveBeenCalled()); - expect(result.current).toBeNull(); - }); - - it('does not update state if the component unmounts before resolution', async () => { - let resolveFn: ((value: string) => void) | undefined; - mockGetBackendUrl.mockImplementation( - () => - new Promise(resolve => { - resolveFn = resolve; - }) - ); - - const { useBackendUrl } = await import('./useBackendUrl'); - const { result, unmount } = renderHook(() => useBackendUrl()); - - unmount(); - resolveFn?.('https://api.example.com'); - await Promise.resolve(); - expect(result.current).toBeNull(); - }); -}); diff --git a/app/src/hooks/useBackendUrl.ts b/app/src/hooks/useBackendUrl.ts deleted file mode 100644 index 59745e57f..000000000 --- a/app/src/hooks/useBackendUrl.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { getBackendUrl } from '../services/backendUrl'; - -/** - * Resolves the runtime backend URL from the core sidecar (or web fallback) - * for use inside React components. Returns `null` while the resolution is in - * flight or if it fails. Components should treat `null` as "URL not yet - * known" and render a placeholder rather than guessing a hardcoded host. - * - * The resolution is delegated to `services/backendUrl#getBackendUrl`, which - * caches the value for the session — using this hook in many components is - * cheap (one RPC for the whole app). - */ -export function useBackendUrl(): string | null { - const [url, setUrl] = useState(null); - - useEffect(() => { - let cancelled = false; - getBackendUrl() - .then(resolved => { - if (!cancelled) setUrl(resolved); - }) - .catch(() => { - if (!cancelled) setUrl(null); - }); - return () => { - cancelled = true; - }; - }, []); - - return url; -} diff --git a/app/src/hooks/useThreadQueries.test.ts b/app/src/hooks/useThreadQueries.test.ts deleted file mode 100644 index 2e088dda7..000000000 --- a/app/src/hooks/useThreadQueries.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { Thread, ThreadMessage } from '../types/thread'; - -const mockGetThreads = vi.fn(); -const mockGetThreadMessages = vi.fn(); - -vi.mock('../services/api/threadApi', () => ({ - threadApi: { - getThreads: () => mockGetThreads(), - getThreadMessages: (threadId: string) => mockGetThreadMessages(threadId), - }, -})); - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -const thread: Thread = { - id: 'thread-1', - title: 'Planning', - chatId: null, - isActive: true, - messageCount: 1, - lastMessageAt: '2026-05-06T00:00:00.000Z', - createdAt: '2026-05-06T00:00:00.000Z', - labels: [], -}; - -const message: ThreadMessage = { - id: 'message-1', - content: 'hello', - type: 'text', - extraMetadata: {}, - sender: 'user', - createdAt: '2026-05-06T00:00:00.000Z', -}; - -describe('useThreadQueries', () => { - beforeEach(() => { - mockGetThreads.mockReset(); - mockGetThreadMessages.mockReset(); - }); - - it('loads threads with loading and success state', async () => { - mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 }); - const { useThreads } = await import('./useThreadQueries'); - - const { result } = renderHook(() => useThreads()); - - expect(result.current.loading).toBe(true); - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.data?.threads).toEqual([thread]); - expect(result.current.data?.count).toBe(1); - expect(result.current.error).toBeNull(); - expect(mockGetThreads).toHaveBeenCalledTimes(1); - }); - - it('surfaces RPC errors without throwing from render', async () => { - mockGetThreads.mockRejectedValue(new Error('rpc failed')); - const { useThreads } = await import('./useThreadQueries'); - - const { result } = renderHook(() => useThreads()); - - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.data).toBeNull(); - expect(result.current.error?.message).toBe('rpc failed'); - }); - - it('does not load messages when no thread id is available', async () => { - const { useThreadMessages } = await import('./useThreadQueries'); - - const { result, rerender } = renderHook(({ threadId }) => useThreadMessages(threadId), { - initialProps: { threadId: null as string | null }, - }); - - expect(result.current.loading).toBe(false); - expect(result.current.data).toBeNull(); - expect(result.current.error).toBeNull(); - expect(mockGetThreadMessages).not.toHaveBeenCalled(); - - rerender({ threadId: ' ' }); - expect(mockGetThreadMessages).not.toHaveBeenCalled(); - }); - - it('loads and refetches thread messages', async () => { - mockGetThreadMessages - .mockResolvedValueOnce({ messages: [message], count: 1 }) - .mockResolvedValueOnce({ - messages: [{ ...message, id: 'message-2', content: 'updated' }], - count: 1, - }); - const { useThreadMessages } = await import('./useThreadQueries'); - - const { result } = renderHook(() => useThreadMessages('thread-1')); - - await waitFor(() => expect(result.current.data?.messages[0].id).toBe('message-1')); - await act(async () => { - await result.current.refetch(); - }); - - expect(result.current.data?.messages[0].id).toBe('message-2'); - expect(mockGetThreadMessages).toHaveBeenNthCalledWith(1, 'thread-1'); - expect(mockGetThreadMessages).toHaveBeenNthCalledWith(2, 'thread-1'); - }); - - it('exposes refetching state while keeping previous data', async () => { - const nextMessages = deferred<{ messages: ThreadMessage[]; count: number }>(); - mockGetThreadMessages - .mockResolvedValueOnce({ messages: [message], count: 1 }) - .mockReturnValueOnce(nextMessages.promise); - const { useThreadMessages } = await import('./useThreadQueries'); - - const { result } = renderHook(() => useThreadMessages('thread-1')); - - await waitFor(() => expect(result.current.data?.messages[0].id).toBe('message-1')); - - let refetchPromise: Promise; - act(() => { - refetchPromise = result.current.refetch(); - }); - - expect(result.current.isRefetching).toBe(true); - expect(result.current.data?.messages[0].id).toBe('message-1'); - - nextMessages.resolve({ messages: [{ ...message, id: 'message-2' }], count: 1 }); - await act(async () => { - await refetchPromise; - }); - - expect(result.current.isRefetching).toBe(false); - expect(result.current.data?.messages[0].id).toBe('message-2'); - }); - - it('clears previous messages while loading a different thread id', async () => { - const nextMessages = deferred<{ messages: ThreadMessage[]; count: number }>(); - mockGetThreadMessages - .mockResolvedValueOnce({ messages: [message], count: 1 }) - .mockReturnValueOnce(nextMessages.promise); - const { useThreadMessages } = await import('./useThreadQueries'); - - const { result, rerender } = renderHook(({ threadId }) => useThreadMessages(threadId), { - initialProps: { threadId: 'thread-1' }, - }); - - await waitFor(() => expect(result.current.data?.messages[0].id).toBe('message-1')); - - rerender({ threadId: 'thread-2' }); - - await waitFor(() => expect(result.current.data).toBeNull()); - expect(result.current.loading).toBe(true); - expect(result.current.isRefetching).toBe(false); - - nextMessages.resolve({ messages: [{ ...message, id: 'message-2' }], count: 1 }); - await waitFor(() => expect(result.current.data?.messages[0].id).toBe('message-2')); - expect(mockGetThreadMessages).toHaveBeenNthCalledWith(1, 'thread-1'); - expect(mockGetThreadMessages).toHaveBeenNthCalledWith(2, 'thread-2'); - }); - - it('ignores stale message responses after switching thread ids', async () => { - const firstMessages = deferred<{ messages: ThreadMessage[]; count: number }>(); - const secondMessages = deferred<{ messages: ThreadMessage[]; count: number }>(); - mockGetThreadMessages - .mockReturnValueOnce(firstMessages.promise) - .mockReturnValueOnce(secondMessages.promise); - const { useThreadMessages } = await import('./useThreadQueries'); - - const { result, rerender } = renderHook(({ threadId }) => useThreadMessages(threadId), { - initialProps: { threadId: 'thread-1' }, - }); - - rerender({ threadId: 'thread-2' }); - - secondMessages.resolve({ messages: [{ ...message, id: 'message-2' }], count: 1 }); - await waitFor(() => expect(result.current.data?.messages[0].id).toBe('message-2')); - - firstMessages.resolve({ messages: [{ ...message, id: 'message-1' }], count: 1 }); - await act(async () => { - await firstMessages.promise; - }); - - expect(result.current.data?.messages[0].id).toBe('message-2'); - }); -}); diff --git a/app/src/hooks/useThreadQueries.ts b/app/src/hooks/useThreadQueries.ts deleted file mode 100644 index e05b64ee6..000000000 --- a/app/src/hooks/useThreadQueries.ts +++ /dev/null @@ -1,134 +0,0 @@ -import debug from 'debug'; -import { useCallback, useEffect, useRef, useState } from 'react'; - -import { threadApi } from '../services/api/threadApi'; -import type { ThreadMessagesData, ThreadsListData } from '../types/thread'; - -const log = debug('hooks:threadQueries'); - -export interface ThreadQueryState { - data: T | null; - loading: boolean; - error: Error | null; - isRefetching: boolean; - refetch: () => Promise; -} - -function normalizeError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -function useThreadQuery( - queryName: string, - load: () => Promise, - enabled = true, - queryKey = queryName -): ThreadQueryState { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(enabled); - const [error, setError] = useState(null); - const [isRefetching, setIsRefetching] = useState(false); - const requestIdRef = useRef(0); - const dataRef = useRef(null); - const queryKeyRef = useRef(queryKey); - - const execute = useCallback( - async (reason: 'initial' | 'refetch'): Promise => { - if (!enabled) { - log('%s skip disabled reason=%s', queryName, reason); - return undefined; - } - - const requestId = requestIdRef.current + 1; - requestIdRef.current = requestId; - const hasData = dataRef.current !== null; - log('%s start requestId=%d reason=%s hasData=%s', queryName, requestId, reason, hasData); - - setError(null); - if (hasData || reason === 'refetch') { - setIsRefetching(true); - } else { - setLoading(true); - } - - try { - const nextData = await load(); - if (requestIdRef.current !== requestId) { - log('%s ignore stale success requestId=%d', queryName, requestId); - return nextData; - } - dataRef.current = nextData; - setData(nextData); - log('%s success requestId=%d', queryName, requestId); - return nextData; - } catch (caught) { - const nextError = normalizeError(caught); - if (requestIdRef.current !== requestId) { - log('%s ignore stale error requestId=%d error=%o', queryName, requestId, nextError); - return undefined; - } - setError(nextError); - log('%s error requestId=%d error=%o', queryName, requestId, nextError); - return undefined; - } finally { - if (requestIdRef.current === requestId) { - setLoading(false); - setIsRefetching(false); - } - } - }, - [enabled, load, queryName] - ); - - useEffect(() => { - if (queryKeyRef.current !== queryKey) { - requestIdRef.current += 1; - queryKeyRef.current = queryKey; - dataRef.current = null; - setData(null); - setError(null); - setIsRefetching(false); - } - - if (!enabled) { - requestIdRef.current += 1; - dataRef.current = null; - setData(null); - setError(null); - setLoading(false); - setIsRefetching(false); - return; - } - void execute('initial'); - }, [enabled, execute, queryKey]); - - useEffect( - () => () => { - requestIdRef.current += 1; - }, - [] - ); - - const refetch = useCallback(() => execute('refetch'), [execute]); - - return { data, loading, error, isRefetching, refetch }; -} - -export function useThreads(): ThreadQueryState { - const load = useCallback(() => threadApi.getThreads(), []); - return useThreadQuery('threads.list', load); -} - -export function useThreadMessages(threadId?: string | null): ThreadQueryState { - const normalizedThreadId = threadId?.trim() || null; - const load = useCallback( - () => threadApi.getThreadMessages(normalizedThreadId ?? ''), - [normalizedThreadId] - ); - return useThreadQuery( - 'threads.messages', - load, - normalizedThreadId !== null, - `threads.messages:${normalizedThreadId ?? 'disabled'}` - ); -} diff --git a/app/src/lib/ai/skillsAgentContext.ts b/app/src/lib/ai/skillsAgentContext.ts deleted file mode 100644 index 01c0d0d15..000000000 --- a/app/src/lib/ai/skillsAgentContext.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Source of truth: src/openhuman/agent/agents/integrations_agent/prompt.md -// Keep in sync when the Rust-side prompt changes. -export const SKILLS_AGENT_PROMPT = `# Skills Agent — Service Integration Specialist - -You are the **Skills Agent**. You interact with connected external services primarily through **Composio** (a managed OAuth gateway for 1000+ apps like Gmail, Notion, GitHub, Slack). - -## Available tool surfaces - -1. **Composio tools** — a small meta-surface that discovers and executes Composio actions on the user's behalf: - - \`composio_list_toolkits\` — what integrations the backend allows (e.g. \`gmail\`, \`notion\`). - - \`composio_list_connections\` — which of those the user has already authorised. - - \`composio_authorize\` — start an OAuth handoff for a toolkit; returns a \`connectUrl\`. - - \`composio_list_tools\` — list available action schemas (optionally filtered by toolkit). Use the returned \`function.name\` slug as the \`tool\` argument to \`composio_execute\`. - - \`composio_execute\` — run a Composio action with \`{ tool, arguments }\` (e.g. \`tool = "GMAIL_SEND_EMAIL"\`). -## Typical Composio flow - -1. Call \`composio_list_connections\` to see what the user already has connected. -2. If the required toolkit is missing, call \`composio_authorize\` and return the \`connectUrl\` so the user can complete OAuth. -3. Once connected, call \`composio_list_tools\` (optionally scoped to one or two toolkits) to discover the action slug and its JSON schema. -4. Call \`composio_execute\` with the slug and argument object. - -## Rules - -- **Never fabricate action slugs.** Always pull them from \`composio_list_tools\` before calling \`composio_execute\`. -- **Respect rate limits** — Composio and upstream providers both throttle. Back off on errors rather than retrying tightly. -- **Handle OAuth expiry** — if an action fails with an auth error, surface the need to re-authorise rather than looping. -- **Use memory context** — consult the injected memory context for details about the user's integrations and preferences. -- **Be precise** — every tool expects a specific argument shape. Validate against the schema from \`composio_list_tools\` before calling. -- **Report results** — state what action was taken and the outcome, including any cost reported by Composio.`; diff --git a/app/src/pages/onboarding/Onboarding.tsx b/app/src/pages/onboarding/Onboarding.tsx index 4c15d7feb..5ac8e3652 100644 --- a/app/src/pages/onboarding/Onboarding.tsx +++ b/app/src/pages/onboarding/Onboarding.tsx @@ -40,7 +40,6 @@ const Onboarding = () => { } /> } /> } /> - {/* } /> */} } /> diff --git a/app/src/pages/onboarding/components/ConfigureLaterCallout.tsx b/app/src/pages/onboarding/components/ConfigureLaterCallout.tsx deleted file mode 100644 index e613447ae..000000000 --- a/app/src/pages/onboarding/components/ConfigureLaterCallout.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useT } from '../../../lib/i18n/I18nContext'; - -interface ConfigureLaterCalloutProps { - /** Settings-route the user will be deep-linked to after onboarding ends. */ - settingsHref: string; -} - -/** - * Placeholder rendered inside a CustomWizardStep when the user picks - * "Configure" but we don't have inline controls for that domain yet. - * Records the user's intent; final wiring lives in the matching Settings - * panel, surfaced via `settingsHref` once onboarding completes. - */ -const ConfigureLaterCallout = ({ settingsHref }: ConfigureLaterCalloutProps) => { - const { t } = useT(); - return ( -
-

- {t('onboarding.custom.configureLater')} -

-

- {t('onboarding.custom.openSettings')}:{' '} - {settingsHref} -

-
- ); -}; - -export default ConfigureLaterCallout; diff --git a/app/src/pages/onboarding/pages/ApiKeysPage.tsx b/app/src/pages/onboarding/pages/ApiKeysPage.tsx deleted file mode 100644 index ec9fb05e3..000000000 --- a/app/src/pages/onboarding/pages/ApiKeysPage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { trackEvent } from '../../../services/analytics'; -import { useOnboardingContext } from '../OnboardingContext'; -import ApiKeysStep from '../steps/ApiKeysStep'; - -const ApiKeysPage = () => { - const { completeAndExit } = useOnboardingContext(); - - const finish = async (skipped: boolean) => { - trackEvent('onboarding_step_complete', { step_name: 'api_keys', skipped }); - try { - await completeAndExit(); - } catch (err) { - console.error('[onboarding:api-keys-page] completeAndExit failed', err); - } - }; - - return void finish(false)} onSkip={() => void finish(true)} />; -}; - -export default ApiKeysPage; diff --git a/app/src/pages/onboarding/pages/ChatProviderPage.tsx b/app/src/pages/onboarding/pages/ChatProviderPage.tsx deleted file mode 100644 index a0ef97ab8..000000000 --- a/app/src/pages/onboarding/pages/ChatProviderPage.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useState } from 'react'; - -import { useT } from '../../../lib/i18n/I18nContext'; -import OnboardingNextButton from '../components/OnboardingNextButton'; -import { useOnboardingContext } from '../OnboardingContext'; - -/** - * Final onboarding step: pick a single chat provider. - * - * TODO: replace this stub with the real provider picker (WhatsApp / - * Telegram / Slack / iMessage / …). For now it just lets the user - * complete onboarding with no provider selected so the routed-pages - * scaffolding can ship on its own. - */ -const ChatProviderPage = () => { - const { t } = useT(); - const { completeAndExit } = useOnboardingContext(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const handleFinish = async () => { - setError(null); - setLoading(true); - try { - await completeAndExit(); - } catch (e) { - setError(e instanceof Error ? e.message : 'Could not finish onboarding.'); - setLoading(false); - } - }; - - return ( -
-
-

{t('onboarding.chatProvider')}

-

- {t('onboarding.chatProviderDesc')} -

-
- -
- {t('misc.beta')} -
- - {error &&

{error}

} - - -
- ); -}; - -export default ChatProviderPage; diff --git a/app/src/pages/onboarding/pages/CustomMemoryPage.tsx b/app/src/pages/onboarding/pages/CustomMemoryPage.tsx deleted file mode 100644 index ea3574c18..000000000 --- a/app/src/pages/onboarding/pages/CustomMemoryPage.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import MemoryDataPanel from '../../../components/settings/panels/MemoryDataPanel'; -import { useT } from '../../../lib/i18n/I18nContext'; -import { trackEvent } from '../../../services/analytics'; -import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps'; -import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext'; -import CustomWizardStep from '../steps/CustomWizardStep'; - -const STEP_KEY = 'memory' as const; -const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY); - -const CustomMemoryPage = () => { - const { t } = useT(); - const navigate = useNavigate(); - const { draft, setDraft, completeAndExit } = useOnboardingContext(); - - const [choice, setChoice] = useState( - draft.customChoices?.[STEP_KEY] ?? null - ); - - const persistChoice = (next: CustomStepChoice) => { - setChoice(next); - setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } })); - }; - - const handleFinish = async () => { - trackEvent('onboarding_step_complete', { - step_name: 'custom_memory', - choice: choice ?? 'default', - }); - try { - await completeAndExit(); - } catch (err) { - console.error('[onboarding:custom-memory] completeAndExit failed', err); - } - }; - - return ( - } - choice={choice} - onChoiceChange={persistChoice} - onBack={() => navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX - 1]])} - onContinue={() => void handleFinish()} - continueLabel={t('onboarding.custom.finish')} - /> - ); -}; - -export default CustomMemoryPage; diff --git a/app/src/pages/onboarding/steps/ReferralApplyStep.tsx b/app/src/pages/onboarding/steps/ReferralApplyStep.tsx deleted file mode 100644 index 3d1422cbf..000000000 --- a/app/src/pages/onboarding/steps/ReferralApplyStep.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { useState } from 'react'; - -import Button from '../../../components/ui/Button'; -import { useT } from '../../../lib/i18n/I18nContext'; -import { useCoreState } from '../../../providers/CoreStateProvider'; -import { referralApi } from '../../../services/api/referralApi'; - -interface ReferralApplyStepProps { - onNext: () => void; - onBack?: () => void; - /** Called after a successful apply so onboarding can skip showing this step when navigating back. */ - onApplied?: () => void; -} - -/** - * Optional step: attribute the signed-in user to a referrer via POST /referral/claim. - * Only eligible if the user has not yet subscribed. - */ -const ReferralApplyStep = ({ onNext, onApplied }: ReferralApplyStepProps) => { - const { t } = useT(); - const { refresh } = useCoreState(); - const [code, setCode] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); - - const handleApply = async () => { - const trimmed = code.trim(); - if (!trimmed) return; - - setIsLoading(true); - setError(null); - - try { - await referralApi.claimReferral(trimmed); - setSuccess(true); - try { - await refresh(); - } catch { - console.warn('[onboarding] referral apply: refresh after apply failed'); - } - onApplied?.(); - console.debug('[onboarding] referral code applied'); - setTimeout(() => onNext(), 1200); - } catch (err: unknown) { - let msg = 'Could not apply referral code. Please check and try again.'; - try { - if (err && typeof err === 'object') { - const obj = err as Record; - if (typeof obj.error === 'string' && obj.error.trim()) { - // Try to parse JSON body embedded in the error string - const jsonMatch = String(obj.error).match(/\{.*\}/); - if (jsonMatch) { - const parsed = JSON.parse(jsonMatch[0]) as Record; - if (typeof parsed.error === 'string' && parsed.error.trim()) { - msg = parsed.error; - } - } else if (!obj.error.includes('{')) { - msg = obj.error; - } - } else if (typeof obj.message === 'string' && obj.message.trim()) { - msg = obj.message; - } - } - } catch { - // keep default msg - } - setError(msg); - } finally { - setIsLoading(false); - } - }; - - return ( -
-
-

{t('onboarding.referral')}

-

{t('onboarding.referralDesc')}

-
- - {success ? ( -
-
- - - -
-

- {t('common.success')} -

-
- ) : ( - <> -
- setCode(e.target.value.toUpperCase())} - onKeyDown={e => e.key === 'Enter' && void handleApply()} - placeholder={t('rewards.referralCode')} - className="w-full px-4 py-3 bg-surface-muted border border-line rounded-xl text-center font-mono text-lg tracking-widest text-content placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-content-faint placeholder:tracking-normal placeholder:font-sans placeholder:text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all" - disabled={isLoading} - /> - {error ?

{error}

: null} -
- -
- - -
- - )} -
- ); -}; - -export default ReferralApplyStep; diff --git a/app/src/services/api/mcpSetupApi.test.ts b/app/src/services/api/mcpSetupApi.test.ts deleted file mode 100644 index c97662d4d..000000000 --- a/app/src/services/api/mcpSetupApi.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mockCallCoreRpc = vi.fn(); - -vi.mock('../coreRpcClient', () => ({ - callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args), -})); - -describe('mcpSetupApi', () => { - beforeEach(() => { - mockCallCoreRpc.mockReset(); - }); - - it('search calls mcp_setup_search', async () => { - mockCallCoreRpc.mockResolvedValueOnce({ servers: [], page: 1, total_pages: 1 }); - const { mcpSetupApi } = await import('./mcpSetupApi'); - await mcpSetupApi.search({ query: 'notion' }); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.mcp_setup_search', - params: { query: 'notion' }, - }); - }); - - it('get unwraps the server detail', async () => { - const server = { qualified_name: 'q', display_name: 'd', connections: [] }; - mockCallCoreRpc.mockResolvedValueOnce({ server }); - const { mcpSetupApi } = await import('./mcpSetupApi'); - const result = await mcpSetupApi.get('q'); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.mcp_setup_get', - params: { qualified_name: 'q' }, - }); - expect(result).toEqual(server); - }); - - it('requestSecret returns an opaque ref', async () => { - mockCallCoreRpc.mockResolvedValueOnce({ ref: 'secret://abc', key_name: 'NOTION_API_KEY' }); - const { mcpSetupApi } = await import('./mcpSetupApi'); - const result = await mcpSetupApi.requestSecret({ - key_name: 'NOTION_API_KEY', - prompt: 'Paste your token', - }); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.mcp_setup_request_secret', - params: { key_name: 'NOTION_API_KEY', prompt: 'Paste your token' }, - }); - expect(result.ref).toBe('secret://abc'); - }); - - it('submitSecret forwards the ref + value', async () => { - mockCallCoreRpc.mockResolvedValueOnce({ ref: 'secret://abc', fulfilled: true }); - const { mcpSetupApi } = await import('./mcpSetupApi'); - const result = await mcpSetupApi.submitSecret({ ref_id: 'secret://abc', value: 'tok' }); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.mcp_setup_submit_secret', - params: { ref_id: 'secret://abc', value: 'tok' }, - }); - expect(result.fulfilled).toBe(true); - }); - - it('testConnection returns ok + tools', async () => { - mockCallCoreRpc.mockResolvedValueOnce({ ok: true, tools: [{ name: 'search' }] }); - const { mcpSetupApi } = await import('./mcpSetupApi'); - const result = await mcpSetupApi.testConnection({ - qualified_name: 'q', - env_refs: { NOTION_API_KEY: 'secret://abc' }, - }); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.mcp_setup_test_connection', - params: { qualified_name: 'q', env_refs: { NOTION_API_KEY: 'secret://abc' } }, - }); - expect(result.ok).toBe(true); - }); - - it('installAndConnect commits the install', async () => { - mockCallCoreRpc.mockResolvedValueOnce({ server_id: 'srv-1', status: 'connected', tools: [] }); - const { mcpSetupApi } = await import('./mcpSetupApi'); - const result = await mcpSetupApi.installAndConnect({ - qualified_name: 'q', - env_refs: { NOTION_API_KEY: 'secret://abc' }, - }); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.mcp_setup_install_and_connect', - params: { qualified_name: 'q', env_refs: { NOTION_API_KEY: 'secret://abc' } }, - }); - expect(result.status).toBe('connected'); - }); -}); diff --git a/app/src/services/api/mcpSetupApi.ts b/app/src/services/api/mcpSetupApi.ts deleted file mode 100644 index 1744632d5..000000000 --- a/app/src/services/api/mcpSetupApi.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Typed RPC wrapper for the MCP **setup-agent** domain (`mcp_setup_*`). - * - * These RPCs power the agent-native "install MCP server with assistant" flow: - * search → get → request_secret (out-of-band native prompt) → test_connection → - * install_and_connect. Secret *values* never travel through here as plaintext - * the agent sees — `request_secret` returns an opaque `secret://` ref that - * the UI fulfils via `submitSecret`, and only refs are passed to - * `testConnection` / `installAndConnect`. - * - * Centralises the `openhuman.mcp_setup_` method-name strings so - * components never spell them out directly (issue #3039 gap B4). - */ -import debug from 'debug'; - -import type { - McpTool, - SmitheryServer, - SmitheryServerDetail, -} from '../../components/channels/mcp/types'; -import { callCoreRpc } from '../coreRpcClient'; - -const log = debug('mcp-setup:api'); - -interface SetupSearchResult { - servers: SmitheryServer[]; - page: number; - total_pages: number; -} - -interface SetupGetResult { - server: SmitheryServerDetail; -} - -interface RequestSecretResult { - ref: string; - key_name: string; -} - -interface SubmitSecretResult { - ref: string; - fulfilled: boolean; -} - -interface TestConnectionResult { - ok: boolean; - tools?: McpTool[]; - error?: string; -} - -interface InstallAndConnectResult { - server_id: string; - status: 'connected' | 'installed_disconnected'; - tools?: McpTool[]; - error?: string; -} - -export const mcpSetupApi = { - /** Search all enabled registries (official modelcontextprotocol.io primary, Smithery fallback). */ - search: async (params: { - query?: string; - page?: number; - page_size?: number; - }): Promise => { - log('search params=%o', params); - return callCoreRpc({ method: 'openhuman.mcp_setup_search', params }); - }, - - /** Fetch one server's detail with `required_env_keys` injected. */ - get: async (qualified_name: string): Promise => { - log('get qualified_name=%s', qualified_name); - const result = await callCoreRpc({ - method: 'openhuman.mcp_setup_get', - params: { qualified_name }, - }); - return result.server; - }, - - /** - * Ask the core to request a secret from the user out-of-band. Returns an - * opaque ref; the value is collected by the native SecretPromptDialog and - * submitted via {@link submitSecret}. - */ - requestSecret: async (params: { - key_name: string; - prompt: string; - }): Promise => { - log('request_secret key_name=%s', params.key_name); - return callCoreRpc({ - method: 'openhuman.mcp_setup_request_secret', - params, - }); - }, - - /** UI-side: fulfil a pending `request_secret` with the user-entered value. */ - submitSecret: async (params: { ref_id: string; value: string }): Promise => { - // Intentionally NOT logging the value. - log('submit_secret ref_id=%s', params.ref_id); - return callCoreRpc({ method: 'openhuman.mcp_setup_submit_secret', params }); - }, - - /** Dry-run a candidate install (spawn, list tools, tear down — nothing persisted). */ - testConnection: async (params: { - qualified_name: string; - env_refs: Record; - }): Promise => { - log('test_connection qualified_name=%s', params.qualified_name); - const result = await callCoreRpc({ - method: 'openhuman.mcp_setup_test_connection', - params, - }); - log('test_connection ok=%s', result.ok); - return result; - }, - - /** Commit the install + secrets, then connect and return the tool list. */ - installAndConnect: async (params: { - qualified_name: string; - env_refs: Record; - }): Promise => { - log('install_and_connect qualified_name=%s', params.qualified_name); - const result = await callCoreRpc({ - method: 'openhuman.mcp_setup_install_and_connect', - params, - }); - log('install_and_connect status=%s', result.status); - return result; - }, -}; diff --git a/app/test/OAuthLoginSection.test.tsx b/app/test/OAuthLoginSection.test.tsx deleted file mode 100644 index ea75250de..000000000 --- a/app/test/OAuthLoginSection.test.tsx +++ /dev/null @@ -1,425 +0,0 @@ -/// -/** - * Tests for Google OAuth login via OAuthLoginSection and OAuthProviderButton. - * - * Coverage areas: - * - Section renders all providers including Google - * - Google button initiates OAuth in both Tauri (desktop) and web environments - * - Loading / disabled state management during login - * - Error handling when the backend URL lookup fails - * - dev-mode URL construction (responseType=json query param) - */ -import { act, fireEvent, screen, waitFor } from '@testing-library/react'; -import type { ComponentProps } from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import OAuthLoginSection from '../src/components/oauth/OAuthLoginSection'; -import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; -import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; -import { renderWithProviders } from '../src/test/test-utils'; - -// --------------------------------------------------------------------------- -// Module mocks -// vi.hoisted() ensures mock functions are available inside vi.mock() factories -// (which are hoisted to the top of the file by Vitest). -// --------------------------------------------------------------------------- - -const { - mockGetBackendUrl, - mockOpenUrl, - mockIsTauri, - mockPrepareOAuthLoginLaunch, - mockCheckBackendHealthy, -} = vi.hoisted(() => ({ - mockGetBackendUrl: vi.fn(), - mockOpenUrl: vi.fn(), - mockIsTauri: vi.fn(), - mockPrepareOAuthLoginLaunch: vi.fn(), - // Default to a healthy backend so the pre-flight in OAuthProviderButton - // (added for issue #1985) doesn't short-circuit the OAuth flow these - // tests exercise. - mockCheckBackendHealthy: vi.fn().mockImplementation(async () => { - // Mirror the real checkBackendHealthy contract: never throw — convert a - // getBackendUrl() rejection into the resolve-failure result so tests that - // exercise that error path see the production banner instead of an - // unhandled rejection in the click handler. - try { - const backendUrl = await mockGetBackendUrl(); - return { healthy: true, status: 200, latencyMs: 5, backendUrl }; - } catch { - return { healthy: false, reason: 'resolve-failure', latencyMs: 0 }; - } - }), -})); - -vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl })); -vi.mock('../src/services/backendHealth', () => ({ checkBackendHealthy: mockCheckBackendHealthy })); - -vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl })); - -vi.mock('../src/utils/tauriCommands', async importOriginal => { - const actual = await importOriginal>(); - return { ...actual, isTauri: mockIsTauri }; -}); -vi.mock('../src/utils/oauthAppVersionGate', async importOriginal => { - const actual = await importOriginal>(); - return { ...actual, prepareOAuthLoginLaunch: mockPrepareOAuthLoginLaunch }; -}); - -beforeEach(() => { - mockPrepareOAuthLoginLaunch.mockReset(); - mockPrepareOAuthLoginLaunch.mockResolvedValue(undefined); -}); - -// IS_DEV is set to `true` by the global setup mock of '../utils/config' - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const googleConfig = oauthProviderConfigs.find(p => p.id === 'google')!; - -const renderSection = (props: Partial> = {}) => - renderWithProviders(); - -const renderGoogleButton = (props: Partial> = {}) => - renderWithProviders(); - -// act() with an async callback returns Promise, making await valid. -const clickButton = (btn: HTMLElement) => - act(async () => { - fireEvent.click(btn); - }); - -// --------------------------------------------------------------------------- -// OAuthLoginSection — rendering -// --------------------------------------------------------------------------- - -describe('OAuthLoginSection', () => { - it('renders the "Continue with" heading', () => { - renderSection(); - expect(screen.getByText('Continue with')).toBeInTheDocument(); - }); - - it('renders a button for every configured OAuth provider', () => { - renderSection(); - for (const provider of oauthProviderConfigs) { - expect( - screen.getByRole('button', { name: new RegExp(provider.name, 'i') }) - ).toBeInTheDocument(); - } - }); - - it('renders a Google login button', () => { - renderSection(); - expect(screen.getByRole('button', { name: /google/i })).toBeInTheDocument(); - }); - - it('renders buttons in a 2-column grid', () => { - const { container } = renderSection(); - const grid = container.querySelector('.grid.grid-cols-2'); - expect(grid).toBeInTheDocument(); - expect(grid!.children).toHaveLength(oauthProviderConfigs.length); - }); - - it('applies extra className to the wrapper div', () => { - const { container } = renderSection({ className: 'mt-8' }); - expect(container.firstChild).toHaveClass('mt-8'); - }); - - it('forwards disabled prop to every provider button', () => { - renderSection({ disabled: true }); - for (const btn of screen.getAllByRole('button')) { - expect(btn).toBeDisabled(); - } - }); -}); - -// --------------------------------------------------------------------------- -// Google button — initial render -// --------------------------------------------------------------------------- - -describe('OAuthProviderButton (Google) — rendering', () => { - beforeEach(() => { - mockGetBackendUrl.mockResolvedValue('http://localhost:5005'); - mockIsTauri.mockReturnValue(false); - }); - - it('shows the Google label', () => { - renderGoogleButton(); - expect(screen.getByText('Google')).toBeInTheDocument(); - }); - - it('is enabled by default', () => { - renderGoogleButton(); - expect(screen.getByRole('button', { name: /google/i })).toBeEnabled(); - }); - - it('is disabled when disabled prop is true', () => { - renderGoogleButton({ disabled: true }); - expect(screen.getByRole('button', { name: /google/i })).toBeDisabled(); - }); - - it('renders the Google SVG icon', () => { - const { container } = renderGoogleButton(); - expect(container.querySelector('svg')).toBeInTheDocument(); - }); -}); - -// --------------------------------------------------------------------------- -// Google button — web environment OAuth flow -// --------------------------------------------------------------------------- - -describe('OAuthProviderButton (Google) — web OAuth flow', () => { - const originalLocation = window.location; - - beforeEach(() => { - mockGetBackendUrl.mockResolvedValue('http://localhost:5005'); - mockIsTauri.mockReturnValue(false); - - // Replace window.location so we can assert href changes - delete (window as unknown as Record).location; - (window as unknown as Record).location = { href: '' }; - }); - - afterEach(() => { - (window as unknown as Record).location = originalLocation; - }); - - it('redirects to backend Google OAuth URL on click (web, IS_DEV=true)', async () => { - renderGoogleButton(); - await clickButton(screen.getByRole('button', { name: /google/i })); - - await waitFor(() => { - expect((window.location as unknown as { href: string }).href).toContain( - 'http://localhost:5005/auth/google/login?responseType=json' - ); - }); - }); - - it('does not call openUrl (Tauri) in web mode', async () => { - renderGoogleButton(); - await clickButton(screen.getByRole('button', { name: /google/i })); - - await waitFor(() => expect((window.location as unknown as { href: string }).href).not.toBe('')); - expect(mockOpenUrl).not.toHaveBeenCalled(); - }); - - it('calls getBackendUrl exactly once per click', async () => { - renderGoogleButton(); - await clickButton(screen.getByRole('button', { name: /google/i })); - - await waitFor(() => expect((window.location as unknown as { href: string }).href).not.toBe('')); - expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); - }); -}); - -// --------------------------------------------------------------------------- -// Google button — Tauri (desktop) OAuth flow -// --------------------------------------------------------------------------- - -describe('OAuthProviderButton (Google) — Tauri OAuth flow', () => { - beforeEach(() => { - mockGetBackendUrl.mockResolvedValue('https://api.example.com'); - mockIsTauri.mockReturnValue(true); - mockOpenUrl.mockResolvedValue(undefined); - }); - - it('calls openUrl with the Google OAuth URL (Tauri, IS_DEV=true)', async () => { - renderGoogleButton(); - await clickButton(screen.getByRole('button', { name: /google/i })); - - await waitFor(() => { - expect(mockOpenUrl).toHaveBeenCalledWith( - expect.stringContaining('https://api.example.com/auth/google/login?responseType=json') - ); - }); - }); - - it('does not set window.location.href in Tauri mode', async () => { - const originalHref = window.location.href; - renderGoogleButton(); - await clickButton(screen.getByRole('button', { name: /google/i })); - - await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); - expect(window.location.href).toBe(originalHref); - }); -}); - -// --------------------------------------------------------------------------- -// Google button — loading state -// --------------------------------------------------------------------------- - -describe('OAuthProviderButton (Google) — loading state', () => { - it('shows spinner and "Connecting..." text while login is in progress', async () => { - let resolveBackendUrl!: (_v: string) => void; - mockGetBackendUrl.mockReturnValue( - new Promise(res => { - resolveBackendUrl = res; - }) - ); - mockIsTauri.mockReturnValue(false); - - renderGoogleButton(); - const button = screen.getByRole('button', { name: /google/i }); - - await clickButton(button); - - await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); - expect(document.querySelector('.animate-spin')).toBeInTheDocument(); - expect(button).toBeDisabled(); - - // Settle the promise so React doesn't warn about state updates after unmount - await act(async () => { - resolveBackendUrl('http://localhost:5005'); - }); - }); - - it('does not respond to a second click while already loading', async () => { - let resolveBackendUrl!: (_v: string) => void; - mockGetBackendUrl.mockReturnValue( - new Promise(res => { - resolveBackendUrl = res; - }) - ); - mockIsTauri.mockReturnValue(false); - - renderGoogleButton(); - const button = screen.getByRole('button', { name: /google/i }); - - await clickButton(button); - await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); - - // Second click while loading — getBackendUrl must still be called only once - fireEvent.click(button); - expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); - - await act(async () => { - resolveBackendUrl('http://localhost:5005'); - }); - }); - - it('remains in loading state after successful Tauri openUrl (awaits deep-link callback)', async () => { - // By design: the app calls openUrl() to open the system browser and then waits - // for the deep-link callback. setIsLoading(false) is only called on error, so - // the button intentionally stays in "Connecting..." state. - mockGetBackendUrl.mockResolvedValue('http://localhost:5005'); - mockIsTauri.mockReturnValue(true); - mockOpenUrl.mockResolvedValue(undefined); - - renderGoogleButton(); - await clickButton(screen.getByRole('button', { name: /google/i })); - - await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); - expect(screen.getByText('Connecting...')).toBeInTheDocument(); - expect(document.querySelector('.animate-spin')).toBeInTheDocument(); - }); -}); - -// --------------------------------------------------------------------------- -// Google button — error handling -// --------------------------------------------------------------------------- - -describe('OAuthProviderButton (Google) — error handling', () => { - beforeEach(() => { - mockIsTauri.mockReturnValue(false); - }); - - it('returns to enabled state after getBackendUrl throws', async () => { - mockGetBackendUrl.mockRejectedValue(new Error('network error')); - - renderGoogleButton(); - const button = screen.getByRole('button', { name: /google/i }); - await clickButton(button); - - await waitFor(() => expect(button).toBeEnabled()); - expect(screen.getByText('Google')).toBeInTheDocument(); - }); - - it('does not redirect when getBackendUrl throws (web mode)', async () => { - const originalLocation = window.location; - delete (window as unknown as Record).location; - (window as unknown as Record).location = { href: '' }; - - mockGetBackendUrl.mockRejectedValue(new Error('network error')); - - renderGoogleButton(); - await clickButton(screen.getByRole('button', { name: /google/i })); - - await waitFor(() => expect(screen.getByRole('button', { name: /google/i })).toBeEnabled()); - expect((window.location as unknown as { href: string }).href).toBe(''); - - (window as unknown as Record).location = originalLocation; - }); - - it('does not call openUrl when getBackendUrl throws in Tauri mode', async () => { - mockIsTauri.mockReturnValue(true); - mockGetBackendUrl.mockRejectedValue(new Error('network error')); - - renderGoogleButton(); - await clickButton(screen.getByRole('button', { name: /google/i })); - - await waitFor(() => expect(screen.getByRole('button', { name: /google/i })).toBeEnabled()); - expect(mockOpenUrl).not.toHaveBeenCalled(); - }); - - it('is a no-op when the button is disabled and clicked', async () => { - renderGoogleButton({ disabled: true }); - await clickButton(screen.getByRole('button', { name: /google/i })); - expect(mockGetBackendUrl).not.toHaveBeenCalled(); - }); -}); - -// --------------------------------------------------------------------------- -// URL construction — dev mode query params (IS_DEV=true via global setup mock) -// --------------------------------------------------------------------------- - -describe('OAuthProviderButton (Google) — dev mode URL params', () => { - // The global setup.ts mocks IS_DEV=true, so these assertions run in that context. - - it('appends ?responseType=json to the Google OAuth URL in dev mode (Tauri)', async () => { - mockGetBackendUrl.mockResolvedValue('https://api.example.com'); - mockIsTauri.mockReturnValue(true); - mockOpenUrl.mockResolvedValue(undefined); - - renderGoogleButton(); - await clickButton(screen.getByRole('button', { name: /google/i })); - - await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); - const calledUrl: string = mockOpenUrl.mock.calls[0][0]; - expect(calledUrl).toContain('?responseType=json'); - expect(calledUrl).toContain('https://api.example.com/auth/google/login?responseType=json'); - }); - - it('appends ?responseType=json to the Google OAuth URL in dev mode (web)', async () => { - const originalLocation = window.location; - delete (window as unknown as Record).location; - (window as unknown as Record).location = { href: '' }; - - mockGetBackendUrl.mockResolvedValue('https://api.example.com'); - mockIsTauri.mockReturnValue(false); - - renderGoogleButton(); - await clickButton(screen.getByRole('button', { name: /google/i })); - - await waitFor(() => expect((window.location as unknown as { href: string }).href).not.toBe('')); - expect((window.location as unknown as { href: string }).href).toContain( - 'https://api.example.com/auth/google/login?responseType=json' - ); - - (window as unknown as Record).location = originalLocation; - }); - - it('uses the /auth/google/login path (not another provider)', async () => { - mockGetBackendUrl.mockResolvedValue('https://api.example.com'); - mockIsTauri.mockReturnValue(true); - mockOpenUrl.mockResolvedValue(undefined); - - renderGoogleButton(); - await clickButton(screen.getByRole('button', { name: /google/i })); - - await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); - const calledUrl: string = mockOpenUrl.mock.calls[0][0]; - expect(calledUrl).toContain('/auth/google/login'); - }); -});