refactor(app): remove orphaned frontend modules (#5056) (#5095)

This commit is contained in:
Cyrus Gray
2026-07-24 03:19:59 +03:00
committed by GitHub
parent c9f2582c26
commit 7aca0ea3ad
27 changed files with 0 additions and 2165 deletions
@@ -1,33 +0,0 @@
/**
* AgentWorldThemeBridge — maps OpenHuman design tokens to tiny.place CSS variables.
*
* Injects a `<style>` block that bridges OpenHuman's Tailwind tokens (ocean
* primary `#4A83DD`, sage/amber/coral semantics) to the CSS variables the
* vendored tiny.place components expect. This avoids modifying the vendored
* source: we theme-bridge at the boundary.
*
* For Wave 0 this is a minimal stub. Full token mapping will be expanded when
* the vendored Explore UI is ported in Wave 1.
*/
const THEME_BRIDGE_CSS = `
:root {
/* tiny.place primary → OpenHuman ocean */
--tp-color-primary: #4A83DD;
--tp-color-primary-hover: #3a73cd;
/* tiny.place surface → OpenHuman dark background */
--tp-color-surface: #111827;
--tp-color-surface-secondary: #1f2937;
/* tiny.place text */
--tp-color-text-primary: #f9fafb;
--tp-color-text-secondary: #9ca3af;
/* tiny.place semantic */
--tp-color-success: #10b981;
--tp-color-warning: #f59e0b;
--tp-color-error: #ef4444;
}
`;
export default function AgentWorldThemeBridge() {
return <style>{THEME_BRIDGE_CSS}</style>;
}
-24
View File
@@ -1,24 +0,0 @@
const GoogleIcon = ({ className = 'w-5 h-5' }: { className?: string }) => {
return (
<svg className={className} viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
);
};
export default GoogleIcon;
-36
View File
@@ -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 (
<span className="inline-flex items-center rounded-full bg-violet-50 border border-violet-200 px-2 py-0.5 text-[10px] font-medium text-violet-700 leading-none">
{t('app.connectionBadge.composio')}
</span>
);
}
return (
<span className="inline-flex items-center rounded-full bg-sky-50 border border-sky-200 px-2 py-0.5 text-[10px] font-medium text-sky-700 leading-none">
{t('app.connectionBadge.messaging')}
</span>
);
}
-37
View File
@@ -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<unknown>(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 <div className={className} style={{ height, width }} />;
}
return <div className={className}>{View}</div>;
};
export default LottieAnimation;
@@ -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 (
<aside className="flex w-80 flex-none flex-col border-l border-line bg-surface">
<div className="flex flex-none items-center justify-between border-b border-line-subtle px-4 py-3">
<div>
<h3 className="text-sm font-semibold text-content">{t('accounts.respondQueue.title')}</h3>
<p className="text-xs text-content-muted">
{count} {t('accounts.respondQueue.pending')}
</p>
</div>
<Button
variant="secondary"
size="xs"
onClick={onRefresh}
data-analytics-id="respond-queue-refresh">
{t('common.refresh')}
</Button>
</div>
<div className="flex-1 overflow-y-auto px-3 py-3">
{status === 'loading' && items.length === 0 ? (
<p className="rounded-lg bg-surface-muted px-3 py-2 text-xs text-content-muted">
{t('accounts.respondQueue.loading')}
</p>
) : null}
{status === 'failed' ? (
<p className="rounded-lg bg-coral-50 px-3 py-2 text-xs text-coral-600">
{error ?? t('accounts.respondQueue.loadFailed')}
</p>
) : null}
{items.length === 0 && status !== 'loading' ? (
<p className="rounded-lg bg-surface-muted px-3 py-2 text-xs text-content-muted">
{t('accounts.respondQueue.empty')}
</p>
) : null}
<div className="space-y-2">
{items.slice(0, 30).map(item => (
<button
key={item.id}
type="button"
data-analytics-id={`respond-queue-item-${item.provider}`}
onClick={() => {
if (item.deepLink) {
void openUrl(item.deepLink);
}
}}
className="w-full rounded-xl border border-line bg-surface px-3 py-2 text-left transition-colors hover:bg-surface-hover disabled:cursor-default"
disabled={!item.deepLink}>
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-medium text-content">{queueTitle(item)}</p>
<span className="rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] uppercase text-content-secondary">
{item.provider}
</span>
</div>
{item.snippet ? (
<p className="mt-1 line-clamp-2 text-xs text-content-secondary">{item.snippet}</p>
) : null}
<div className="mt-1 flex items-center justify-between text-[10px] text-content-muted">
<span>{item.senderName ?? item.senderHandle ?? item.accountId}</span>
<span>{relativeTime(item.timestamp)}</span>
</div>
</button>
))}
</div>
</div>
</aside>
);
}
@@ -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 (
<div className="relative group">
{teamUsage ? (
<LimitPill label={t('chat.cycle')} usedPct={usagePct} />
) : (
<span className="text-[10px] text-content-faint animate-pulse">{t('common.loading')}</span>
)}
{teamUsage && (
<div className="absolute bottom-full left-0 mb-2 hidden group-hover:block z-50">
<div className="bg-stone-900 text-white text-[10px] rounded-lg px-3 py-2 shadow-lg whitespace-nowrap space-y-1.5">
<div className="flex items-center justify-between gap-4">
<span className="text-content-faint">{t('chat.cycleSpent')}</span>
<span>
${(teamUsage.cycleSpentUsd ?? 0).toFixed(2)} / $
{(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)}
</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-content-faint">{t('chat.cycleRemaining')}</span>
<span>
${(teamUsage.remainingUsd ?? 0).toFixed(2)} {t('chat.left')}
{teamUsage.cycleEndsAt && (
<span className="text-content-faint ml-1">
{t('chat.resets')} {formatResetTime(teamUsage.cycleEndsAt)}
</span>
)}
</span>
</div>
</div>
</div>
)}
</div>
);
}
-100
View File
@@ -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 (
<div className="flex items-center gap-1.5 text-[11px] leading-none">
{showSessionCounter ? (
<span
className="inline-flex items-center gap-1 rounded-full bg-surface-subtle px-2 py-1 font-mono text-content-secondary ring-1 ring-stone-200/60 dark:ring-neutral-700"
title={t('token.sessionTokens')
.replace('{in}', sessionTokens.inputTokens.toLocaleString())
.replace('{out}', sessionTokens.outputTokens.toLocaleString())
.replace('{turns}', String(sessionTokens.turns))}>
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
{formatTokens(totalTokens)}
</span>
) : null}
{showPlanPill ? (
<button
type="button"
data-analytics-id="chat-token-plan-pill"
onClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
title={planTitle}
className={`inline-flex items-center gap-1 rounded-full px-2 py-1 font-medium ring-1 transition-colors ${planSeverity.bg} ${planSeverity.text} ${planSeverity.ring} hover:opacity-80`}>
{isAtLimit ? t('token.limit') : planSeverity.label}
</button>
) : null}
</div>
);
};
export default TokenUsagePill;
@@ -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: () => <div data-testid="cycle-usage-pill" /> }));
function makeAttachment(overrides: Partial<Attachment> = {}): Attachment {
const blob = new Blob([new Uint8Array(256)], { type: 'image/png' });
@@ -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 (
<div className={`space-y-4 ${className}`}>
<div className="space-y-3">
<h3 className="block w-full text-center text-sm opacity-30 mb-3 font-semibold tracking-wide">
{t('oauth.login.continueWith')}
</h3>
<div className="grid grid-cols-2 gap-3">
{oauthProviderConfigs.map(provider => (
<OAuthProviderButton
key={provider.id}
provider={provider}
disabled={disabled}
className="w-full min-w-0"
/>
))}
</div>
</div>
</div>
);
};
export default OAuthLoginSection;
@@ -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 (
<section className="space-y-4">
<div className="flex flex-col gap-2 rounded-2xl bg-surface p-4 border border-line">
<h3 className="font-headline text-2xl font-bold tracking-tight text-stone-950 dark:text-content">
{t('settings.billing.history.title')}
</h3>
<p className="mt-1 text-sm text-content-muted">{t('settings.billing.history.desc')}</p>
<div className="flex items-center justify-between gap-3">
{hasActive && (
<Button
variant="tertiary"
size="sm"
onClick={onManageSubscription}
className="px-0 font-semibold text-primary-600 hover:bg-transparent hover:text-primary-700 dark:text-primary-300">
{t('settings.billing.history.openPortal')}
</Button>
)}
</div>
</div>
<div className="overflow-hidden rounded-[28px] bg-surface shadow-[0_24px_70px_rgba(15,23,42,0.06)] ring-1 ring-stone-950/5">
{transactionRows.length > 0 ? (
<div className="divide-y divide-line-subtle dark:divide-neutral-800">
{transactionRows.map(transaction => {
const isEarn = transaction.type === 'EARN';
return (
<div
key={transaction.id}
className="grid gap-3 px-5 py-4 text-sm sm:grid-cols-[1.3fr_0.8fr_0.7fr_0.8fr] sm:items-center">
<div>
<p className="font-semibold text-stone-950 dark:text-content">
{transaction.action}
</p>
<p className="mt-1 text-xs text-content-muted">
{new Date(transaction.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
<div className="text-content-muted">{transaction.type}</div>
<div
className={`font-semibold ${isEarn ? 'text-sage-600 dark:text-sage-300' : 'text-stone-950 dark:text-content'}`}>
{isEarn ? '+' : '-'}${Math.abs(transaction.amountUsd).toFixed(2)}
</div>
<div className="sm:text-right">
<span className="rounded-full bg-surface-subtle px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-content-muted">
{t('settings.billing.history.posted')}
</span>
</div>
</div>
);
})}
</div>
) : (
<div className="px-5 py-8 text-sm text-content-muted">
{t('settings.billing.history.empty')}
</div>
)}
</div>
</section>
);
}
@@ -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 (
<>
<section className="space-y-4">
<PayAsYouGoCard
creditBalance={creditBalance}
isLoadingCredits={isLoadingCredits}
isToppingUp={isToppingUp}
onTopUp={onTopUp}
/>
</section>
<AutoRechargeSection
arSettings={arSettings}
arLoading={arLoading}
arError={arError}
arSaving={arSaving}
arThreshold={arThreshold}
arAmount={arAmount}
arWeeklyLimit={arWeeklyLimit}
arDirty={arDirty}
setArThreshold={setArThreshold}
setArAmount={setArAmount}
setArWeeklyLimit={setArWeeklyLimit}
onArToggle={onArToggle}
onArSave={onArSave}
cards={cards}
cardsLoading={cardsLoading}
confirmDeleteId={confirmDeleteId}
deletingCardId={deletingCardId}
settingDefaultId={settingDefaultId}
setConfirmDeleteId={setConfirmDeleteId}
onSetDefault={onSetDefault}
onDeleteCard={onDeleteCard}
onAddCard={onAddCard}
/>
</>
);
}
@@ -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 (
<SubscriptionPlans
currentTier={currentTier}
billingInterval={billingInterval}
setBillingInterval={setBillingInterval}
paymentMethod={paymentMethod}
setPaymentMethod={setPaymentMethod}
isPurchasing={isPurchasing}
purchasingTier={purchasingTier}
paymentConfirmed={paymentConfirmed}
onUpgrade={onUpgrade}
/>
);
}
@@ -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<string, ResourceGroup>();
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<string, string> = {
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 (
<p className="text-xs text-content-faint italic">
{t('skills.resource.tree.empty')}
</p>
);
}
return (
<div className="space-y-3">
{groups.map(group => (
<div
key={group.key}
className="rounded-xl border border-line bg-surface-muted overflow-hidden">
<div className="flex items-center justify-between border-b border-line bg-surface-muted px-3 py-1.5">
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
{t(GROUP_LABEL_KEYS[group.key] ?? group.key)}
</h4>
<span className="text-[10px] text-content-faint font-mono">
{group.items.length}
</span>
</div>
<ul className="divide-y divide-line-subtle dark:divide-neutral-800">
{group.items.map(path => {
const isSelected = selectedPath === path;
return (
<li key={path}>
<button
type="button"
onClick={() => {
log('click path=%s', path);
onSelect(path);
}}
className={`w-full truncate px-3 py-2 text-left text-[11px] font-mono transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-primary-500 ${
isSelected
? 'bg-primary-50 dark:bg-primary-500/15 text-primary-700 dark:text-primary-300'
: 'text-content-secondary hover:bg-white dark:hover:bg-surface-muted/60'
}`}
title={path}>
{path}
</button>
</li>
);
})}
</ul>
</div>
))}
</div>
);
}
-51
View File
@@ -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<string>(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();
});
});
-33
View File
@@ -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<string | null>(null);
useEffect(() => {
let cancelled = false;
getBackendUrl()
.then(resolved => {
if (!cancelled) setUrl(resolved);
})
.catch(() => {
if (!cancelled) setUrl(null);
});
return () => {
cancelled = true;
};
}, []);
return url;
}
-191
View File
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((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<unknown>;
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');
});
});
-134
View File
@@ -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<T> {
data: T | null;
loading: boolean;
error: Error | null;
isRefetching: boolean;
refetch: () => Promise<T | undefined>;
}
function normalizeError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function useThreadQuery<T>(
queryName: string,
load: () => Promise<T>,
enabled = true,
queryKey = queryName
): ThreadQueryState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(enabled);
const [error, setError] = useState<Error | null>(null);
const [isRefetching, setIsRefetching] = useState(false);
const requestIdRef = useRef(0);
const dataRef = useRef<T | null>(null);
const queryKeyRef = useRef(queryKey);
const execute = useCallback(
async (reason: 'initial' | 'refetch'): Promise<T | undefined> => {
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<ThreadsListData> {
const load = useCallback(() => threadApi.getThreads(), []);
return useThreadQuery('threads.list', load);
}
export function useThreadMessages(threadId?: string | null): ThreadQueryState<ThreadMessagesData> {
const normalizedThreadId = threadId?.trim() || null;
const load = useCallback(
() => threadApi.getThreadMessages(normalizedThreadId ?? ''),
[normalizedThreadId]
);
return useThreadQuery(
'threads.messages',
load,
normalizedThreadId !== null,
`threads.messages:${normalizedThreadId ?? 'disabled'}`
);
}
-29
View File
@@ -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.`;
-1
View File
@@ -40,7 +40,6 @@ const Onboarding = () => {
<Route path="custom/embeddings" element={<CustomEmbeddingsPage />} />
<Route path="custom/activity" element={<CustomActivityPage />} />
<Route path="custom/vault" element={<VaultSetupStep />} />
{/* <Route path="custom/memory" element={<CustomMemoryPage />} /> */}
<Route path="*" element={<Navigate to="welcome" replace />} />
</Route>
</Routes>
@@ -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 (
<div className="flex flex-col gap-3">
<p className="text-xs text-content-secondary leading-relaxed">
{t('onboarding.custom.configureLater')}
</p>
<p className="text-[11px] text-content-muted" data-testid="configure-later-hint">
{t('onboarding.custom.openSettings')}:{' '}
<code className="text-content-secondary">{settingsHref}</code>
</p>
</div>
);
};
export default ConfigureLaterCallout;
@@ -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 <ApiKeysStep onNext={() => void finish(false)} onSkip={() => void finish(true)} />;
};
export default ApiKeysPage;
@@ -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<string | null>(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 (
<div
data-testid="onboarding-chat-provider-step"
className="rounded-2xl border border-line bg-surface p-8 shadow-soft animate-fade-up">
<div className="text-center mb-5">
<h1 className="text-xl font-bold mb-2 text-content">{t('onboarding.chatProvider')}</h1>
<p className="text-content-muted text-sm leading-relaxed max-w-sm mx-auto">
{t('onboarding.chatProviderDesc')}
</p>
</div>
<div className="rounded-xl border border-dashed border-line bg-surface-muted p-6 mb-5 text-center text-sm text-content-muted">
{t('misc.beta')}
</div>
{error && <p className="text-coral-400 text-sm mb-3 text-center">{error}</p>}
<OnboardingNextButton
onClick={handleFinish}
loading={loading}
loadingLabel={t('common.finish')}
label={t('common.finish')}
/>
</div>
);
};
export default ChatProviderPage;
@@ -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<CustomStepChoice | null>(
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 (
<CustomWizardStep
testId="onboarding-custom-memory-step"
stepIndex={STEP_INDEX}
stepCount={CUSTOM_WIZARD_STEPS.length}
title={t('onboarding.custom.memory.title')}
subtitle={t('onboarding.custom.memory.subtitle')}
defaultDescription={t('onboarding.custom.memory.defaultDesc')}
configureDescription={t('onboarding.custom.memory.configureDesc')}
configureContent={<MemoryDataPanel embedded />}
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;
@@ -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<string | null>(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<string, unknown>;
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<string, unknown>;
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 (
<div className="rounded-2xl border border-line bg-surface p-8 shadow-soft animate-fade-up">
<div className="text-center mb-6">
<h1 className="text-xl font-bold mb-2 text-content">{t('onboarding.referral')}</h1>
<p className="text-content-secondary text-sm">{t('onboarding.referralDesc')}</p>
</div>
{success ? (
<div className="text-center py-4">
<div className="w-12 h-12 bg-sage-50 dark:bg-sage-500/10 rounded-full flex items-center justify-center mx-auto mb-3">
<svg
className="w-6 h-6 text-sage-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<p className="text-sage-600 dark:text-sage-300 font-medium text-sm">
{t('common.success')}
</p>
</div>
) : (
<>
<div className="mb-4">
<input
type="text"
value={code}
onChange={e => 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 ? <p className="text-coral-500 text-xs mt-2 text-center">{error}</p> : null}
</div>
<div className="flex gap-3">
<Button variant="secondary" onClick={onNext} disabled={isLoading} className="flex-1">
{t('onboarding.skip')}
</Button>
<Button
variant="primary"
onClick={() => void handleApply()}
disabled={isLoading || !code.trim()}
className="flex-1">
{isLoading ? t('common.loading') : t('common.submit')}
</Button>
</div>
</>
)}
</div>
);
};
export default ReferralApplyStep;
-88
View File
@@ -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');
});
});
-129
View File
@@ -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://<hex>` ref that
* the UI fulfils via `submitSecret`, and only refs are passed to
* `testConnection` / `installAndConnect`.
*
* Centralises the `openhuman.mcp_setup_<function>` 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<SetupSearchResult> => {
log('search params=%o', params);
return callCoreRpc<SetupSearchResult>({ method: 'openhuman.mcp_setup_search', params });
},
/** Fetch one server's detail with `required_env_keys` injected. */
get: async (qualified_name: string): Promise<SmitheryServerDetail> => {
log('get qualified_name=%s', qualified_name);
const result = await callCoreRpc<SetupGetResult>({
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<RequestSecretResult> => {
log('request_secret key_name=%s', params.key_name);
return callCoreRpc<RequestSecretResult>({
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<SubmitSecretResult> => {
// Intentionally NOT logging the value.
log('submit_secret ref_id=%s', params.ref_id);
return callCoreRpc<SubmitSecretResult>({ 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<string, string>;
}): Promise<TestConnectionResult> => {
log('test_connection qualified_name=%s', params.qualified_name);
const result = await callCoreRpc<TestConnectionResult>({
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<string, string>;
}): Promise<InstallAndConnectResult> => {
log('install_and_connect qualified_name=%s', params.qualified_name);
const result = await callCoreRpc<InstallAndConnectResult>({
method: 'openhuman.mcp_setup_install_and_connect',
params,
});
log('install_and_connect status=%s', result.status);
return result;
},
};
-425
View File
@@ -1,425 +0,0 @@
/// <reference types="@testing-library/jest-dom/vitest" />
/**
* 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<Record<string, unknown>>();
return { ...actual, isTauri: mockIsTauri };
});
vi.mock('../src/utils/oauthAppVersionGate', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
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<ComponentProps<typeof OAuthLoginSection>> = {}) =>
renderWithProviders(<OAuthLoginSection {...props} />);
const renderGoogleButton = (props: Partial<ComponentProps<typeof OAuthProviderButton>> = {}) =>
renderWithProviders(<OAuthProviderButton provider={googleConfig} {...props} />);
// act() with an async callback returns Promise<void>, 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<string, unknown>).location;
(window as unknown as Record<string, unknown>).location = { href: '' };
});
afterEach(() => {
(window as unknown as Record<string, unknown>).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<string>(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<string>(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<string, unknown>).location;
(window as unknown as Record<string, unknown>).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<string, unknown>).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<string, unknown>).location;
(window as unknown as Record<string, unknown>).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<string, unknown>).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');
});
});